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:
@@ -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.
|
||||
@@ -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>")
|
||||
Reference in New Issue
Block a user