Consolidates all connector scripts under tooling/ per project structure conventions. Symlink at db/connectors → tooling/db/ preserves backwards compatibility (remove after Sprint 22). Updated references in CLAUDE.md, Makefile, DEVOPS.md, all skill files, agent files, rules, schema comments, and Sprint 21 briefings. Python scripts updated with correct SCHEMA_PATH (now relative to WORKTREE_ROOT/db/schema.sql). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
6.6 KiB
Python
207 lines
6.6 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)
|
|
"""
|
|
|
|
import numpy as np
|
|
import wave
|
|
import os
|
|
|
|
SR = 44100
|
|
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "..", "client", "assets", "audio")
|
|
|
|
|
|
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
|
|
print(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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
print("Synthesizing UI sounds for Sprint 7 #440...")
|
|
print()
|
|
|
|
print("[UI-001] cursor_hover — bandpass noise impulse (digital click)")
|
|
cursor_hover()
|
|
|
|
print("[UI-004] weapon_aim — filtered noise + sub thump (mechanical latch)")
|
|
weapon_aim()
|
|
|
|
print("[UI-005] sfx_monologue_chime — FM synthesis bell (PLACEHOLDER)")
|
|
monologue_chime()
|
|
|
|
print("[UI-006] sfx_monologue_chime_urgent — FM + beating (PLACEHOLDER)")
|
|
monologue_chime_urgent()
|
|
|
|
print()
|
|
print("Done. Convert with: tooling/db/audio-post convert <file.wav>")
|