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]