shrike/audio.py¶

Part of SHRIKE.

  1"""SHRIKE's audio: every sound synthesised at load, and the mix the power state drives.
  2
  3There are no audio files. Each cue is rendered into a numpy buffer when the
  4director enters the tree and handed to the engine as an ``AudioClip``, so the
  5whole soundtrack ships as arithmetic.
  6
  7The mix is diegetic. Running the generator adds a room-tone hum whose loudness
  8IS the signature warning; silent running low-passes the entire mix down to hull
  9creaks and, if the Shrike is out there, its sonar ping; low oxygen tightens that
 10same filter and puts your own breathing in the foreground; the T-30 klaxon ducks
 11the bed and lets a heartbeat through. Spatial cues (spawn chevrons, the ping)
 12pan toward the thing that made them.
 13
 14Every cue also reaches the HUD as a caption on the :attr:`~AudioDirector.audio_cue`
 15signal, because a mechanic carried by sound must never be hearing-gated. The
 16player's three mixer sliders arrive through :meth:`AudioDirector.set_bus_volumes`
 17and trim what the cue table authored rather than replacing it.
 18
 19The noise beds and sweeps are rendered with vectorised numpy rather than
 20``AudioSynth``'s per-source filters and pink noise, which are per-sample Python
 21loops sized for sub-second effects; the tonal cues use ``AudioSynth`` directly.
 22"""
 23
 24import math
 25from dataclasses import dataclass
 26
 27import numpy as np
 28
 29from simvx.core import (
 30    ADSR,
 31    AudioBusLayout,
 32    AudioClip,
 33    AudioPlayer,
 34    AudioSynth,
 35    Exponential,
 36    Linear,
 37    LowPassFilter,
 38    Node,
 39    Oscillator,
 40    Signal,
 41    Vec3,
 42)
 43
 44from . import balance
 45from .power import SignalWiring
 46from .runtime import Groups, SignalNames
 47
 48# ============================================================================
 49# Mix constants
 50#
 51# These are presentation numbers rather than balance ones, so they live here
 52# rather than in balance.py; the two that gate gameplay readings
 53# (O2_LOW_WARNING_SECONDS, KILL_COMBO_WINDOW_S) come from balance.
 54# ============================================================================
 55
 56SAMPLE_RATE = 48000
 57#: Fixed seed so every boot renders a byte-identical bank.
 58NOISE_SEED = 7717
 59
 60#: Round-robin one-shot voices. Deep enough for a busy wave, shallow enough to
 61#: bound the node count.
 62ONE_SHOT_VOICES = 12
 63
 64#: The hum's loudness at zero signature and at SIGNATURE_MAX. This ramp is the
 65#: warning: players learn the sector's danger by ear before reading the border.
 66HUM_QUIET_DB = -34.0
 67HUM_LOUD_DB = -8.0
 68
 69#: Ducking at T-30: the music bed drops, the ambience drops less, and the
 70#: heartbeat comes in over the hole.
 71DUCK_MUSIC_DB = -18.0
 72DUCK_AMBIENCE_DB = -10.0
 73
 74#: Master low-pass cutoffs. Silent running is the tighter of the two, so when
 75#: both apply the mix takes the lower.
 76SILENT_RUNNING_CUTOFF_HZ = 520.0
 77LOW_O2_CUTOFF_HZ = 1100.0
 78MASTER_LOWPASS_Q = 0.9
 79
 80#: Kill pitch ladder: one semitone per kill inside balance.KILL_COMBO_WINDOW_S.
 81KILL_LADDER_SEMITONE = 2.0 ** (1.0 / 12.0)
 82KILL_LADDER_MAX_STEPS = 8
 83
 84#: Pan buckets a positional cue snaps to, and the offset from the ship in world
 85#: units that reads as a hard pan.
 86PAN_BUCKETS = (-1.0, -0.5, 0.0, 0.5, 1.0)
 87PAN_FULL_WIDTH = 36.0
 88
 89#: Seconds between sonar pings while silent running with the Shrike in-sector.
 90SONAR_PING_INTERVAL_S = 3.5
 91
 92#: Two-tone klaxon shape.
 93KLAXON_SLOT_S = 0.3
 94KLAXON_HIGH_HZ = 466.0
 95KLAXON_LOW_HZ = 349.0
 96
 97#: The three mixer sliders, as bus names. Every cue plays on one of them and
 98#: ``Master`` multiplies the lot, so a settings screen owns the whole mix with
 99#: three numbers and the cue table keeps its authored balance untouched.
