Compare commits

...

3 Commits

Author SHA1 Message Date
David Ibia 3975a5b97d feat(zsh): add fsimg to send clipboard images to flowstate-remote agents
fsimg saves the clipboard image into the Mutagen-synced share (~/Company/flowstate-share here, /home/ubuntu/share on the server), flushes the sync so the file is there before you paste, and copies its server path to the clipboard for an agent's chat.
2026-10-18 13:12:34 +01:00
David Ibia 66d1d21011 feat(omo): play omo's own synthesised notification sounds
omo-notify and omo-remote played Apple's Glass and Ping through afplay, which exist only on macOS and are not ours to ship. They now play omo-done (task finished), omo-input (needs your input) and omo-error (stopped with an error), rendered by sfx from .config/omo/sounds/recipes.toml at the same loudness as Glass. The new omo-sound plays a sound by name or any audio file, with afplay on macOS and pw-play, paplay, aplay or ffplay on Linux, and finds the sounds through the dotfiles checkout it is linked from.
2026-10-18 13:12:34 +01:00
David Ibia 5805f45358 feat(bin): add sfx to render notification sounds offline from TOML recipes
sfx is a single-file uv script that synthesises sounds from sine partials and seeded noise, so its output contains no samples, SoundFonts or model output, renders without a network once uv has installed numpy and mido, and comes out byte-identical on every run. Each sound in a recipe is normalised to a target loudness (ITU-R BS.1770, default -26 LUFS, the level of the macOS Glass alert) and written as WAV, with optional MP3 through ffmpeg and a MIDI file of the same notes. Unknown keys and bad note names stop the render with an error naming the table.

