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