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]