Files
settled-reach/tooling/synth_ui_sounds.py
T
jpmschweitzerandClaude Opus 4.6 d0d545f44d feat(audio): add dip spec and synthesis tooling for #440
dialogue-ambient-dip.md: full Godot implementation spec for dialogue
(-7dB ambient) and confrontation (-11dB ambient + LP 800Hz, -5dB world
SFX) audio dips with tween code and edge case handling. synth_ui_sounds
.py: FM/noise/impulse synthesis for insert-tech UI sounds (cursor hover,
weapon aim, monologue chimes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:04:44 +01:00

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: db/connectors/audio-post convert <file.wav>")