Files
settled-reach/tooling/domains/assets/synth_ui.py
T
jpmschweitzerandClaude Opus 5.5 ddce4441a9 refactor(tooling): T-1290 — the assets domain, where OFF is the normal case
tooling/db/ (a misnamed directory: connectors, not database work),
trellis-batch.sh and synth_ui_sounds.py become `reach assets`:
audio {health,generate,batch,post {convert,normalize,trim,pipeline}},
image {health,generate}, trellis {health,generate,batch}, and synth-ui.
The four audio bash wrappers are retired, and tooling/db/ is gone.

Parity, from baselines taken before anything moved:

- the four UI-sound WAVs and the harmonic-synth WAVs (exponential and linear
  decay) are byte-identical
- the ffmpeg pipeline's decoded PCM is identical. Its .ogg bytes are not,
  even between two runs of the OLD code: Ogg picks a random stream serial,
  so the encoded file was never the right thing to compare
- the network success paths can't be run in a gate (Stable Audio and Trellis
  are kept off, Gemini costs money), so tooling/test_assets.py stands up a
  fake Gradio and pins every payload: the audio submit, Trellis's six-call
  session sequence with its 9-input image_to_3d, and the Gemini body. It
  failed when one Trellis value was mutated (7.5 → 7.0)

Failure classification, in endpoints.py, is the point of the port. The
services are OFF by design (VRAM on tower-of-joy, D-17), and the topology doc
warns against "fixing" one by restarting it. So a refused connection says OFF
and asks for the service to be turned on rather than restarted; a 4xx/5xx says
the request was rejected; 401/403 says credentials; 429 says quota; and an
unreachable Gemini blames the network, not VRAM.

Behaviour changes, each a failure that used to read as success or crash:

- audio batch and trellis batch exited 0 with failures in their summaries;
  they now print the summary and exit 1
- trellis generate on a missing image crashed with a TypeError
  (print(..., indent=2)); it now names the file, and checks it before the
  service so a typo is not reported as an outage
- the ffmpeg pipeline left its intermediates behind when a step failed

Structure: the connectors called each other as subprocesses (batch spawned
the connector, which spawned audio_post) and parsed each other's stdout. They
are now function calls, and ffmpeg is the only exec, through core/process.
ensure_venv() is removed: it os.execv'd into .venv, which D-263's exec rule
forbids, and reach declares the dependencies itself. config.json moved into
the domain deliberately, and the local-services rule follows it.

Output contract: results are still JSON on stdout with the same keys, so skill
readers keep working. Failures are an exit status with a Fix line, never
{"ok": false}. The audio-gen, glb-gen and image-gen skills, Araminta's agent
file and the allow-list are updated to match. glb-gen's "trellis-batch.sh is
hardcoded to one category" caveat is gone: batch takes --input-dir or --names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:49:20 +02:00

