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>
This commit is contained in:
2026-02-16 01:04:44 +01:00
co-authored by Claude Opus 4.6
parent c5f0694a1b
commit d0d545f44d
2 changed files with 448 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
# Dialogue & Confrontation Ambient Dip — Implementation Spec
Audio bus volume/filter changes for dialogue and confrontation states. These are NOT audio files — they're mix parameter changes applied to AudioBus volumes and effects in Godot's AudioServer.
**Ticket:** #440
**Decisions:** D-068 (5-bus architecture), D-069 (dip profiles), D-070 (confrontation as cognitive vulnerability)
**Branch:** client (AudioManager implementation), audio (this spec)
## Bus Architecture Reference
| Bus Index | Name | Purpose |
|-----------|------|---------|
| 0 | Master | Final mix output |
| 1 | Music | Score (empty for now) |
| 2 | Ambient | amb_* loops, environmental background |
| 3 | World SFX | Positional sounds in physical space |
| 4 | Player Actions | Combat, footsteps, interaction SFX |
| 5 | UI Sounds | Non-positional interface feedback |
## Dialogue Dip
**Trigger:** Dialogue box opens (client #434)
**Release:** Dialogue box closes
| Parameter | Value |
|-----------|-------|
| Ambient bus volume | -6 to -8 dB (from current) |
| Ease-in duration | 300ms |
| Ease-out duration | 500ms |
| Easing curve | Cubic ease-in-out |
| Affected buses | Ambient only |
### Godot Implementation
```gdscript
# In AudioManager (autoload singleton)
const DIALOGUE_DIP_DB := -7.0 # midpoint of -6 to -8 range
const DIALOGUE_DIP_IN_MS := 300.0
const DIALOGUE_DIP_OUT_MS := 500.0
var _ambient_bus_idx: int
var _ambient_base_volume_db: float
var _dip_tween: Tween
func _ready() -> void:
_ambient_bus_idx = AudioServer.get_bus_index("Ambient")
_ambient_base_volume_db = AudioServer.get_bus_volume_db(_ambient_bus_idx)
func dialogue_dip_start() -> void:
_cancel_dip_tween()
var target := _ambient_base_volume_db + DIALOGUE_DIP_DB
_dip_tween = create_tween()
_dip_tween.tween_method(
_set_ambient_volume,
AudioServer.get_bus_volume_db(_ambient_bus_idx),
target,
DIALOGUE_DIP_IN_MS / 1000.0
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
func dialogue_dip_end() -> void:
_cancel_dip_tween()
_dip_tween = create_tween()
_dip_tween.tween_method(
_set_ambient_volume,
AudioServer.get_bus_volume_db(_ambient_bus_idx),
_ambient_base_volume_db,
DIALOGUE_DIP_OUT_MS / 1000.0
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
func _set_ambient_volume(db: float) -> void:
AudioServer.set_bus_volume_db(_ambient_bus_idx, db)
func _cancel_dip_tween() -> void:
if _dip_tween and _dip_tween.is_valid():
_dip_tween.kill()
```
### Signal Wiring
```gdscript
# In DialogueBox or wherever dialogue state is managed:
func _open_dialogue() -> void:
# ... show dialogue UI ...
AudioManager.dialogue_dip_start()
func _close_dialogue() -> void:
# ... hide dialogue UI ...
AudioManager.dialogue_dip_end()
```
## Confrontation Dip
**Trigger:** Confrontation dialogue begins (client #434, confrontation variant)
**Release:** Confrontation dialogue ends
**Design intent (D-070):** "Felt, not computed." The player's focus narrows — the world acoustically recedes. This is cognitive vulnerability made audible.
| Parameter | Value |
|-----------|-------|
| Ambient bus volume | -11 dB (midpoint of -10 to -12) |
| Ambient bus low-pass filter | 800 Hz cutoff, 6 dB resonance |
| World SFX bus volume | -5 dB (midpoint of -4 to -6) |
| Ease-in duration | 500ms |
| Ease-out duration | 1000ms |
| Easing curve | Cubic ease-in-out |
| Affected buses | Ambient, World SFX |
### Godot Implementation
The confrontation dip adds a low-pass filter effect to the Ambient bus. This must be set up in the Godot AudioBus layout (Project → Audio Bus Layout):
**Bus setup (audio bus layout .tres):**
1. Add `AudioEffectLowPassFilter` to the Ambient bus
2. Set it to **bypassed by default** (effect is inactive until confrontation)
3. Default cutoff: 20500 Hz (fully open)
```gdscript
const CONFRONTATION_AMBIENT_DIP_DB := -11.0
const CONFRONTATION_SFX_DIP_DB := -5.0
const CONFRONTATION_LP_CUTOFF_HZ := 800.0
const CONFRONTATION_LP_OPEN_HZ := 20500.0
const CONFRONTATION_DIP_IN_MS := 500.0
const CONFRONTATION_DIP_OUT_MS := 1000.0
var _world_sfx_bus_idx: int
var _world_sfx_base_volume_db: float
var _ambient_lp_effect_idx: int # index of the LP filter on Ambient bus
var _confrontation_tween: Tween
func _ready() -> void:
# ... (ambient bus setup from dialogue dip above) ...
_world_sfx_bus_idx = AudioServer.get_bus_index("World SFX")
_world_sfx_base_volume_db = AudioServer.get_bus_volume_db(_world_sfx_bus_idx)
# Find the LP filter effect index on the Ambient bus
for i in range(AudioServer.get_bus_effect_count(_ambient_bus_idx)):
if AudioServer.get_bus_effect(_ambient_bus_idx, i) is AudioEffectLowPassFilter:
_ambient_lp_effect_idx = i
break
func confrontation_dip_start() -> void:
_cancel_confrontation_tween()
# Enable the LP filter
AudioServer.set_bus_effect_enabled(_ambient_bus_idx, _ambient_lp_effect_idx, true)
var amb_target := _ambient_base_volume_db + CONFRONTATION_AMBIENT_DIP_DB
var sfx_target := _world_sfx_base_volume_db + CONFRONTATION_SFX_DIP_DB
var dur := CONFRONTATION_DIP_IN_MS / 1000.0
_confrontation_tween = create_tween()
_confrontation_tween.set_parallel(true)
# Ambient volume dip
_confrontation_tween.tween_method(
_set_ambient_volume,
AudioServer.get_bus_volume_db(_ambient_bus_idx),
amb_target, dur
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
# World SFX volume dip
_confrontation_tween.tween_method(
_set_world_sfx_volume,
AudioServer.get_bus_volume_db(_world_sfx_bus_idx),
sfx_target, dur
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
# Low-pass filter sweep
var lp_effect: AudioEffectLowPassFilter = AudioServer.get_bus_effect(
_ambient_bus_idx, _ambient_lp_effect_idx
)
_confrontation_tween.tween_property(
lp_effect, "cutoff_hz",
CONFRONTATION_LP_CUTOFF_HZ, dur
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
func confrontation_dip_end() -> void:
_cancel_confrontation_tween()
var dur := CONFRONTATION_DIP_OUT_MS / 1000.0
_confrontation_tween = create_tween()
_confrontation_tween.set_parallel(true)
# Restore ambient volume
_confrontation_tween.tween_method(
_set_ambient_volume,
AudioServer.get_bus_volume_db(_ambient_bus_idx),
_ambient_base_volume_db, dur
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
# Restore world SFX volume
_confrontation_tween.tween_method(
_set_world_sfx_volume,
AudioServer.get_bus_volume_db(_world_sfx_bus_idx),
_world_sfx_base_volume_db, dur
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
# Open LP filter back up
var lp_effect: AudioEffectLowPassFilter = AudioServer.get_bus_effect(
_ambient_bus_idx, _ambient_lp_effect_idx
)
_confrontation_tween.tween_property(
lp_effect, "cutoff_hz",
CONFRONTATION_LP_OPEN_HZ, dur
).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
# Disable LP filter after tween completes
_confrontation_tween.chain().tween_callback(func():
AudioServer.set_bus_effect_enabled(
_ambient_bus_idx, _ambient_lp_effect_idx, false
)
)
func _set_world_sfx_volume(db: float) -> void:
AudioServer.set_bus_volume_db(_world_sfx_bus_idx, db)
func _cancel_confrontation_tween() -> void:
if _confrontation_tween and _confrontation_tween.is_valid():
_confrontation_tween.kill()
```
## Edge Cases
### Confrontation during dialogue
Confrontation dip supersedes dialogue dip (it's deeper). If dialogue is active when confrontation starts, skip directly to confrontation levels. When confrontation ends, restore to dialogue dip levels (not base), then to base when dialogue ends.
### Rapid open/close
The tween-kill-and-restart pattern handles this — a new dip start/end always kills the current tween and starts from the current actual volume, preventing jarring jumps.
### Player volume slider interaction
Dips are RELATIVE to `_ambient_base_volume_db`. If the player adjusts their Ambient slider mid-dip, update `_ambient_base_volume_db` and recalculate the target. The `AudioManager` settings save/load system should call a method to refresh base volumes.
## ListeningFocus Boost (D-069)
When the player is in active listening mode (future sprint), World SFX gets a +2-3 dB boost instead of a dip. This is the inverse of confrontation — the character is paying MORE attention to the environment.
| Parameter | Value |
|-----------|-------|
| World SFX bus volume | +2.5 dB (midpoint of +2 to +3) |
| Ease-in | 200ms |
| Ease-out | 300ms |
Implementation follows the same tween pattern. Mutually exclusive with confrontation dip.
+206
View File
@@ -0,0 +1,206 @@
#!/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>")