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>
210 lines
7.0 KiB
Python
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
|