hollow / sting.py
Pabloler21's picture
feat(audio): audible seamless dread drone + louder menu bed
121e41a
Raw
History Blame Contribute Delete
9.55 kB
"""The bad-finale horror sting, synthesized at import time (~50 ms): a
dissonant descending shriek over a sub rumble with noise stabs. ~3.2 s mono
WAV. Generated in code so no binary lives in the repo (HF rejects raw .wav).
Tune and listen: `python sting.py` writes sting_preview.wav."""
import io
import wave
import numpy as np
_SR = 22050
_DUR = 3.2
def sting_wav_bytes() -> bytes:
t = np.linspace(0, _DUR, int(_SR * _DUR), endpoint=False)
rng = np.random.default_rng(13)
# 1. the shriek: inharmonic partials sweeping down with deepening vibrato
sweep = 2200 * np.exp(-1.1 * t) + 380
vibrato = 1 + 0.025 * np.sin(2 * np.pi * (5 + 3 * t) * t)
phase = 2 * np.pi * np.cumsum(sweep * vibrato) / _SR
shriek = (np.sin(phase) + 0.6 * np.sin(1.93 * phase)
+ 0.4 * np.sin(2.71 * phase))
shriek *= np.clip(t / 0.35, 0, 1) # swell in
shriek *= np.exp(-0.55 * t) # decay
# 2. sub rumble: slow 42-55 Hz wobble underneath everything
wob = 48 + 7 * np.sin(2 * np.pi * 0.7 * t)
rumble = np.sin(2 * np.pi * np.cumsum(wob) / _SR)
rumble *= np.clip(t / 0.8, 0, 1) * 0.9
# 3. noise stabs at the start and mid-convulsion
noise = rng.standard_normal(len(t))
stab = np.zeros_like(t)
for start, decay in ((0.0, 18.0), (1.5, 22.0)):
env = np.where(t >= start, np.exp(-decay * (t - start)), 0)
stab += env
mix = 0.9 * shriek + 0.8 * rumble + 0.5 * noise * stab
mix = np.tanh(1.6 * mix) # gentle distortion, glues layers
fade = int(0.5 * _SR)
mix[-fade:] *= np.linspace(1, 0, fade) # fade out
mix = mix / np.max(np.abs(mix)) * 0.6 # moderate volume, no blasting
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(_SR)
w.writeframes((mix * 32767).astype(np.int16).tobytes())
return buf.getvalue()
def _wav(mix: np.ndarray, sr: int) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes((mix * 32767).astype(np.int16).tobytes())
return buf.getvalue()
# Hollow's reply chime: a single detuned music-box note, one per turn,
# rotating through a small minor-feel set so it never becomes a loop.
_CHIME_FREQS = (659.3, 493.9, 784.0) # E5, B4, G5
def chime_wav_bytes(idx: int = 0) -> bytes:
sr = 22050
dur = 0.8
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
f = _CHIME_FREQS[idx % len(_CHIME_FREQS)]
def tine(freq):
# struck-tine partials, slightly inharmonic, fast decay
return (np.sin(2 * np.pi * freq * t)
+ 0.35 * np.sin(2 * np.pi * 2.76 * freq * t)
+ 0.12 * np.sin(2 * np.pi * 5.4 * freq * t))
# two oscillators a breath apart — the music box is out of tune
note = tine(f) + tine(f * 1.006)
note *= np.exp(-5.5 * t) # tine decay
note *= np.clip(t / 0.004, 0, 1) # 4 ms attack, no click
note = note / np.max(np.abs(note)) * 0.22 # quiet — plays every turn
return _wav(note, sr)
def _thud(t, center, freq, amp, width):
"""A single soft low drum hit centered at `center` seconds."""
env = np.exp(-((t - center) ** 2) / (2 * width ** 2))
return amp * np.sin(2 * np.pi * freq * t) * env
def heartbeat_wav_bytes() -> bytes:
"""A loopable lub-dub heartbeat bed for the finale build. Exactly two
beats (1.7 s) so the HTML `loop` attribute repeats seamlessly."""
sr = 22050
beat = 0.85 # ~70 bpm
beats = 2
dur = beat * beats
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
sig = np.zeros_like(t)
for b in range(beats):
b0 = b * beat
sig += _thud(t, b0 + 0.00, 60, 1.0, 0.035) # lub
sig += _thud(t, b0 + 0.16, 46, 0.7, 0.045) # dub
sig = np.tanh(1.5 * sig)
sig = sig / np.max(np.abs(sig)) * 0.45
return _wav(sig, sr)
def sigh_wav_bytes() -> bytes:
"""Relief for the redemption climax: a soft filtered exhale over a warm
major drone that swells and resolves. ~2.6 s."""
sr = 22050
dur = 2.6
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
rng = np.random.default_rng(7)
# breath: smoothed noise (a soft 'hhh') with a quick-in long-out envelope
noise = rng.standard_normal(len(t))
kernel = np.hanning(220)
kernel /= kernel.sum()
breath = np.convolve(noise, kernel, mode="same")
exhale = np.clip(t / 0.15, 0, 1) * np.exp(-1.6 * t)
breath *= exhale * 0.5
# warm resolving tone: G3 + major third + fifth, swelling then fading
tone = (np.sin(2 * np.pi * 196.00 * t)
+ 0.5 * np.sin(2 * np.pi * 246.94 * t)
+ 0.4 * np.sin(2 * np.pi * 293.66 * t))
swell = np.sin(np.pi * np.clip(t / dur, 0, 1))
tone *= swell * 0.25
mix = breath + tone
fade = int(0.3 * sr)
mix[-fade:] *= np.linspace(1, 0, fade)
mix = mix / np.max(np.abs(mix)) * 0.5
return _wav(mix, sr)
def flatline_wav_bytes() -> bytes:
"""The visitor-loop climax: three decelerating beats, then the line goes
flat — a sustained low tone that fades. Your heart stops being yours. ~4 s."""
sr = 22050
dur = 4.0
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
sig = np.zeros_like(t)
for i, c in enumerate((0.0, 0.95, 2.1)): # widening gaps
amp = 1.0 - 0.25 * i
sig += _thud(t, c, 58, amp, 0.035)
sig += _thud(t, c + 0.16, 46, 0.7 * amp, 0.045)
flat_start = 2.5
flat = np.where(t >= flat_start, 0.18 * np.sin(2 * np.pi * 55 * t), 0.0)
flat *= np.clip((t - flat_start) / 0.2, 0, 1)
sig += flat
fade = int(0.6 * sr)
sig[-fade:] *= np.linspace(1, 0, fade)
sig = sig / np.max(np.abs(sig)) * 0.5
return _wav(sig, sr)
def menu_loop_wav_bytes() -> bytes:
"""Deep dread menu bed, made audible: a dark hollow-fifth body in the
low-mids (heard on any speaker) over a felt sub, with a slow dissonant
swell and a distant toll. Seamless loop — every sustained component is
integer-cycle over the 16 s loop, so no fade-dip is needed at the seam."""
sr = 22050
dur = 16.0
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
# slow breathing, exactly 2 cycles over the loop (continuous at the seam)
breath = 0.8 + 0.2 * np.sin(2 * np.pi * (2.0 / dur) * t)
# 1. sub (felt): 41 + 27.5 Hz (41*16, 27.5*16 are integers)
sub = (np.sin(2 * np.pi * 41.0 * t)
+ 0.6 * np.sin(2 * np.pi * 27.5 * t)) * 0.16 * breath
# 2. audible body: a hollow fifth in the low-mids (65.5/98/131 Hz + harmonics)
def tone(f):
return (np.sin(2 * np.pi * f * t)
+ 0.4 * np.sin(2 * np.pi * 2 * f * t)
+ 0.18 * np.sin(2 * np.pi * 3 * f * t))
body = (tone(65.5) + 0.8 * tone(98.0) + 0.5 * tone(131.0)) * 0.07 * breath
# 3. slow dissonant swell: two close tones beating (minor-second tension)
swell_env = np.sin(np.pi * np.clip(t / dur, 0, 1)) ** 1.5 # 0 at both ends
swell = (np.sin(2 * np.pi * 110.0 * t)
+ np.sin(2 * np.pi * 116.5 * t)) * 0.05 * swell_env
# 4. distant toll ~every 5.3 s: struck tone, long decay, far and quiet
toll = np.zeros_like(t)
for c in (1.0, 6.3, 11.6):
env = np.where(t >= c, np.exp(-1.8 * (t - c)), 0.0)
strike = (np.sin(2 * np.pi * 98.0 * t)
+ 0.5 * np.sin(2 * np.pi * 147.0 * t)
+ 0.25 * np.sin(2 * np.pi * 196.0 * t))
toll += strike * env
echo = np.zeros_like(t) # room tail
for delay, gain in ((0.28, 0.30), (0.56, 0.15)):
ds = int(delay * sr)
echo[ds:] += toll[:-ds] * gain
toll = (toll + echo) * 0.14
mix = sub + body + swell + toll
mix = np.tanh(1.5 * mix)
mix = mix / np.max(np.abs(mix)) * 0.5 # audible; no end-fade -> seamless
return _wav(mix, sr)
def type_tick_wav_bytes() -> bytes:
"""A short dry tick for the intro typewriter: a low ~180 Hz burst with a
fast decay and a touch of noise, faintly detuned. Synthesized like the
other stings (HF rejects committed audio binaries)."""
sr = 22050
dur = 0.035
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
rng = np.random.default_rng(7)
body = (np.sin(2 * np.pi * 180 * t)
+ 0.4 * np.sin(2 * np.pi * 181.2 * t) # faint detune
+ 0.2 * np.sin(2 * np.pi * 360 * t))
click = rng.standard_normal(len(t)) * 0.25 # a little grit at the onset
env = np.exp(-t * 90) # fast percussive decay
mix = (body + click) * env
mix = mix / np.max(np.abs(mix)) * 0.5
return _wav(mix, sr)
if __name__ == "__main__":
from pathlib import Path
here = Path(__file__).parent
(here / "sting_preview.wav").write_bytes(sting_wav_bytes())
for i in range(3):
(here / f"chime_preview_{i}.wav").write_bytes(chime_wav_bytes(i))
(here / "heartbeat_preview.wav").write_bytes(heartbeat_wav_bytes())
(here / "sigh_preview.wav").write_bytes(sigh_wav_bytes())
(here / "flatline_preview.wav").write_bytes(flatline_wav_bytes())
(here / "menu_loop_preview.wav").write_bytes(menu_loop_wav_bytes())
(here / "type_tick_preview.wav").write_bytes(type_tick_wav_bytes())
print("written sting_preview.wav + chime_preview_0/1/2.wav + menu_loop_preview.wav + type_tick_preview.wav")