diff --git a/.config/omo/sounds/omo-done.wav b/.config/omo/sounds/omo-done.wav new file mode 100644 index 0000000..5d18195 Binary files /dev/null and b/.config/omo/sounds/omo-done.wav differ diff --git a/.config/omo/sounds/omo-error.wav b/.config/omo/sounds/omo-error.wav new file mode 100644 index 0000000..5df192d Binary files /dev/null and b/.config/omo/sounds/omo-error.wav differ diff --git a/.config/omo/sounds/omo-input.wav b/.config/omo/sounds/omo-input.wav new file mode 100644 index 0000000..759636d Binary files /dev/null and b/.config/omo/sounds/omo-input.wav differ diff --git a/.config/omo/sounds/recipes.toml b/.config/omo/sounds/recipes.toml new file mode 100644 index 0000000..ed7a4e6 --- /dev/null +++ b/.config/omo/sounds/recipes.toml @@ -0,0 +1,19 @@ +# omo's notification sounds. omo-notify and omo-remote play them through +# omo-sound. Re-render after editing a recipe: sfx recipes.toml +# Recipe format: ~/.omo/agent/skills/sound-design/SKILL.md +loudness = -26.0 # LUFS, the level of macOS's Glass alert + +[sound.omo-done] +about = "quick rising major arpeggio: task finished" +tone = { bright = 1.0, decay = 1.0 } +notes = [[0.00, "C6", 0.65, -0.2], [0.07, "E6", 0.75, 0.0], [0.14, "G6", 1.00, 0.2]] + +[sound.omo-input] +about = "rising fourth, second note louder: needs your input" +tone = { bright = 1.25, decay = 0.8 } +notes = [[0.00, "E6", 0.80, 0.0], [0.15, "A6", 1.00, 0.0]] + +[sound.omo-error] +about = "low descending minor third, muted: stopped with an error" +tone = { bright = 0.35, decay = 0.7 } +notes = [[0.00, "E5", 1.00, 0.0], [0.16, "C#5", 0.90, 0.0]] diff --git a/.local/bin/omo-notify b/.local/bin/omo-notify index b6e3ee3..e2ebc11 100755 --- a/.local/bin/omo-notify +++ b/.local/bin/omo-notify @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Ding, tab marker and notification for omo (senpi) hooks: UserPromptSubmit, # Stop and Notification. Reads the hook JSON payload on stdin. Wired up in -# ~/.omo/agent/hooks.json. The sound plays through afplay, so Focus modes and -# notification permissions cannot mute it; the banner is a silent extra. +# ~/.omo/agent/hooks.json. The sound plays through omo-sound (afplay on macOS), +# so Focus modes and notification permissions cannot mute it; the banner is a +# silent extra. # Ghostty posts the banner, so clicking it opens the tab it came from, and # omo-status then selects the session's pane. omo-status also colours that tab: # blue working, amber waiting for you, green finished, red error. @@ -32,13 +33,13 @@ case "$event" in case "$(field '.stopReason')" in # Esc/interrupts are your own doing; no need to be told about them. aborted) state="clear" ;; - error) state="error" message="Stopped with an error" sound="Glass" ;; - *) state="done" message="Task finished" sound="Glass" ;; + error) state="error" message="Stopped with an error" sound="omo-error" ;; + *) state="done" message="Task finished" sound="omo-done" ;; esac ;; Notification) if [[ "$(field '.kind')" == "ask-user-asked" ]]; then - state=input message="Needs your input: $(field '.title')" sound="Ping" + state=input message="Needs your input: $(field '.title')" sound="omo-input" else # Answered or timed out: the agent carries on. state=working @@ -50,8 +51,8 @@ case "$event" in esac # Background the ding so the hook returns immediately; redirect output so the -# hook runner is not left waiting on afplay's inherited pipes. -[[ -n "$sound" ]] && afplay "/System/Library/Sounds/${sound}.aiff" >/dev/null 2>&1 & +# hook runner is not left waiting on the player's inherited pipes. +[[ -n "$sound" ]] && "$HOME/.local/bin/omo-sound" "$sound" >/dev/null 2>&1 & # Find the tmux pane omo runs in: hooks run detached without TMUX_PANE, so walk # up the process tree to the first ancestor whose tty is a tmux pane. diff --git a/.local/bin/omo-remote b/.local/bin/omo-remote index 8fb40a2..0dc7c77 100755 --- a/.local/bin/omo-remote +++ b/.local/bin/omo-remote @@ -124,9 +124,9 @@ case ${1:-} in [[ $tags == *,omo,* ]] || exit 0 # Claude Code posts to the same topic (($(date +%s) - ${NTFY_TIME:-0} <= fresh)) || exit 0 case $tags in - *,question,*) state=input sound=Ping ;; - *,x,*) state=error sound=Glass ;; - *) state="done" sound=Glass ;; + *,question,*) state=input sound=omo-input ;; + *,x,*) state=error sound=omo-error ;; + *) state="done" sound=omo-done ;; esac # "attached" only says some terminal shows the session: a tmux pane here # (the title path has it), a plain tab, or a mosh-server left behind. @@ -135,7 +135,7 @@ case ${1:-} in logline "ntfy $state ${NTFY_TITLE:-} skipped: a tmux pane got it through the title" exit 0 fi - afplay "/System/Library/Sounds/${sound}.aiff" >/dev/null 2>&1 & + "$HOME/.local/bin/omo-sound" "$sound" >/dev/null 2>&1 & # Ghostty posts the banner from a plain tab connected with mosh or ssh, so # clicking it lands there, or else from the tmux tab you used last. target=$(plain_tab_tty "$tags") diff --git a/.local/bin/omo-sound b/.local/bin/omo-sound new file mode 100755 index 0000000..0fcb778 --- /dev/null +++ b/.local/bin/omo-sound @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Play one of omo's notification sounds (omo-done, omo-input, omo-error) or any +# audio file with whatever player this machine has: afplay on macOS, else +# PipeWire, PulseAudio, ALSA or ffplay on Linux. It blocks until playback ends, +# so hooks that must not wait run it in the background. +# +# The sounds are rendered by sfx from .config/omo/sounds/recipes.toml in the +# dotfiles checkout this script is linked from. +# +# Usage: omo-sound NAME|FILE + +sound=${1:?usage: omo-sound NAME|FILE} +if [[ ! -f $sound ]]; then + sound="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.config/omo/sounds/$sound.wav" +fi +if [[ ! -f $sound ]]; then + echo "omo-sound: no such sound or file: $1" >&2 + exit 1 +fi + +if command -v afplay >/dev/null; then + exec afplay "$sound" +elif command -v pw-play >/dev/null; then + exec pw-play "$sound" +elif command -v paplay >/dev/null; then + exec paplay "$sound" +elif command -v aplay >/dev/null; then + exec aplay -q "$sound" +elif command -v ffplay >/dev/null; then + exec ffplay -nodisp -autoexit -loglevel quiet "$sound" +fi +echo "omo-sound: no audio player found (afplay, pw-play, paplay, aplay or ffplay)" >&2 +exit 1 diff --git a/README.md b/README.md index 6be3df7..4830e8e 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ OpenCode is retired. Its agents, commands and plugins are left in `.config/openc - `omo.jsonc` (linked as `~/.omo/omo.jsonc`): model routing for the main session, task categories and curated agents. `design-critique-a` and `design-critique-b` are the two lanes the Impeccable critique delegates its assessments to. - `hooks.json` (linked as `~/.omo/agent/hooks.json`): runs `omo-notify` when you send a prompt, when a session stops and when it asks you something, for the sound, the notification and the coloured tab marker. It also runs `owt hook` with each prompt, which tells an agent in an `owt` workspace its current tab title so it retitles the tab as the work changes. omo runs a new hook only after you approve it with `/hooks trust ` (ids are in `/hooks list`). +- `sounds/`: omo's own notification sounds, rendered offline by `sfx` from `recipes.toml`. They are pure synthesis, so they contain no third-party audio and play on Linux too. `omo-sound` plays them for `omo-notify` and `omo-remote`. Edit a recipe, run `sfx recipes.toml` in that directory, and the next notification uses the new sound. - `prompts/` (linked as `~/.omo/agent/prompts`): slash commands. - `/linear-sync [notes]` posts a progress comment on the workspace's Linear issues and sets them to In Progress, or In Review once a PR is open. - `/owt-done [notes]` finishes a workspace after its PR merges: it comments on each issue and moves it to Done, then runs `owt done --detach`. It asks before discarding uncommitted or unmerged work. @@ -232,6 +233,7 @@ Custom scripts and binaries are stored in `.local/bin/`: - `omo-notify`: Sound, tab marker and notification for OmO's hooks. Ghostty posts the notification, so clicking it opens the session's tab and pane - `omo-status`: Coloured marker in front of the Ghostty tab title for each tmux session running omo: ๐Ÿ”ด stopped with an error, ๐ŸŸก waiting for your answer, ๐ŸŸข finished, ๐Ÿ”ต working. A tab with several agents shows the most urgent one; focusing the tab clears red, amber and green, and undoes any title you gave the tab by hand so it always shows its session name - `omo-remote`: The same sound, tab marker and notification for omo sessions on a remote server (flowstate-remote). The server's `omo-notify` (kept on the server in `~/.local/bin`, not in this repository) writes the session's state into its terminal title, marker first (`๐ŸŸข studio ยท omo:done:...`). mosh drops notifications but carries titles, and so does ssh, so tmux's `pane-title-changed` hook passes the event to `omo-notify` for the pane running mosh or ssh. Everything else reaches you through ntfy: `omo-remote install` starts a launchd agent that subscribes to the topic and plays the sound and posts the notification for omo pushes, unless a tmux pane here already handled that event through its title, so nothing arrives twice. That covers sessions no terminal is showing and `fs` in a plain Ghostty tab, whose title shows the marker and which posts the notification itself (the coloured tab marker needs tmux). The agent reads `NTFY_URL`, `NTFY_TOPIC` and `NTFY_TOKEN` from `~/.config/ntfy/omo-remote.env` (chmod 600, not in this repository) and needs `brew "ntfy"` +- `omo-sound`: Plays an omo notification sound by name (`omo-done`, `omo-input`, `omo-error`) or any audio file, with afplay on macOS and pw-play, paplay, aplay or ffplay on Linux - `sfx`: Renders notification sounds and short effects from a TOML recipe, offline and from synthesis alone, normalised to one loudness, as WAV plus optional MP3 and MIDI. A uv script; `sfx --help` lists the options and the `sound-design` skill documents the recipe format - `docker-nuke`: Force-restart a wedged Docker Desktop, capturing diagnostics to `~/.local/state/docker-nuke/` first (`--dry-run` to preview, `--no-restart` to leave it down, `--deep` for root helpers)