Files
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

330 lines
13 KiB
Python
Executable File

#!/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()