The new sound-design skill documents the recipe format and how to design notification sounds; link-ai-configs.sh links it for omo and Claude Code.
2026-10-18 13:12:34 +01:00
13 changed files with 476 additions and 15 deletions
+1
View File
@@ -17,6 +17,7 @@ OpenCode-era evaluation notes and rollback steps are in git history at
| `web-design-guidelines` | [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) (`skills/web-design-guidelines/SKILL.md`) | `063bee94c3f4df8453406c830b0a7df0f2860278` | per upstream |
| ten marketing and copy skills | [coreyhaines31/marketingskills](https://github.com/coreyhaines31/marketingskills) (`skills/`) | `b9ba399dd88b082b926e261e8ccfb843d20aa066` | MIT, copied into each skill |
| `frontend-workflow` | local | - | - |
| `sound-design` | local | - | - |
The ten marketing skills are `product-marketing`, `copywriting`, `copy-editing`,
`emails`, `cold-email`, `content-strategy`, `seo-audit`, `ai-seo`, `launch` and
+61
View File
@@ -0,0 +1,61 @@
---
name: sound-design
description: Creates original notification sounds, UI sounds, short sound effects and MIDI offline with the sfx recipe renderer, so the audio contains no third-party material and works on macOS and Linux. Use when asked to make, tweak or wire up a ding, chime, alert, notification or effect sound, including omo's own notification sounds.
---
# Sound design with sfx
`sfx` (`~/.local/bin/sfx`, a uv script in the dotfiles) renders sounds from a TOML recipe. Everything is synthesised from sine partials and seeded noise, and the same recipe renders to byte-identical files on every run and machine. `sfx --help` lists the options.
## Rules
- Synthesise only. Never use samples, SoundFonts, sound libraries, online generators or AI audio models: the point is audio with no third-party material that renders without a network.
- You cannot hear. Read the numbers `sfx` prints, then ask the user to audition the file before calling a sound done, and never change a sound they approved without them listening again.
- Keep the recipe next to the rendered files and commit both. The recipe is the source.
## Workflow
1. Write or edit a recipe file (format below).
2. Run `sfx recipes.toml [OUT_DIR] [--only NAME ...]`. Files go next to the recipe unless OUT_DIR is given. The first run lets uv install numpy and mido once.
3. Check each report line: length, loudness and peak, e.g. `omo-done: 2.22s, -26.0 LUFS, peak -15.8 dBFS`. A note saying the peak ceiling held it back means the sound is quieter than the target; lower `bright` or spread the notes out.
4. Ask the user to listen with `omo-sound path/to/NAME.wav` (afplay on macOS; pw-play, paplay, aplay or ffplay on Linux), then iterate on the notes and tone.
## Recipe format
```toml
loudness = -26.0 # target LUFS for every sound (default -26, the level of the macOS Glass alert)
extras = ["mp3", "mid"] # optional: MP3 needs ffmpeg; MIDI exports the notes only
program = 11 # General MIDI program for the .mid files (default 11, vibraphone)
[sound.example-chime]
about = "what it is for"
notes = [[0.00, "G5", 0.75, -0.15], [0.11, "E6", 1.00, 0.15]] # [start s, note, velocity 0..1, pan -1..1]
tone = { bright = 0.6, decay = 1.3 }
room = { rt60 = 0.7, wet = 0.22 }
whoosh = { start = 0.0, duration = 0.38, from_hz = 300, to_hz = 5000, gain = 0.6, pan = 0.0 }
loudness = -24.0 # optional per-sound override
```
- **notes**: a note is a name (`E6`, `C#5`, `Bb4`; C4 is middle C, 60) or a MIDI number. Each one is a struck-bar ding.
- **tone**: `bright` sets how much of the metallic strike comes through (0.35 is muted, 1.0 neutral, 1.25 sparkly). `decay` scales the ring time; at 1.0 a note rings out in about two seconds.
- **room**: a small synthetic room. `rt60` is its decay time (0.05 to 5 s) and `wet` its level; set `wet = 0` for a dry sound.
- **whoosh**: band-passed noise sweeping from `from_hz` to `to_hz` (20 to 8000), for air, swishes and sent effects. A sound needs notes, a whoosh, or both.
Unknown keys and bad notes stop the render with an error naming the table and key.
## Designing notification sounds
- Keep the audible part under a second, with two or three notes. A rising contour reads as positive or as a call for attention; a falling one reads as a problem. Brighter tones feel more urgent.
- Tell events apart by contour and timbre, not just pitch: for example a rising arpeggio for done, a brighter rising pair for a question, and a muted falling pair for an error.
- Keep notes between about C5 and C7. Higher gets shrill and lower gets muddy on laptop speakers.
- Give a set of sounds one loudness and let their shape carry the meaning.
## Formats
- **WAV** (always written, 48 kHz 16-bit stereo): desktop playback and native notifications. macOS plays a custom notification sound only from inside the app bundle.
- **MP3** (`extras = ["mp3"]`): browsers. Browsers refuse to play audio until the user has interacted with the page, and web push notifications cannot carry a custom sound at all.
- **MIDI** (`extras = ["mid"]`): the same notes for a DAW. A whoosh has no MIDI form.
## omo's own sounds
`~/dotfiles/.config/omo/sounds/recipes.toml` renders `omo-done`, `omo-input` and `omo-error`. `omo-notify` (local hooks) and `omo-remote` (events from flowstate-remote) play them through `omo-sound NAME`. To change one, edit the recipe, run `sfx recipes.toml` in that directory, and the next event uses the new sound. Keep the three names, because the scripts map events to them.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -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]]
+8 -7
View File
@@ -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.
+4 -4
View File
@@ -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")
+33
View File
@@ -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
Executable
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy>=1.26", "mido>=1.3"]
# ///
"""Render notification sounds and short effects from a TOML recipe file.
Every sound is synthesised from sine partials and seeded noise: no samples,
SoundFonts or model output go in, nothing touches the network once uv has
installed numpy and mido, and a recipe renders to the same audio on every run
and machine. Each sound is normalised to a target loudness in LUFS
(ITU-R BS.1770), so a set of sounds plays at one consistent level.
"""
import argparse
import re
import shutil
import subprocess
import sys
import tomllib
import wave
import zlib
from pathlib import Path
import mido
import numpy as np
SR = 48_000
PEAK_CEILING_DBFS = -1.0
TAIL_DB = -55.0
DEFAULT_LOUDNESS = -26.0
DEFAULT_PROGRAM = 11
NOTE_NAME = re.compile(r"([A-Ga-g])([#b]?)(-?\d)")
SEMITONES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
# BS.1770 K-weighting at 48 kHz as ((b0, b1, b2), (a1, a2)): a high shelf, then a high-pass.
K_WEIGHTING = (
((1.53512485958697, -2.69169618940638, 1.19839281085285), (-1.69065929318241, 0.73248077421585)),
((1.0, -2.0, 1.0), (-1.99004745483398, 0.99007225036621)),
)
TOP_KEYS = {"loudness", "extras", "program", "sound"}
SOUND_KEYS = {"about", "notes", "tone", "whoosh", "room", "loudness"}
TONE_KEYS = {"bright", "decay"}
ROOM_KEYS = {"rt60", "wet"}
WHOOSH_KEYS = {"start", "duration", "from_hz", "to_hz", "gain", "pan"}
EXTRAS = {"mp3", "mid"}
class RecipeError(Exception):
pass
def check_keys(table, allowed, where):
if not isinstance(table, dict):
raise RecipeError(f"{where} must be a table")
unknown = sorted(set(table) - allowed)
if unknown:
raise RecipeError(f"{where}: unknown key {', '.join(unknown)} (allowed: {', '.join(sorted(allowed))})")
def midi_note(value, where):
if isinstance(value, int) and not isinstance(value, bool):
number = value
else:
match = NOTE_NAME.fullmatch(str(value))
if not match:
raise RecipeError(f"{where}: {value!r} is neither a MIDI note number nor a note name like E6")
letter, accidental, octave = match.groups()
number = 12 * (int(octave) + 1) + SEMITONES[letter.upper()] + {"#": 1, "b": -1, "": 0}[accidental]
if not 0 <= number <= 127:
raise RecipeError(f"{where}: note {value!r} is outside the MIDI range")
return number
def hz(note):
return 440.0 * 2 ** ((note - 69) / 12)
def fade_in(x, seconds):
n = min(len(x), int(seconds * SR))
x[:n] *= 0.5 - 0.5 * np.cos(np.linspace(0, np.pi, n))
return x
def ding(rng, note, bright, decay):
"""Struck-bar tone: decaying partials, a few inharmonic ones for the strike."""
partials = ( # (frequency ratio, amplitude, decay time constant in seconds)
(1.0, 1.0, 0.32 * decay),
(2.0, 0.30 * bright, 0.16 * decay),
(2.76, 0.16 * bright, 0.07 * decay),
(5.40, 0.07 * bright, 0.03 * decay),
(8.93, 0.035 * bright, 0.015 * decay),
)
t = np.arange(int(7 * partials[0][2] * SR)) / SR # long enough to decay to about -60 dB
f0 = hz(note)
out = np.zeros_like(t)
for ratio, amp, tau in partials:
if f0 * ratio < SR * 0.45:
out += amp * np.sin(2 * np.pi * f0 * ratio * t + rng.uniform(0, 2 * np.pi)) * np.exp(-t / tau)
n = int(0.004 * SR) # soft mallet tick
tick = np.convolve(rng.standard_normal(n), np.ones(8) / 8, mode="same")
out[:n] += 0.04 * bright * tick * np.linspace(1, 0, n)
return fade_in(out, 0.003)
def whoosh(rng, duration, from_hz, to_hz, q=2.0):
"""Noise through a band-pass whose centre sweeps from from_hz to to_hz."""
n = int(duration * SR)
noise = rng.standard_normal(n).tolist()
coeff = (2 * np.sin(np.pi * np.geomspace(from_hz, to_hz, n) / SR)).tolist()
damp = 1 / q
low = band = 0.0
out = [0.0] * n
for i in range(n): # Chamberlin state-variable filter
high = noise[i] - low - damp * band
band += coeff[i] * high
low += coeff[i] * band
out[i] = band
y = np.array(out) * np.sin(np.pi * np.linspace(0, 1, n)) ** 2
return y / np.abs(y).max()
def fftconv(a, b):
n = len(a) + len(b) - 1
size = 1 << (n - 1).bit_length()
return np.fft.irfft(np.fft.rfft(a, size) * np.fft.rfft(b, size), size)[:n]
def room(rng, rt60):
"""Synthetic small-room impulse response: decaying, darkened noise."""
t = np.arange(int(rt60 * SR)) / SR
ir = rng.standard_normal(len(t)) * np.exp(-6.91 * t / rt60) # -60 dB at rt60
ir = fade_in(np.convolve(ir, np.ones(12) / 12, mode="same"), 0.01)
return ir / np.sqrt(np.sum(ir**2))
def mix(rng, events, rt60, wet):
"""events: (start seconds, mono signal, gain, pan from -1 to 1), mixed to stereo."""
length = max(int(start * SR) + len(sig) for start, sig, _, _ in events)
dry = np.zeros((length, 2))
for start, sig, gain, pan in events:
i = int(start * SR)
theta = (pan + 1) * np.pi / 4 # constant-power pan
dry[i : i + len(sig), 0] += sig * gain * np.cos(theta)
dry[i : i + len(sig), 1] += sig * gain * np.sin(theta)
mono = dry.mean(axis=1)
out = np.zeros((length + int(rt60 * SR) - 1, 2))
out[:length] = dry
if wet > 0:
out[:, 0] += wet * fftconv(mono, room(rng, rt60))
out[:, 1] += wet * fftconv(mono, room(rng, rt60))
# Trim the inaudible tail, then fade the last 30 ms so the end never clicks.
level = np.abs(out).max(axis=1)
out = out[: np.nonzero(level > level.max() * 10 ** (TAIL_DB / 20))[0][-1] + 1]
n = min(len(out), int(0.03 * SR))
out[-n:] *= np.linspace(1, 0, n)[:, None]
return out
def biquad(x, b, a):
b0, b1, b2 = b
a1, a2 = a
y = np.empty_like(x)
for ch in range(x.shape[1]):
x1 = x2 = y1 = y2 = 0.0
out = []
for xi in x[:, ch].tolist():
yi = b0 * xi + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
x2, x1, y2, y1 = x1, xi, y1, yi
out.append(yi)
y[:, ch] = out
return y
def loudness(x):
"""Integrated loudness in LUFS per ITU-R BS.1770-4: K-weighting, 400 ms blocks, two gates."""
z = x
for b, a in K_WEIGHTING:
z = biquad(z, b, a)
block, hop = int(0.4 * SR), int(0.1 * SR)
z = np.pad(z, ((0, max(0, block - len(z))), (0, 0)))
power = np.array([np.mean(z[i : i + block] ** 2, axis=0).sum() for i in range(0, len(z) - block + 1, hop)])
def lufs(p):
return -0.691 + 10 * np.log10(np.maximum(p, 1e-20))
power = power[lufs(power) > -70]
if not len(power):
raise RecipeError("the sound is silent")
power = power[lufs(power) > lufs(power.mean()) - 10]
return float(lufs(power.mean()))
def normalise(x, target):
"""Scale to the target loudness, or lower if that would push the peak past the ceiling."""
measured = loudness(x)
gain = target - measured
peak = 20 * np.log10(np.abs(x).max()) + gain
capped = peak > PEAK_CEILING_DBFS
if capped:
gain -= peak - PEAK_CEILING_DBFS
return x * 10 ** (gain / 20), measured + gain, capped
def build(name, spec):
where = f"[sound.{name}]"
check_keys(spec, SOUND_KEYS, where)
tone = {"bright": 1.0, "decay": 1.0, **spec.get("tone", {})}
check_keys(tone, TONE_KEYS, f"{where} tone")
space = {"rt60": 0.7, "wet": 0.22, **spec.get("room", {})}
check_keys(space, ROOM_KEYS, f"{where} room")
if not 0.05 <= space["rt60"] <= 5:
raise RecipeError(f"{where} room: rt60 must be between 0.05 and 5 seconds")
rng = np.random.default_rng(zlib.crc32(name.encode()))
events, notes = [], []
for i, entry in enumerate(spec.get("notes", [])):
if not (isinstance(entry, list) and len(entry) == 4):
raise RecipeError(f"{where} notes[{i}] must be [start, note, velocity, pan]")
start, note, velocity, pan = entry
number = midi_note(note, f"{where} notes[{i}]")
notes.append((float(start), number, float(velocity)))
events.append((float(start), ding(rng, number, tone["bright"], tone["decay"]), float(velocity), float(pan)))
if "whoosh" in spec:
w = spec["whoosh"]
check_keys(w, WHOOSH_KEYS, f"{where} whoosh")
missing = sorted({"duration", "from_hz", "to_hz"} - set(w))
if missing:
raise RecipeError(f"{where} whoosh is missing {', '.join(missing)}")
if not 20 <= min(w["from_hz"], w["to_hz"]) <= max(w["from_hz"], w["to_hz"]) <= 8000:
raise RecipeError(f"{where} whoosh: from_hz and to_hz must be between 20 and 8000")
signal = whoosh(rng, float(w["duration"]), float(w["from_hz"]), float(w["to_hz"]))
events.append((float(w.get("start", 0.0)), signal, float(w.get("gain", 1.0)), float(w.get("pan", 0.0))))
if not events:
raise RecipeError(f"{where} needs notes, a whoosh, or both")
return mix(rng, events, space["rt60"], space["wet"]), notes
def write_wav(path, x):
pcm = (np.clip(x, -1, 1) * 32767).astype("<i2")
with wave.open(str(path), "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(pcm.tobytes())
def write_midi(path, notes, program):
mid = mido.MidiFile(ticks_per_beat=480) # 960 ticks per second at 120 bpm
track = mido.MidiTrack()
mid.tracks.append(track)
track.append(mido.MetaMessage("set_tempo", tempo=500_000, time=0))
track.append(mido.Message("program_change", program=program, time=0))
messages = []
for start, note, velocity in notes:
on = round(start * 960)
level = max(1, min(127, round(velocity * 110)))
messages.append((on, 1, mido.Message("note_on", note=note, velocity=level)))
messages.append((on + 384, 0, mido.Message("note_off", note=note, velocity=0)))
now = 0
for tick, _, message in sorted(messages, key=lambda m: (m[0], m[1])):
track.append(message.copy(time=tick - now))
now = tick
track.append(mido.MetaMessage("end_of_track", time=0))
mid.save(path)
def write_mp3(wav, mp3):
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav), "-codec:a", "libmp3lame", "-q:a", "2", str(mp3)],
check=True,
)
def main():
parser = argparse.ArgumentParser(
prog="sfx",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Recipe format and examples: ~/.omo/agent/skills/sound-design/SKILL.md",
)
parser.add_argument("recipes", type=Path, help="TOML recipe file")
parser.add_argument("out_dir", type=Path, nargs="?", help="where the files go (default: next to the recipe file)")
parser.add_argument("--only", nargs="+", metavar="NAME", help="render only these sounds")
args = parser.parse_args()
try:
book = tomllib.loads(args.recipes.read_text())
check_keys(book, TOP_KEYS, args.recipes.name)
sounds = book.get("sound", {})
check_keys(sounds, set(sounds), "[sound]")
extras = set(book.get("extras", []))
if extras - EXTRAS:
raise RecipeError(f"extras: unknown format {', '.join(sorted(extras - EXTRAS))} (allowed: mp3, mid)")
if "mp3" in extras and not shutil.which("ffmpeg"):
raise RecipeError("mp3 output needs ffmpeg on PATH")
names = args.only or list(sounds)
if not names:
raise RecipeError(f"{args.recipes.name} has no [sound.NAME] tables")
unknown = [n for n in names if n not in sounds]
if unknown:
raise RecipeError(f"no such sound: {', '.join(unknown)}")
out_dir = args.out_dir or args.recipes.parent
out_dir.mkdir(parents=True, exist_ok=True)
for name in names:
spec = sounds[name]
try:
audio, notes = build(name, spec)
target = float(spec.get("loudness", book.get("loudness", DEFAULT_LOUDNESS)))
audio, level, capped = normalise(audio, target)
except (TypeError, ValueError) as e:
raise RecipeError(f"[sound.{name}]: {e}") from e
wav = out_dir / f"{name}.wav"
write_wav(wav, audio)
written = [wav.name]
if "mp3" in extras:
write_mp3(wav, wav.with_suffix(".mp3"))
written.append(f"{name}.mp3")
if "mid" in extras and notes:
write_midi(wav.with_suffix(".mid"), notes, int(book.get("program", DEFAULT_PROGRAM)))
written.append(f"{name}.mid")
peak = 20 * np.log10(np.abs(audio).max())
note = ", held below the peak ceiling so quieter than the target" if capped else ""
print(f"{name}: {len(audio) / SR:.2f}s, {level:.1f} LUFS, peak {peak:.1f} dBFS{note} -> {', '.join(written)}")
except (OSError, tomllib.TOMLDecodeError, RecipeError, subprocess.CalledProcessError) as e:
sys.exit(f"sfx: {e}")
if __name__ == "__main__":
main()
+14
View File
@@ -429,4 +429,18 @@ function fs() {
local session="${1:-main}"
mosh flowstate-remote -- tmux new-session -A -s "$session"
}
# Send the clipboard image to flowstate-remote agents. Saves it into the
# Mutagen-synced share (~/Company/flowstate-share <-> /home/ubuntu/share),
# waits for it to reach the server, then copies the server path to paste into chat.
function fsimg() {
local name="clip-$(date +%Y%m%d-%H%M%S).png"
pngpaste ~/Company/flowstate-share/"$name" || return 1
echo "uploading $name..."
if ! mutagen sync flush fs-share >/dev/null; then
echo "warning: sync flush failed, the file may not be on the server yet" >&2
fi
printf '/home/ubuntu/share/%s' "$name" | pbcopy
echo "path copied: /home/ubuntu/share/$name"
}
export PATH=$PATH:$HOME/.maestro/bin
+5 -2
View File
@@ -103,7 +103,7 @@ The script creates individual relative symlinks, skips existing correct links, a
- `.config/omo/omo.jsonc` supplies `~/.omo/omo.jsonc` with OmO's model routing, including the `design-critique-a` and `design-critique-b` categories that the Impeccable critique delegates its two assessments to.
- `.config/ai/AGENTS.md` holds shared personal preferences. OmO reads it through `~/.config/opencode/AGENTS.md`, which is hard-wired in OmO's rules engine ahead of `~/.claude/CLAUDE.md`; the engine stops at the first file it finds, so this link both supplies these preferences and keeps Claude's OMC orchestration instructions out of OmO. Keep it even though OpenCode is retired. Claude imports the same file from its own `CLAUDE.md`.
- `.config/claude/` supplies Claude's global instructions, settings, and OMC preferences. The machine-specific OMC `nodeBinary` cache is intentionally omitted; install Node.js and run OMC setup on each new machine to configure its runtime and HUD.
- `.config/omo/skills/` holds the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow` and marketing skills. The script links every one into `~/.omo/agent/skills/`, and all but `frontend-workflow` into `~/.claude/skills/`; see [the skills README](.config/omo/skills/README.md) for provenance and upgrades. Portable Claude plugins and unrelated user skills remain enabled.
- `.config/omo/skills/` holds the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow`, `sound-design` and marketing skills. The script links every one into `~/.omo/agent/skills/`, and all but `frontend-workflow` into `~/.claude/skills/`; see [the skills README](.config/omo/skills/README.md) for provenance and upgrades. Portable Claude plugins and unrelated user skills remain enabled.
Credentials, `settings.local.json`, plugin installations, unrelated user skills, histories, caches, and runtime state are not copied into this repository or managed by this script. Install other desired plugins and portable skills separately. OmO's MCP servers live in `~/.omo/agent/mcp.json`, which this repository does not manage; the local ones (headroom, agcanvas, uvx, ast-grep-mcp, portfolio-mcp) must be installed on a new machine before they resolve.
@@ -175,12 +175,13 @@ 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 <id>` (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.
- `/impeccable [command] [target]` runs the Impeccable design skill.
- `/setup-notes`, `/sync-notes`, `/ingest-notes`, `/teleport-notes`, `/analyze-vault`, `/reorganize-notes`, `/preview-notes-delegation` and `/validate-notes-config` manage with-context documentation delegation. They came over from OpenCode and need the with-context MCP server, which is not in `~/.omo/agent/mcp.json`.
- `skills/` (linked into `~/.omo/agent/skills/`, and into `~/.claude/skills/` for the shared ones): the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow` and marketing skills. See [the skills README](.config/omo/skills/README.md) for provenance, local modifications and upgrades.
- `skills/` (linked into `~/.omo/agent/skills/`, and into `~/.claude/skills/` for the shared ones): the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow`, `sound-design` and marketing skills. See [the skills README](.config/omo/skills/README.md) for provenance, local modifications and upgrades.
- `archive/`: the OpenCode-era `DEVELOPMENT-WORKFLOW.md` and `UI-WORKFLOW.md`, kept for reference and loaded by nothing.
MCP servers (in `~/.omo/agent/mcp.json`, which this repository does not manage): Playwright, Chrome DevTools, Linear, reoclo, time, ast-grep, headroom, agcanvas and portfolio.
@@ -232,6 +233,8 @@ 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)
- `impeccable-browser`: The browser for Impeccable's URL scans (`impeccable detect <url>`) on flowstate-remote, set as `IMPECCABLE_BROWSER` in the server's `~/.zshenv`. Public URLs render on Browserless. Its key, `BROWSERLESS_API_KEY` from the Reoclo project `flowstate-tools`, is cached for 12 hours in `~/.impeccable/browserless.token`. Localhost and private addresses reach Browserless through a cloudflared quick tunnel that lasts only for the scan, and reports still name the local URL. Other launches, and any scan whose tunnel or Browserless connection fails, run on the headless Chrome in the Puppeteer cache with `--no-sandbox`, because the server's AppArmor blocks Chrome's sandbox. `IMPECCABLE_TUNNEL=0` keeps private addresses on local Chrome, which pages that call a second local port (a separate API, say) need. Every decision is logged to `~/.impeccable/browser.log`
+2 -2
View File
@@ -50,8 +50,8 @@ targets=(
# steps are in .config/omo/skills/README.md. frontend-workflow is the OmO
# delegation process skill and is not linked into Claude Code; the rest are
# shared by both harnesses.
omo_skills=(frontend-workflow impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social)
claude_skills=(impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social)
omo_skills=(frontend-workflow impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social sound-design)
claude_skills=(impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social sound-design)
for skill in "${omo_skills[@]}"; do
sources+=(".config/omo/skills/$skill")
targets+=(".omo/agent/skills/$skill")