210 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Synthesize insert-tech UI sounds for Sprint 7 #440.
Each sound uses a DIFFERENT synthesis technique to ensure distinct character:
- cursor_hover: impulse → resonant bandpass (digital click)
- weapon_aim: filtered noise + sub thump (mechanical)
- monologue_chime: FM synthesis (crystalline bell)
- monologue_chime_urgent: FM synthesis + beating/dissonance (tense bell)
Formerly tooling/synth_ui_sounds.py (T-1290). The synthesis is untouched —
the four WAVs are byte-identical before and after the port. The output
directory became a parameter; the default is still client/assets/audio.
"""
import os
import wave
import numpy as np
from tooling.core import config, console
SR = 44100
OUTPUT_DIR = str(config.repo_root() / "client" / "assets" / "audio")
_output_dir = OUTPUT_DIR
def write_wav(filename, samples, channels=1):
"""Write float samples [-1, 1] to 16-bit WAV."""
path = os.path.join(_output_dir, filename)
# Normalize to peak if it exceeds 1.0
peak = np.max(np.abs(samples))
if peak > 1.0:
samples = samples / peak
int_samples = np.clip(samples * 32767, -32767, 32767).astype(np.int16)
with wave.open(path, "w") as f:
f.setnchannels(channels)
f.setsampwidth(2)
f.setframerate(SR)
f.writeframes(int_samples.tobytes())
dur_ms = len(int_samples) / SR * 1000
console.event(f"wrote {path} ({dur_ms:.0f}ms, {channels}ch)")
return path
def simple_lowpass(signal, cutoff_hz, sr=SR):
"""Single-pole IIR low-pass filter."""
rc = 1.0 / (2 * np.pi * cutoff_hz)
dt = 1.0 / sr
alpha = dt / (rc + dt)
out = np.zeros_like(signal)
out[0] = alpha * signal[0]
for i in range(1, len(signal)):
out[i] = out[i - 1] + alpha * (signal[i] - out[i - 1])
return out
def simple_highpass(signal, cutoff_hz, sr=SR):
"""Single-pole IIR high-pass filter."""
rc = 1.0 / (2 * np.pi * cutoff_hz)
dt = 1.0 / sr
alpha = rc / (rc + dt)
out = np.zeros_like(signal)
out[0] = signal[0]
for i in range(1, len(signal)):
out[i] = alpha * (out[i - 1] + signal[i] - signal[i - 1])
return out
def cursor_hover():
"""UI-001: Digital click/tick on entity hover.
Technique: Short noise impulse bandpass-filtered to ~4kHz.
Sounds like a tiny digital snap — no sustained tone at all.
The "click" of a selection appearing on a HUD.
"""
duration = 0.035 # 35ms — shorter than before
n = int(SR * duration)
t = np.linspace(0, duration, n, endpoint=False)
rng = np.random.default_rng(77)
# White noise impulse
impulse = rng.uniform(-1, 1, n)
# Bandpass around 4kHz: high-pass at 3kHz, low-pass at 5kHz
filtered = simple_highpass(impulse, 3000)
filtered = simple_lowpass(filtered, 5500)
# Very fast decay — done in 25ms
envelope = np.exp(-t * 140)
return write_wav("cursor_hover.wav", filtered * envelope * 0.3)
def weapon_aim():
"""UI-004: Mechanical latch for weapon aim.
Technique: Low-pass filtered noise burst (the clack) + sub-bass
thump (the weight). No musical pitch — this is a MECHANICAL sound.
Think: safety clicking off, bolt sliding home.
"""
duration = 0.15 # 150ms
n = int(SR * duration)
t = np.linspace(0, duration, n, endpoint=False)
rng = np.random.default_rng(42)
# Component 1: Low-pass noise burst — the metallic clack
noise = rng.uniform(-1, 1, n)
# Low-pass at 1.5kHz — dull, heavy impact, not bright
clack = simple_lowpass(noise, 1500)
clack_env = np.exp(-t * 60) # fast decay
clack = clack * clack_env
# Component 2: Sub-bass thump — weight of the mechanism
# 80Hz sine, very fast decay
thump = np.sin(2 * np.pi * 80 * t)
thump_env = np.exp(-t * 40)
thump = thump * thump_env
# Component 3: High metallic click at the very start (2ms)
click = rng.uniform(-1, 1, n)
click = simple_highpass(click, 4000)
click_env = np.zeros(n)
click_mask = t < 0.003
click_env[click_mask] = np.exp(-t[click_mask] * 800)
click = click * click_env
signal = clack * 0.4 + thump * 0.35 + click * 0.25
return write_wav("weapon_aim.wav", signal * 0.45)
def monologue_chime():
"""UI-005: Crystalline thought-chime. PLACEHOLDER.
Technique: FM synthesis — carrier modulated by a lower frequency
creates rich, evolving harmonics that sound like struck glass or
crystal. Fundamentally different timbre from additive sine waves.
"""
duration = 0.75 # 750ms
n = int(SR * duration)
t = np.linspace(0, duration, n, endpoint=False)
# FM synthesis: carrier at 1200Hz, modulator at 420Hz (ratio ~2.86:1)
# Inharmonic ratio = bell-like quality
f_carrier = 1200
f_mod = 420
# Mod index decays over time — bright attack, mellow sustain
mod_index = 3.0 * np.exp(-t * 6)
# Modulator signal
modulator = mod_index * np.sin(2 * np.pi * f_mod * t)
# Carrier with FM
signal = np.sin(2 * np.pi * f_carrier * t + modulator)
# Gentle attack (20ms), slow decay
attack = np.minimum(t / 0.02, 1.0)
decay = np.exp(-t * 3.0)
envelope = attack * decay
return write_wav("sfx_monologue_chime.wav", signal * envelope * 0.22)
def monologue_chime_urgent():
"""UI-006: Urgent thought-chime. PLACEHOLDER.
Technique: FM synthesis with higher mod index (brighter/harsher) +
a second detuned carrier that creates beating/tension. The beating
is what makes it feel "urgent" — not louder, but unsettled.
"""
duration = 0.5 # 500ms — shorter
n = int(SR * duration)
t = np.linspace(0, duration, n, endpoint=False)
f_carrier = 1200
f_mod = 420
# Higher mod index = more sidebands = brighter, more aggressive
mod_index = 5.0 * np.exp(-t * 7)
modulator = mod_index * np.sin(2 * np.pi * f_mod * t)
# Primary carrier
carrier1 = np.sin(2 * np.pi * f_carrier * t + modulator)
# Second carrier, detuned +8Hz — creates beating at 8Hz (nervous flicker)
carrier2 = np.sin(2 * np.pi * (f_carrier + 8) * t + modulator * 0.8)
signal = carrier1 * 0.6 + carrier2 * 0.4
# Sharp attack (6ms), faster decay
attack = np.minimum(t / 0.006, 1.0)
decay = np.exp(-t * 4.0)
envelope = attack * decay
# 15% louder than normal
return write_wav("sfx_monologue_chime_urgent.wav", signal * envelope * 0.25)
def run(output_dir: str = OUTPUT_DIR) -> list[str]:
"""Write all four UI sounds into output_dir; returns their paths."""
global _output_dir
_output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
console.event("[UI-001] cursor_hover — bandpass noise impulse (digital click)")
paths = [cursor_hover()]
console.event("[UI-004] weapon_aim — filtered noise + sub thump (mechanical latch)")
paths.append(weapon_aim())
console.event("[UI-005] sfx_monologue_chime — FM synthesis bell (PLACEHOLDER)")
paths.append(monologue_chime())
console.event("[UI-006] sfx_monologue_chime_urgent — FM + beating (PLACEHOLDER)")
paths.append(monologue_chime_urgent())
return paths