mirror of
https://github.com/boxpositron/absolute-dotfiles.git
synced 2026-10-24 08:23:19 +00:00
Compare commits
3 Commits
161d205dfd
...
3975a5b97d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3975a5b97d | |||
| 66d1d21011 | |||
| 5805f45358 |
@@ -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 |
|
| `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 |
|
| ten marketing and copy skills | [coreyhaines31/marketingskills](https://github.com/coreyhaines31/marketingskills) (`skills/`) | `b9ba399dd88b082b926e261e8ccfb843d20aa066` | MIT, copied into each skill |
|
||||||
| `frontend-workflow` | local | - | - |
|
| `frontend-workflow` | local | - | - |
|
||||||
|
| `sound-design` | local | - | - |
|
||||||
|
|
||||||
The ten marketing skills are `product-marketing`, `copywriting`, `copy-editing`,
|
The ten marketing skills are `product-marketing`, `copywriting`, `copy-editing`,
|
||||||
`emails`, `cold-email`, `content-strategy`, `seo-audit`, `ai-seo`, `launch` and
|
`emails`, `cold-email`, `content-strategy`, `seo-audit`, `ai-seo`, `launch` and
|
||||||
|
|||||||
@@ -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.
@@ -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]]
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Ding, tab marker and notification for omo (senpi) hooks: UserPromptSubmit,
|
# Ding, tab marker and notification for omo (senpi) hooks: UserPromptSubmit,
|
||||||
# Stop and Notification. Reads the hook JSON payload on stdin. Wired up in
|
# 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
|
# ~/.omo/agent/hooks.json. The sound plays through omo-sound (afplay on macOS),
|
||||||
# notification permissions cannot mute it; the banner is a silent extra.
|
# 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
|
# 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:
|
# omo-status then selects the session's pane. omo-status also colours that tab:
|
||||||
# blue working, amber waiting for you, green finished, red error.
|
# blue working, amber waiting for you, green finished, red error.
|
||||||
@@ -32,13 +33,13 @@ case "$event" in
|
|||||||
case "$(field '.stopReason')" in
|
case "$(field '.stopReason')" in
|
||||||
# Esc/interrupts are your own doing; no need to be told about them.
|
# Esc/interrupts are your own doing; no need to be told about them.
|
||||||
aborted) state="clear" ;;
|
aborted) state="clear" ;;
|
||||||
error) state="error" message="Stopped with an error" sound="Glass" ;;
|
error) state="error" message="Stopped with an error" sound="omo-error" ;;
|
||||||
*) state="done" message="Task finished" sound="Glass" ;;
|
*) state="done" message="Task finished" sound="omo-done" ;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
Notification)
|
Notification)
|
||||||
if [[ "$(field '.kind')" == "ask-user-asked" ]]; then
|
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
|
else
|
||||||
# Answered or timed out: the agent carries on.
|
# Answered or timed out: the agent carries on.
|
||||||
state=working
|
state=working
|
||||||
@@ -50,8 +51,8 @@ case "$event" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
# Background the ding so the hook returns immediately; redirect output so the
|
# Background the ding so the hook returns immediately; redirect output so the
|
||||||
# hook runner is not left waiting on afplay's inherited pipes.
|
# hook runner is not left waiting on the player's inherited pipes.
|
||||||
[[ -n "$sound" ]] && afplay "/System/Library/Sounds/${sound}.aiff" >/dev/null 2>&1 &
|
[[ -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
|
# 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.
|
# up the process tree to the first ancestor whose tty is a tmux pane.
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ case ${1:-} in
|
|||||||
[[ $tags == *,omo,* ]] || exit 0 # Claude Code posts to the same topic
|
[[ $tags == *,omo,* ]] || exit 0 # Claude Code posts to the same topic
|
||||||
(($(date +%s) - ${NTFY_TIME:-0} <= fresh)) || exit 0
|
(($(date +%s) - ${NTFY_TIME:-0} <= fresh)) || exit 0
|
||||||
case $tags in
|
case $tags in
|
||||||
*,question,*) state=input sound=Ping ;;
|
*,question,*) state=input sound=omo-input ;;
|
||||||
*,x,*) state=error sound=Glass ;;
|
*,x,*) state=error sound=omo-error ;;
|
||||||
*) state="done" sound=Glass ;;
|
*) state="done" sound=omo-done ;;
|
||||||
esac
|
esac
|
||||||
# "attached" only says some terminal shows the session: a tmux pane here
|
# "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.
|
# (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"
|
logline "ntfy $state ${NTFY_TITLE:-} skipped: a tmux pane got it through the title"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
# 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.
|
# clicking it lands there, or else from the tmux tab you used last.
|
||||||
target=$(plain_tab_tty "$tags")
|
target=$(plain_tab_tty "$tags")
|
||||||
|
|||||||
Executable
+33
@@ -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
@@ -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()
|
||||||
@@ -429,4 +429,18 @@ function fs() {
|
|||||||
local session="${1:-main}"
|
local session="${1:-main}"
|
||||||
mosh flowstate-remote -- tmux new-session -A -s "$session"
|
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
|
export PATH=$PATH:$HOME/.maestro/bin
|
||||||
|
|||||||
@@ -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/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/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/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.
|
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.
|
- `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`).
|
- `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.
|
- `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.
|
- `/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.
|
- `/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.
|
- `/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`.
|
- `/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.
|
- `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.
|
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-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-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-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)
|
- `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`
|
- `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
@@ -50,8 +50,8 @@ targets=(
|
|||||||
# steps are in .config/omo/skills/README.md. frontend-workflow is the OmO
|
# 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
|
# delegation process skill and is not linked into Claude Code; the rest are
|
||||||
# shared by both harnesses.
|
# 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)
|
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)
|
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
|
for skill in "${omo_skills[@]}"; do
|
||||||
sources+=(".config/omo/skills/$skill")
|
sources+=(".config/omo/skills/$skill")
|
||||||
targets+=(".omo/agent/skills/$skill")
|
targets+=(".omo/agent/skills/$skill")
|
||||||
|
|||||||
Reference in New Issue
Block a user