100BUS_MASTER = "Master"
101BUS_SFX = "SFX"
102BUS_MUSIC = "Music"
103#: Decibels a bus is trimmed to at a slider of zero. Far enough down to be
104#: silence at any sane master, and finite so the arithmetic stays real.
105BUS_SILENT_DB = -80.0
106
107#: How a panned caption names the side the sound came from. Screen words rather
108#: than arrows or brackets: a caption track is prose about a sound, and every
109#: piece of punctuation in it has been read as scaffolding by somebody.
110CAPTION_LEFT = "LEFT"
111CAPTION_RIGHT = "RIGHT"
112
113
114# ============================================================================
115# The cue catalogue
116# ============================================================================
117
118
119@dataclass(frozen=True)
120class Cue:
121    """One sound: its id, its caption, and how it sits in the mix.
122
123    ``caption`` is the line the HUD's caption track prints for this sound,
124    written in small caps as a diegetic phrase. It carries no glyphs, brackets
125    or asterisks: two blind playtests read punctuated captions as placeholder
126    text that had shipped, and the caption plate is what says "this is a sound"
127    now.
128    """
129
130    id: str
131    caption: str
132    duration: float
133    gain_db: float
134    loop: bool = False
135    bus: str = BUS_SFX
136
137
138CUES: dict[str, Cue] = {
139    "generator_hum": Cue("generator_hum", "GENERATOR HUM", 2.0, HUM_QUIET_DB, loop=True),
140    "hull_creak": Cue("hull_creak", "HULL CREAKS", 4.0, -22.0, loop=True),
141    "heartbeat": Cue("heartbeat", "HEARTBEAT", 1.0, -10.0, loop=True),
142    "breathing": Cue("breathing", "LABOURED BREATHING", 4.0, -14.0, loop=True),
143    "klaxon": Cue("klaxon", "KLAXON", 1.2, -14.0),
144    "sonar_ping": Cue("sonar_ping", "SONAR PING", 1.2, -10.0),
145    "groan": Cue("groan", "DISTANT GROAN", 3.0, -8.0),
146    "weapon_fire": Cue("weapon_fire", "SHOT", 0.12, -14.0),
147    "kill": Cue("kill", "KILL CHIME", 0.22, -10.0),
148    "spool_stutter": Cue("spool_stutter", "SPOOL STUTTERS", 0.35, -10.0),
149    "spawn_chevron": Cue("spawn_chevron", "HOSTILES INBOUND", 0.3, -12.0),
150    "mortar_whistle": Cue("mortar_whistle", "WHISTLE", 1.6, -13.0),
151    "signature_locked": Cue("signature_locked", "SIGNATURE LOCKED", 0.9, -12.0),
152    "tear_in": Cue("tear_in", "IT TEARS IN", 1.6, -9.0),
153    "lantern_burn": Cue("lantern_burn", "LANTERN BURN", 1.1, -9.0),
154}
155
156#: Cues the mix state machine holds open, in the order they are created.
157LOOP_CUES = tuple(cue.id for cue in CUES.values() if cue.loop)
158#: Loops the T-30 duck pulls down. The heartbeat is what the duck makes room for.
159AMBIENCE_CUES = ("generator_hum", "hull_creak", "breathing")
160
161#: Telegraph stage to cue, for the strict ladder in hunter.py.
162TELEGRAPH_CUES = {"t60": "groan", "t30": "klaxon", "t0": "tear_in"}
163
164
165def _clamp_gain(value: float) -> float:
166    """A slider reading clamped to the 0..1 fraction the mixer accepts."""
167    return 0.0 if value < 0.0 else 1.0 if value > 1.0 else float(value)
168
169
170def _gain_db(fraction: float) -> float:
171    """A linear volume fraction as decibels, with zero as :data:`BUS_SILENT_DB`."""
172    fraction = _clamp_gain(fraction)
173    return BUS_SILENT_DB if fraction <= 0.0 else max(BUS_SILENT_DB, 20.0 * math.log10(fraction))
174
175
176def subtitle(cue: str, pan: float = 0.0) -> str:
177    """The caption line for *cue*, naming the side a panned sound came from.
178
179    Small caps, no punctuation around it: the caption plate the HUD draws under
180    the line is the convention that marks a caption track, and the brackets this
181    used to carry read as an unfinished placeholder instead.
182    """
183    caption = CUES[cue].caption
184    if pan <= -0.4:
185        return f"{caption}, {CAPTION_LEFT}"
186    if pan >= 0.4:
187        return f"{caption}, {CAPTION_RIGHT}"
188    return caption
189
190
191# ============================================================================
192# Synthesis helpers (mono float32, all vectorised)
193# ============================================================================
194
195
196def _samples(duration: float) -> int:
197    return max(1, int(duration * SAMPLE_RATE))
198
199
200def _time(duration: float) -> np.ndarray:
201    return np.arange(_samples(duration), dtype=np.float32) / SAMPLE_RATE
202
203
204def _sweep(freq_hz: np.ndarray) -> np.ndarray:
205    """Phase-continuous sine over a per-sample frequency curve."""
206    phase = 2.0 * np.pi * np.cumsum(np.asarray(freq_hz, dtype=np.float64)) / SAMPLE_RATE
207    return np.sin(phase).astype(np.float32)
208
209
210def _smooth(signal: np.ndarray, window: int) -> np.ndarray:
211    """Boxcar low-pass via a cumulative sum: O(n) and fully vectorised."""
212    if window <= 1:
213        return signal
214    padded = np.concatenate((np.zeros(window, dtype=np.float64), signal.astype(np.float64)))
215    running = np.cumsum(padded)
216    return ((running[window:] - running[:-window]) / window).astype(np.float32)
217
218
219def _normalise(signal: np.ndarray, peak: float = 1.0) -> np.ndarray:
220    largest = float(np.max(np.abs(signal))) if signal.size else 0.0
221    if largest <= 1e-9:
222        return signal.astype(np.float32)
223    return (signal * (peak / largest)).astype(np.float32)
224
225
226def _fade_edges(signal: np.ndarray, seconds: float = 0.006) -> np.ndarray:
227    """Taper both ends so a one-shot cannot click."""
228    n = min(int(seconds * SAMPLE_RATE), signal.size // 2)
229    if n <= 0:
230        return signal
231    ramp = np.linspace(0.0, 1.0, n, dtype=np.float32)
232    out = signal.copy()
233    out[:n] *= ramp
234    out[-n:] *= ramp[::-1]
235    return out
236
237
238def _loopify(signal: np.ndarray, seconds: float = 0.15) -> np.ndarray:
239    """Crossfade the tail into the head so a noise bed loops without a seam."""
240    n = min(int(seconds * SAMPLE_RATE), signal.size // 3)
241    if n <= 0:
242        return signal
243    ramp = np.linspace(0.0, 1.0, n, dtype=np.float32)
244    head = signal[:n] * ramp + signal[-n:] * ramp[::-1]
245    return np.concatenate((head, signal[n:-n])).astype(np.float32)
246
247
248def _bake_mono(synth: AudioSynth, duration: float) -> np.ndarray:
249    """Render an AudioSynth to a mono buffer this module can pan itself."""
250    clip = synth.bake(duration, sample_rate=SAMPLE_RATE, channels=1)
251    return np.asarray(clip.backend_data, dtype=np.float32)
252
253
254def _stereo_clip(mono: np.ndarray, pan: float, name: str) -> AudioClip:
255    """Interleave *mono* into a stereo clip at *pan*, matching AudioSynth's law."""
256    left = mono * min(1.0, 1.0 - pan)
257    right = mono * min(1.0, 1.0 + pan)
258    interleaved = np.empty(mono.size * 2, dtype=np.float32)
259    interleaved[0::2] = left
260    interleaved[1::2] = right
261    np.clip(interleaved, -1.0, 1.0, out=interleaved)
262    return AudioClip.from_pcm(interleaved, sample_rate=SAMPLE_RATE, channels=2, name=name)
263
264
265# ============================================================================
266# Cue synthesis, one function per cue
267# ============================================================================
268
269
270def _generator_hum() -> np.ndarray:
271    """A fat mains drone. 55 and 56 Hz beat once a second, so 2 s loops seamlessly."""
272    synth = AudioSynth()
273    synth.add(Oscillator.sine(55.0), gain=0.45)
274    synth.add(Oscillator.sine(56.0), gain=0.30)
275    synth.add(Oscillator.sine(110.0), gain=0.18)
276    synth.add(Oscillator.triangle(220.0), gain=0.06)
277    return _normalise(_bake_mono(synth, CUES["generator_hum"].duration), 0.9)
278
279
280def _hull_creak() -> np.ndarray:
281    """Metal under pressure: a smoothed noise bed with two slow tonal groans."""
282    duration = CUES["hull_creak"].duration
283    t = _time(duration)
284    rng = np.random.default_rng(NOISE_SEED)
285    bed = _smooth(rng.standard_normal(t.size).astype(np.float32), 260) * 5.0
286    groan = 0.35 * np.sin(2.0 * np.pi * 47.0 * t) * (0.55 + 0.45 * np.sin(2.0 * np.pi * 0.25 * t))
287    ticks = np.zeros(t.size, dtype=np.float32)
288    for start_s, freq in ((0.9, 131.0), (2.6, 97.0)):
289        start = int(start_s * SAMPLE_RATE)
290        span = int(0.18 * SAMPLE_RATE)
291        local = t[:span]
292        ticks[start : start + span] += 0.4 * np.sin(2.0 * np.pi * freq * local) * np.exp(-local / 0.045)
293    return _loopify(_normalise(bed + groan + ticks, 0.75))
294
295
296def _heartbeat() -> np.ndarray:
297    """Two chest thumps a second: a falling sub sweep plus a soft transient."""
298    duration = CUES["heartbeat"].duration
299    out = np.zeros(_samples(duration), dtype=np.float32)
300    rng = np.random.default_rng(NOISE_SEED + 1)
301    span = int(0.22 * SAMPLE_RATE)
302    local = _time(0.22)
303    for start_s, level in ((0.0, 1.0), (0.28, 0.72)):
304        thump = _sweep(np.linspace(64.0, 34.0, span, dtype=np.float32)) * np.exp(-local / 0.055)
305        knock = _smooth(rng.standard_normal(span).astype(np.float32), 120) * 3.0 * np.exp(-local / 0.012)
306        start = int(start_s * SAMPLE_RATE)
307        out[start : start + span] += level * (thump + 0.5 * knock)
308    return _normalise(out, 0.85)
309
310
311def _breathing() -> np.ndarray:
312    """One breath cycle under a failing scrubber: inhale, hold, exhale, gap."""
313    duration = CUES["breathing"].duration
314    t = _time(duration)
315    rng = np.random.default_rng(NOISE_SEED + 2)
316    inhale = np.clip((t - 0.15) / 1.45, 0.0, 1.0) * np.clip((1.75 - t) / 0.35, 0.0, 1.0)
317    exhale = np.clip((t - 2.05) / 0.25, 0.0, 1.0) * np.clip((3.6 - t) / 1.4, 0.0, 1.0)
318    breath = _smooth(rng.standard_normal(t.size).astype(np.float32), 34) * 5.0
319    voiced = 0.18 * np.sin(2.0 * np.pi * 187.0 * t)
320    return _loopify(_normalise(breath * (inhale + 0.85 * exhale) + voiced * exhale, 0.8), 0.2)
321
322
323def _klaxon() -> np.ndarray:
324    """Two-tone alarm: a high slot and a low slot, gated so the tones separate."""
325    duration = CUES["klaxon"].duration
326    t = _time(duration)
327    slot = np.floor(t / KLAXON_SLOT_S).astype(np.int32)
328    freq = np.where(slot % 2 == 0, KLAXON_HIGH_HZ, KLAXON_LOW_HZ).astype(np.float32)
329    phase = 2.0 * np.pi * np.cumsum(freq.astype(np.float64)) / SAMPLE_RATE
330    tone = np.sin(phase) + 0.28 * np.sin(2.0 * phase) + 0.12 * np.sin(3.0 * phase)
331    within = (t % KLAXON_SLOT_S) / KLAXON_SLOT_S
332    gate = np.clip(np.minimum(within, 1.0 - within) / 0.08, 0.0, 1.0)
333    return _normalise(tone.astype(np.float32) * gate.astype(np.float32), 0.95)
334
335
336def _sonar_ping() -> np.ndarray:
337    """A descending chirp and three decaying returns: something is looking for you."""
338    duration = CUES["sonar_ping"].duration
339    out = np.zeros(_samples(duration), dtype=np.float32)
340    span = int(0.12 * SAMPLE_RATE)
341    local = _time(0.12)
342    ping = _sweep(np.linspace(1900.0, 950.0, span, dtype=np.float32)) * np.exp(-local / 0.04)
343    for index, level in enumerate((1.0, 0.45, 0.2, 0.09)):
344        start = int(index * 0.28 * SAMPLE_RATE)
345        out[start : start + span] += ping * level
346    return _normalise(out, 0.9)
347
348
349def _groan() -> np.ndarray:
350    """The T-60 sub-bass: a slow swell falling from 30 Hz to 18 Hz."""
351    duration = CUES["groan"].duration
352    t = _time(duration)
353    rng = np.random.default_rng(NOISE_SEED + 3)
354    freq = np.linspace(30.0, 18.0, t.size, dtype=np.float32)
355    phase = 2.0 * np.pi * np.cumsum(freq.astype(np.float64)) / SAMPLE_RATE
356    body = np.sin(phase) + 0.35 * np.sin(2.0 * phase) + 0.12 * np.sin(3.0 * phase)
357    rumble = _smooth(rng.standard_normal(t.size).astype(np.float32), 420) * 4.0
358    swell = np.sin(np.pi * t / duration) ** 1.4
359    return _fade_edges(_normalise((body.astype(np.float32) + 0.35 * rumble) * swell, 0.95))
360
361
362def _weapon_fire() -> np.ndarray:
363    """A short bright zap: warm gold in the ear as well as on screen."""
364    duration = CUES["weapon_fire"].duration
365    synth = AudioSynth()
366    synth.add(Oscillator.saw(720.0), envelope=ADSR(attack=0.001, decay=0.05, sustain=0.0, release=0.03), gain=0.5)
367    synth.add(Oscillator.sine(1440.0), envelope=Exponential(start=1.0, end=0.02, power=2.0), gain=0.3)
368    synth.add(Oscillator.noise.white(NOISE_SEED + 4), envelope=Exponential(start=0.6, end=0.01, power=3.0), gain=0.2)
369    return _fade_edges(_normalise(_bake_mono(synth, duration), 0.9), 0.003)
370
371
372def _kill() -> np.ndarray:
373    """The ladder rung: a clean two-tone chime the combo pitches upward."""
374    duration = CUES["kill"].duration
375    synth = AudioSynth()
376    synth.add(Oscillator.sine(523.0), envelope=Exponential(start=1.0, end=0.01, power=2.0), gain=0.55)
377    synth.add(Oscillator.triangle(784.0), envelope=Exponential(start=0.7, end=0.01, power=2.6), gain=0.3)
378    synth.add(Oscillator.sine(1046.0), envelope=Exponential(start=0.35, end=0.01, power=3.4), gain=0.15)
379    return _fade_edges(_normalise(_bake_mono(synth, duration), 0.9), 0.004)
380
381
382def _spool_stutter() -> np.ndarray:
383    """The warp ring losing its grip: a falling sweep chopped six times."""
384    duration = CUES["spool_stutter"].duration
385    t = _time(duration)
386    freq = np.linspace(900.0, 300.0, t.size, dtype=np.float32)
387    tone = _sweep(freq)
388    chop = (np.sin(2.0 * np.pi * 17.0 * t) > -0.2).astype(np.float32)
389    return _fade_edges(_normalise(tone * chop * np.exp(-t / 0.22), 0.9))
390
391
392def _spawn_chevron() -> np.ndarray:
393    """The edge chevron: a rising chirp that tells you which way to look."""
394    duration = CUES["spawn_chevron"].duration
395    t = _time(duration)
396    freq = np.linspace(420.0, 1420.0, t.size, dtype=np.float32)
397    tone = _sweep(freq) + 0.25 * _sweep(freq * 2.0)
398    return _fade_edges(_normalise(tone * np.exp(-t / 0.11), 0.9))
399
400
401def _mortar_whistle() -> np.ndarray:
402    """The incoming-shell scream: a falling whistle that swells as it drops."""
403    duration = CUES["mortar_whistle"].duration
404    t = _time(duration)
405    fall = np.linspace(1350.0, 460.0, t.size, dtype=np.float32)
406    vibrato = 1.0 + 0.014 * np.sin(2.0 * np.pi * 9.0 * t)
407    freq = fall * vibrato.astype(np.float32)
408    tone = _sweep(freq) + 0.3 * _sweep(freq * 2.0)
409    swell = np.clip(t / (duration * 0.8), 0.0, 1.0) ** 1.5
410    return _fade_edges(_normalise(tone * (0.3 + 0.7 * swell.astype(np.float32)), 0.9))
411
412
413def _signature_locked() -> np.ndarray:
414    """The stamp under SIGNATURE LOCKED: a low, final two-note fall."""
415    duration = CUES["signature_locked"].duration
416    synth = AudioSynth()
417    synth.add(Oscillator.sine(110.0), envelope=ADSR(attack=0.004, decay=0.3, sustain=0.3, release=0.4), gain=0.5)
418    synth.add(Oscillator.sine(73.0), envelope=Linear(start=1.0, end=0.0), gain=0.35)
419    synth.add(Oscillator.triangle(220.0), envelope=Exponential(start=0.5, end=0.01, power=2.0), gain=0.2)
420    return _fade_edges(_normalise(_bake_mono(synth, duration), 0.95))
421
422
423def _tear_in() -> np.ndarray:
424    """T-0: the sector opens. A sub plunge under a wall of rising noise."""
425    duration = CUES["tear_in"].duration
426    t = _time(duration)
427    rng = np.random.default_rng(NOISE_SEED + 5)
428    plunge = _sweep(np.linspace(210.0, 32.0, t.size, dtype=np.float32))
429    tear = _smooth(rng.standard_normal(t.size).astype(np.float32), 60) * 4.0
430    rise = np.clip(t / (duration * 0.7), 0.0, 1.0) ** 2.0
431    return _fade_edges(_normalise(plunge * (1.0 - 0.4 * rise) + tear * rise, 0.98))
432
433
434def _lantern_burn() -> np.ndarray:
435    """The lantern opening on the hull: a hard onset into a searing wash.
436
437    The burn used to borrow the klaxon, which is the sound of the ship's own
438    alarm and says nothing about what is doing the burning. So it is a sound
439    with no alarm in it at all: a bright band of noise that arrives at once and
440    then holds and decays, under a high pair of tones beating against each
441    other. Nothing in the mix rises the way this does, which is the point: the
442    caption track can now name the animal instead of reporting a klaxon.
443    """
444    duration = CUES["lantern_burn"].duration
445    t = _time(duration)
446    rng = np.random.default_rng(NOISE_SEED + 6)
447    # The wash: broadband noise smoothed just enough to lose its grain, with an
448    # onset short enough to read as a beam that was already on when it arrived.
449    wash = _smooth(rng.standard_normal(t.size).astype(np.float32), 12) * 3.0
450    onset = np.clip(t / 0.015, 0.0, 1.0).astype(np.float32)
451    decay = np.exp(-t / (duration * 0.42)).astype(np.float32)
452    # The two tones, a shade apart so they beat rather than harmonise: light
453    # that hurts, not a chord.
454    shimmer = _sweep(np.full(t.size, 1580.0, dtype=np.float32)) + _sweep(np.full(t.size, 1633.0, dtype=np.float32))
455    return _fade_edges(_normalise((wash + 0.35 * shimmer) * onset * decay, 0.97))
456
457
458_BUILDERS = {
459    "generator_hum": _generator_hum,
460    "hull_creak": _hull_creak,
461    "heartbeat": _heartbeat,
462    "breathing": _breathing,
463    "klaxon": _klaxon,
464    "sonar_ping": _sonar_ping,
465    "groan": _groan,
466    "weapon_fire": _weapon_fire,
467    "kill": _kill,
468    "spool_stutter": _spool_stutter,
469    "spawn_chevron": _spawn_chevron,
470    "mortar_whistle": _mortar_whistle,
471    "signature_locked": _signature_locked,
472    "tear_in": _tear_in,
473    "lantern_burn": _lantern_burn,
474}
475
476
477# ============================================================================
478# AudioDirector
479# ============================================================================
480
481
482class AudioDirector(Node):
483    """The run's single audio authority: singleton ``Services.AUDIO``.
484
485    Bakes the cue bank on entry, plays one-shots through a round-robin voice
486    pool, holds the looping beds open, and reconciles the mix whenever the power
487    state, the oxygen state or the hunter ladder moves.
488
489    Cue ids are a stable contract shared with the HUD's subtitle line:
490    ``generator_hum``, ``hull_creak``, ``heartbeat``, ``breathing``, ``klaxon``,
491    ``sonar_ping``, ``groan``, ``weapon_fire``, ``kill``, ``spool_stutter``,
492    ``spawn_chevron``, ``mortar_whistle``, ``signature_locked``, ``tear_in``.
493    Every play emits :attr:`audio_cue` with the cue id and its caption, so
494    nothing this module does is audible only.
495
496    The three mixer sliders arrive through :meth:`set_bus_volumes` and are
497    applied on top of the authored per-cue gains, never into them: the cue table
498    keeps the balance the mix was tuned to and the player scales the result.
499
500    Wiring is deferred through :class:`~shrike.power.SignalWiring`: the hunter,
501    the wave composer and the socketed generator all mount long after the
502    director does, and the generator comes and goes with the socket.
503    """
504
505    #: (cue_id, caption). The HUD's caption track is the only consumer that must
506    #: exist; anything wanting cue-accurate reactions may connect too.
507    audio_cue = Signal(str, str)
508
509    def __init__(self, **kwargs):
510        super().__init__(**kwargs)
511        self.generator_running = False
512        self.silent_running = False
513        self.low_oxygen = False
514        self.ducked = False
515        self.signature = 0.0
516        self.kill_combo = 0
517        self.last_cue: str | None = None
518        self.last_pan = 0.0
519        self._mono: dict[str, np.ndarray] = {}
520        self._clips: dict[tuple[str, float], AudioClip] = {}
521        self._voices: list[AudioPlayer] = []
522        self._voice_index = 0
523        self._loops: dict[str, AudioPlayer] = {}
524        self._combo_timer = 0.0
525        self._ping_timer = 0.0
526        self._wiring = SignalWiring(self)
527        self._lowpass: LowPassFilter | None = None
528        self._music_base_db: float | None = None
529        #: The player's three sliders as linear fractions of full volume.
530        self._bus_gain: dict[str, float] = {BUS_MASTER: 1.0, BUS_SFX: 1.0, BUS_MUSIC: 1.0}
531
532    # ------------------------------------------------------------------
533    # Lifecycle
534    # ------------------------------------------------------------------
535
536    def on_ready(self):
537        self._bake_bank()
538        for index in range(ONE_SHOT_VOICES):
539            self._voices.append(self.add_child(AudioPlayer(name=f"Voice{index}", bus=BUS_SFX)))
540        for cue_id in LOOP_CUES:
541            cue = CUES[cue_id]
542            self._loops[cue_id] = self.add_child(
543                AudioPlayer(
544                    name=cue_id.title().replace("_", ""),
545                    stream=self._clip(cue_id, 0.0),
546                    loop=True,
547                    bus=cue.bus,
548                    volume_db=cue.gain_db,
549                )
550            )
551        for signal_name, handler in (
552            (SignalNames.GENERATOR_CHANGED, self._on_generator_changed),
553            (SignalNames.SILENT_RUNNING_CHANGED, self._on_silent_running_changed),
554            (SignalNames.O2_CHANGED, self._on_o2_changed),
555            (SignalNames.SIGNATURE_CHANGED, self._on_signature_changed),
556            (SignalNames.SIGNATURE_LOCKED, self._on_signature_locked),
557            (SignalNames.ENEMY_KILLED, self._on_enemy_killed),
558            (SignalNames.WEAPON_FIRED, self._on_weapon_fired),
559            (SignalNames.WAVE_SPAWNED, self._on_wave_spawned),
560            (SignalNames.SHELL_LAUNCHED, self._on_shell_launched),
561            (SignalNames.HUNTER_TELEGRAPH, self._on_hunter_telegraph),
562            (SignalNames.HUNTER_DEPARTED, self._on_hunter_departed),
563            (SignalNames.WARP_SPOOL_INTERRUPTED, self._on_spool_interrupted),
564        ):
565            self._wiring.want(signal_name, handler)
566        self._wiring.sweep()
567
568    def on_exit_tree(self):
569        """Hand the shared bus layout back exactly as it was found."""
570        self._release_master_lowpass()
571        if self._music_base_db is not None:
572            layout = AudioBusLayout.get_default()
573            if layout.has_bus(BUS_MUSIC):
574                layout.get_bus(BUS_MUSIC).volume_db = self._music_base_db
575            self._music_base_db = None
576
577    def on_update(self, dt: float):
578        self._wiring.poll(dt)
579        if self._combo_timer > 0.0:
580            self._combo_timer -= dt
581            if self._combo_timer <= 0.0:
582                self.kill_combo = 0
583        self._tick_sonar(dt)
584
585    # ------------------------------------------------------------------
586    # Public surface
587    # ------------------------------------------------------------------
588
589    def play(self, cue: str, *, position: Vec3 | None = None) -> None:
590        """Play *cue*, panned toward *position* when one is given.
591
592        Loop cues start their bed; every cue emits its subtitle glyph.
593        """
594        spec = self._spec(cue)
595        pan = self._pan_for(position)
596        if spec.loop:
597            self._set_loop(cue, True, pan=pan)
598            return
599        if cue == "kill":
600            self._advance_kill_combo()
601        player = self._voices[self._voice_index]
602        self._voice_index = (self._voice_index + 1) % len(self._voices)
603        player.stop()
604        player.stream = self._clip(cue, pan)
605        player.volume_db = self._volume_for(cue)
606        player.pitch_scale = self.kill_pitch() if cue == "kill" else 1.0
607        player.play()
608        self._announce(cue, pan)
609
610    def stop(self, cue: str) -> None:
611        """Stop a looping cue. One-shots retire themselves."""
612        if self._spec(cue).loop:
613            self._set_loop(cue, False)
614
615    def set_power_state(self, *, generator: bool, silent: bool) -> None:
616        """Set the two power flags the mix is built on and reconcile it."""
617        self.generator_running = bool(generator)
618        self.silent_running = bool(silent)
619        self._reconcile()
620
621    def duck_to_heartbeat(self, active: bool) -> None:
622        """Pull the bed down and let the heartbeat through (the T-30 duck)."""
623        self.ducked = bool(active)
624        self._reconcile()
625
626    def set_bus_volumes(self, master: float = 1.0, sfx: float = 1.0, music: float = 1.0) -> None:
627        """Set the player's three mixer sliders, as fractions from 0 to 1.
628
629        The sliders are a trim laid over the authored mix rather than a rewrite
630        of it: :data:`CUES` keeps the gains the balance pass tuned, and every
631        voice's ``volume_db`` is the cue's own gain plus this trim. A slider at
632        zero is :data:`BUS_SILENT_DB`, which is silence at any master setting.
633
634        ``master`` multiplies the other two, so the effects bed answers both its
635        own slider and the master, and so does the music bus.
636        """
637        self._bus_gain[BUS_MASTER] = _clamp_gain(master)
638        self._bus_gain[BUS_SFX] = _clamp_gain(sfx)
639        self._bus_gain[BUS_MUSIC] = _clamp_gain(music)
640        self._reconcile()
641
642    @property
643    def bus_volumes(self) -> tuple[float, float, float]:
644        """The three sliders as ``(master, sfx, music)``, for the settings screen."""
645        return (self._bus_gain[BUS_MASTER], self._bus_gain[BUS_SFX], self._bus_gain[BUS_MUSIC])
646
647    def bus_trim_db(self, bus: str) -> float:
648        """Decibels the sliders add to anything playing on *bus*."""
649        return _gain_db(self._bus_gain[BUS_MASTER] * self._bus_gain.get(bus, 1.0))
650
651    @property
652    def mix_state(self) -> str:
653        """The mix in one word, most urgent first.
654
655        ``"ducked"`` (the heartbeat has the floor), ``"silent"`` (the whole mix
656        is low-passed to creaks), ``"strained"`` (low oxygen, breathing in
657        front), ``"hum"`` (the generator's room tone is the loudest thing) or
658        ``"clear"``.
659        """
660        if self.ducked:
661            return "ducked"
662        if self.silent_running:
663            return "silent"
664        if self.low_oxygen:
665            return "strained"
666        if self.generator_running:
667            return "hum"
668        return "clear"
669
670    @property
671    def master_cutoff_hz(self) -> float | None:
672        """The active whole-mix low-pass cutoff, or None when the mix is open."""
673        cutoffs = []
674        if self.silent_running:
675            cutoffs.append(SILENT_RUNNING_CUTOFF_HZ)
676        if self.low_oxygen:
677            cutoffs.append(LOW_O2_CUTOFF_HZ)
678        return min(cutoffs) if cutoffs else None
679
680    def kill_pitch(self) -> float:
681        """Pitch multiplier for the next kill cue: a semitone per combo rung."""
682        steps = min(KILL_LADDER_MAX_STEPS, max(0, self.kill_combo - 1))
683        return min(2.0, KILL_LADDER_SEMITONE**steps)
684
685    def is_playing(self, cue: str) -> bool:
686        """True while a looping cue's bed is open."""
687        player = self._loops.get(cue)
688        return player is not None and player.is_playing()
689
690    # ------------------------------------------------------------------
691    # Cue bank
692    # ------------------------------------------------------------------
693
694    def _bake_bank(self) -> None:
695        for cue_id, build in _BUILDERS.items():
696            self._mono[cue_id] = build()
697            self._clips[(cue_id, 0.0)] = _stereo_clip(self._mono[cue_id], 0.0, cue_id)
698
699    def _clip(self, cue: str, pan: float) -> AudioClip:
700        """The clip for *cue* at *pan*, baking the panned variant on first use."""
701        key = (cue, pan)
702        clip = self._clips.get(key)
703        if clip is None:
704            clip = _stereo_clip(self._mono[cue], pan, f"{cue}@{pan:+.1f}")
705            self._clips[key] = clip
706        return clip
707
708    @staticmethod
709    def _spec(cue: str) -> Cue:
710        spec = CUES.get(cue)
711        if spec is None:
712            raise KeyError(f"unknown audio cue {cue!r}; known cues: {', '.join(sorted(CUES))}")
713        return spec
714
715    # ------------------------------------------------------------------
716    # Mix state machine
717    # ------------------------------------------------------------------
718
719    def _reconcile(self) -> None:
720        self._apply_master_lowpass()
721        self._set_loop("generator_hum", self.generator_running)
722        self._set_loop("hull_creak", self.silent_running)
723        self._set_loop("breathing", self.low_oxygen)
724        self._set_loop("heartbeat", self.ducked)
725        for cue_id, player in self._loops.items():
726            player.volume_db = self._volume_for(cue_id)
727        self._apply_music_duck()
728
729    def _volume_for(self, cue: str) -> float:
730        """The volume a voice playing *cue* takes: authored gain plus the trims.
731
732        The order matters. The cue's own gain is the mix as tuned, the duck is
733        the game leaning on it, and the player's slider is last, so a pilot who
734        pulls the effects down does not flatten the ramp that makes the hum a
735        warning.
736        """
737        spec = CUES[cue]
738        if cue == "generator_hum":
739            fraction = min(1.0, max(0.0, self.signature / balance.SIGNATURE_MAX))
740            level = HUM_QUIET_DB + (HUM_LOUD_DB - HUM_QUIET_DB) * fraction
741        else:
742            level = spec.gain_db
743        if self.ducked and cue in AMBIENCE_CUES:
744            level += DUCK_AMBIENCE_DB
745        return level + self.bus_trim_db(spec.bus)
746
747    def _apply_music_duck(self) -> None:
748        layout = AudioBusLayout.get_default()
749        if not layout.has_bus(BUS_MUSIC):
750            return
751        music = layout.get_bus(BUS_MUSIC)
752        if self._music_base_db is None:
753            self._music_base_db = float(music.volume_db)
754        duck = DUCK_MUSIC_DB if self.ducked else 0.0
755        music.volume_db = self._music_base_db + duck + self.bus_trim_db(BUS_MUSIC)
756
757    def _apply_master_lowpass(self) -> None:
758        cutoff = self.master_cutoff_hz
759        if cutoff is None:
760            self._release_master_lowpass()
761            return
762        if self._lowpass is None:
763            self._lowpass = LowPassFilter(cutoff_hz=cutoff, q=MASTER_LOWPASS_Q)
764            AudioBusLayout.get_default().get_bus(BUS_MASTER).add_effect(self._lowpass)
765        else:
766            self._lowpass.cutoff_hz = cutoff
767
768    def _release_master_lowpass(self) -> None:
769        if self._lowpass is None:
770            return
771        layout = AudioBusLayout.get_default()
772        if layout.has_bus(BUS_MASTER):
773            layout.get_bus(BUS_MASTER).remove_effect(self._lowpass)
774        self._lowpass = None
775
776    def _set_loop(self, cue: str, active: bool, *, pan: float = 0.0) -> None:
777        player = self._loops.get(cue)
778        if player is None:
779            return
780        if active and not player.is_playing():
781            player.volume_db = self._volume_for(cue)
782            player.play()
783            self._announce(cue, pan)
784        elif not active and player.is_playing():
785            player.stop()
786
787    def _announce(self, cue: str, pan: float) -> None:
788        self.last_cue = cue
789        self.last_pan = pan
790        self.audio_cue(cue, subtitle(cue, pan))
791
792    # ------------------------------------------------------------------
793    # Spatialisation and timers
794    # ------------------------------------------------------------------
795
796    def _pan_for(self, position: Vec3 | None) -> float:
797        if position is None:
798            return 0.0
799        offset = (float(position.x) - self._listener_x()) / PAN_FULL_WIDTH
800        offset = max(-1.0, min(1.0, offset))
801        return min(PAN_BUCKETS, key=lambda bucket: abs(bucket - offset))
802
803    def _listener_x(self) -> float:
804        ship = self._first_in_group(Groups.SHIP)
805        return float(ship.position.x) if ship is not None else 0.0
806
807    def _first_in_group(self, group: str) -> Node | None:
808        if self.tree is None:
809            return None
810        members = self.tree.group(group)
811        return members[0] if members else None
812
813    def _advance_kill_combo(self) -> None:
814        self.kill_combo = self.kill_combo + 1 if self._combo_timer > 0.0 else 1
815        self._combo_timer = balance.KILL_COMBO_WINDOW_S
816
817    def _tick_sonar(self, dt: float) -> None:
818        """While the mix is silent, the Shrike's own ping is what fills it."""
819        hunter = self._first_in_group(Groups.HUNTER) if self.silent_running else None
820        if hunter is None:
821            self._ping_timer = 0.0
822            return
823        self._ping_timer -= dt
824        if self._ping_timer <= 0.0:
825            self._ping_timer = SONAR_PING_INTERVAL_S
826            self.play("sonar_ping", position=hunter.position)
827
828    # ------------------------------------------------------------------
829    # Signal handlers
830    # ------------------------------------------------------------------
831
832    def _on_generator_changed(self, running: bool) -> None:
833        self.set_power_state(generator=bool(running), silent=self.silent_running)
834
835    def _on_silent_running_changed(self, active: bool) -> None:
836        self.set_power_state(generator=self.generator_running, silent=bool(active))
837
838    def _on_o2_changed(self, current: float, maximum: float) -> None:
839        seconds_left = float(current) / balance.O2_DRAIN_PER_S
840        low = seconds_left < balance.O2_LOW_WARNING_SECONDS
841        if low != self.low_oxygen:
842            self.low_oxygen = low
843            self._reconcile()
844
845    def _on_signature_changed(self, value: float) -> None:
846        self.signature = float(value)
847        hum = self._loops.get("generator_hum")
848        if hum is not None:
849            hum.volume_db = self._volume_for("generator_hum")
850
851    def _on_signature_locked(self) -> None:
852        self.play("signature_locked")
853
854    def _on_enemy_killed(self, archetype: str, position: Vec3, elite: bool) -> None:
855        self.play("kill", position=position)
856
857    def _on_weapon_fired(self, weapon_id: str) -> None:
858        self.play("weapon_fire")
859
860    def _on_wave_spawned(self, count: int) -> None:
861        self.play("spawn_chevron")
862
863    def _on_shell_launched(self, origin: Vec3, flight: float) -> None:
864        """A bombardier shell is in the air; whistle from the tube that fired it."""
865        self.play("mortar_whistle", position=origin)
866
867    def _on_hunter_telegraph(self, stage: str) -> None:
868        cue = TELEGRAPH_CUES.get(stage)
869        if cue is None:
870            return
871        self.play(cue)
872        if stage == "t30":
873            self.duck_to_heartbeat(True)
874
875    def _on_hunter_departed(self) -> None:
876        self.duck_to_heartbeat(False)
877
878    def _on_spool_interrupted(self, added_seconds: float) -> None:
879        self.play("spool_stutter")
880
881
882__all__ = [
883    "AudioDirector",
884    "BUS_MASTER",
885    "BUS_MUSIC",
886    "BUS_SFX",
887    "BUS_SILENT_DB",
888    "CUES",
889    "Cue",
890    "LOOP_CUES",
891    "TELEGRAPH_CUES",
892    "subtitle",
893]