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