All six rooms are now flux_local matte paintings quantized by the engine (basement, gig, alley, office, Roskilde, field), with rooms reduced to invisible sim ops: walkability, depth, water, the footlight chases, and the NO MEANS NO! spray painted OVER the brick painting. The office desk gets a real walk-behind from the priority bands. Soundtrack: tools/gen_music.py synthesizes five loop-safe mood overrides (music/*.wav) in the band's own meters — 7/4 walking bass for the basement, the 9/8 riff floored for gig and Roskilde, chromatic noir for the alley, puck-rock bounce for the office, a 5/4 drone for the field. Fixed the office east-door lane (crates pinched the exit trigger) and removed the alley's unreachable north exit. Playthrough: 60/60, won, byte-identical twice, one intentional death. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
7.0 KiB
Python
223 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Synthesize the NOMEANSNO QUEST soundtrack — stdlib only.
|
|
|
|
Five mood-override loops for MRPCI (music/<mood>.wav), built from the
|
|
band's own vocabulary: overdriven bass riffs in odd meters, brushed noise
|
|
hats, kick sweeps. 22050 Hz mono 16-bit, loop-safe (bar-aligned, short
|
|
fades). The engine loops them at low volume under the room.
|
|
|
|
python3 tools/gen_music.py
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import random
|
|
import struct
|
|
import wave
|
|
|
|
SR = 22050
|
|
OUT = os.path.join(os.path.dirname(__file__), "..", "music")
|
|
|
|
|
|
def clip(x):
|
|
return math.tanh(x) # soft overdrive — the Rob Wright pedal
|
|
|
|
|
|
def note_hz(semi): # semitones from A1 (55 Hz)
|
|
return 55.0 * (2 ** (semi / 12.0))
|
|
|
|
|
|
class Mix:
|
|
def __init__(self, seconds):
|
|
self.buf = [0.0] * int(SR * seconds)
|
|
|
|
def add(self, t0, samples, gain=1.0):
|
|
i0 = int(t0 * SR)
|
|
for i, s in enumerate(samples):
|
|
j = i0 + i
|
|
if 0 <= j < len(self.buf):
|
|
self.buf[j] += s * gain
|
|
|
|
def wav(self, path):
|
|
n = len(self.buf)
|
|
fade = int(SR * 0.02)
|
|
out = bytearray()
|
|
for i, v in enumerate(self.buf):
|
|
g = min(1.0, i / fade, (n - 1 - i) / fade)
|
|
out += struct.pack("<h", int(max(-1, min(1, clip(v))) * 30000 * g))
|
|
with wave.open(path, "wb") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(SR)
|
|
w.writeframes(bytes(out))
|
|
print(f" {os.path.basename(path)} {n / SR:.1f}s")
|
|
|
|
|
|
def bass(semi, dur, drive=2.2, decay=6.0):
|
|
"""Picked, overdriven bass: saw+square blend through the clipper."""
|
|
hz = note_hz(semi)
|
|
n = int(dur * SR)
|
|
out = []
|
|
for i in range(n):
|
|
t = i / SR
|
|
ph = (t * hz) % 1.0
|
|
saw = 2.0 * ph - 1.0
|
|
sq = 1.0 if ph < 0.5 else -1.0
|
|
env = math.exp(-t * decay) * min(1.0, i / 80.0)
|
|
out.append(clip((0.6 * saw + 0.4 * sq) * drive) * env * 0.5)
|
|
return out
|
|
|
|
|
|
def kick(dur=0.14):
|
|
n = int(dur * SR)
|
|
return [math.sin(2 * math.pi * (90 * math.exp(-i / SR * 22)) * (i / SR)) * math.exp(-i / SR * 30) * 0.9 for i in range(n)]
|
|
|
|
|
|
def snare(dur=0.12, rng=None):
|
|
n = int(dur * SR)
|
|
rng = rng or random.Random(7)
|
|
return [(rng.random() * 2 - 1) * math.exp(-i / SR * 34) * 0.55 for i in range(n)]
|
|
|
|
|
|
def hat(dur=0.05, rng=None, gain=0.2):
|
|
n = int(dur * SR)
|
|
rng = rng or random.Random(11)
|
|
return [(rng.random() * 2 - 1) * math.exp(-i / SR * 90) * gain for i in range(n)]
|
|
|
|
|
|
def stab(semis, dur, decay=9.0):
|
|
"""An angular chord stab (thin pulse voices) — the Kerr guitar ghost."""
|
|
n = int(dur * SR)
|
|
out = [0.0] * n
|
|
for semi in semis:
|
|
hz = note_hz(semi) * 2
|
|
for i in range(n):
|
|
t = i / SR
|
|
ph = (t * hz) % 1.0
|
|
v = 1.0 if ph < 0.25 else -1.0
|
|
out[i] += v * math.exp(-t * decay) * 0.16
|
|
return out
|
|
|
|
|
|
def drone(semi, dur, wob=0.3):
|
|
hz = note_hz(semi)
|
|
n = int(dur * SR)
|
|
return [
|
|
(math.sin(2 * math.pi * hz * (i / SR)) + 0.5 * math.sin(2 * math.pi * hz * 1.5 * (i / SR) + wob * math.sin(i / SR * 0.7)))
|
|
* 0.22
|
|
for i in range(n)
|
|
]
|
|
|
|
|
|
def build_calm():
|
|
"""Basement, 1979 — walking jazz bass in 7/4, brushes. John's homework."""
|
|
bpm, beats, bars = 92, 7, 4
|
|
spb = 60.0 / bpm
|
|
m = Mix(bars * beats * spb)
|
|
rng = random.Random(1979)
|
|
walk = [0, 3, 5, 7, 10, 7, 5, 3, 0, -2, 0, 3, 2, 5, 3, 7, 0, 3, 5, 7, 10, 12, 10, 7, 5, 3, 2, 0]
|
|
k = 0
|
|
for bar in range(bars):
|
|
for b in range(beats):
|
|
t = (bar * beats + b) * spb
|
|
m.add(t, bass(walk[k % len(walk)] - 12, spb * 0.95, drive=1.2, decay=3.0), 0.8)
|
|
k += 1
|
|
m.add(t, hat(rng=rng, gain=0.12))
|
|
if b in (2, 5):
|
|
m.add(t + spb * 0.5, hat(rng=rng, gain=0.08))
|
|
return m
|
|
|
|
|
|
def build_tense():
|
|
"""The gig / Roskilde — the 9/8 riff, floored."""
|
|
bpm, beats, bars = 168, 9, 8
|
|
spb = 60.0 / bpm
|
|
m = Mix(bars * beats * spb)
|
|
rng = random.Random(1994)
|
|
riff = [0, 0, 3, 0, 5, 6, 5, 3, 1]
|
|
for bar in range(bars):
|
|
root = -12 if bar % 4 < 2 else -7
|
|
for b in range(beats):
|
|
t = (bar * beats + b) * spb
|
|
m.add(t, bass(riff[b] + root, spb * 0.9, drive=3.0, decay=8.0), 0.9)
|
|
if b in (0, 3, 5):
|
|
m.add(t, kick())
|
|
if b in (2, 6, 8):
|
|
m.add(t, snare(rng=rng))
|
|
m.add(t, hat(rng=rng, gain=0.14))
|
|
if bar % 2 == 1:
|
|
m.add((bar * beats + 6) * spb, stab([12 + root, 18 + root, 22 + root], spb * 2))
|
|
return m
|
|
|
|
|
|
def build_noir():
|
|
"""The alley — rain, chromatic descent, someone's future on a wall."""
|
|
bpm, beats, bars = 66, 4, 6
|
|
spb = 60.0 / bpm
|
|
m = Mix(bars * beats * spb)
|
|
rng = random.Random(1981)
|
|
seq = [0, -1, -3, -5, -6, -5, -3, -1]
|
|
for bar in range(bars):
|
|
for b in range(beats):
|
|
t = (bar * beats + b) * spb
|
|
if b % 2 == 0:
|
|
m.add(t, bass(seq[(bar * 2 + b // 2) % len(seq)] - 12, spb * 1.8, drive=1.4, decay=2.2), 0.7)
|
|
m.add(t + spb * 0.5, hat(dur=0.09, rng=rng, gain=0.07))
|
|
if bar % 3 == 2:
|
|
m.add(bar * beats * spb, stab([7, 13], spb * 3, decay=3.0), 0.6)
|
|
return m
|
|
|
|
|
|
def build_jolly():
|
|
"""Alternative Tentacles — puck-rock bounce; the Hansons drop by."""
|
|
bpm, beats, bars = 150, 4, 8
|
|
spb = 60.0 / bpm
|
|
m = Mix(bars * beats * spb)
|
|
rng = random.Random(1987)
|
|
roots = [0, 0, 5, 3]
|
|
for bar in range(bars):
|
|
root = roots[bar % 4] - 12
|
|
for b in range(beats):
|
|
t = (bar * beats + b) * spb
|
|
m.add(t, bass(root, spb * 0.45, drive=2.4, decay=10.0), 0.85)
|
|
m.add(t + spb * 0.5, bass(root + 7, spb * 0.4, drive=2.0, decay=11.0), 0.6)
|
|
m.add(t, kick() if b % 2 == 0 else snare(rng=rng))
|
|
m.add(t + spb * 0.5, hat(rng=rng, gain=0.16))
|
|
if bar % 2 == 1:
|
|
m.add((bar * beats + 3) * spb, stab([root + 24, root + 28, root + 31], spb * 0.8))
|
|
return m
|
|
|
|
|
|
def build_eerie():
|
|
"""The field at dawn — a drone in fifths, toms like far thunder, 5/4."""
|
|
bpm, beats, bars = 76, 5, 4
|
|
spb = 60.0 / bpm
|
|
m = Mix(bars * beats * spb)
|
|
rng = random.Random(1997)
|
|
m.add(0, drone(-24, bars * beats * spb), 0.8)
|
|
m.add(0, drone(-17, bars * beats * spb, wob=0.8), 0.4)
|
|
for bar in range(bars):
|
|
for b in range(beats):
|
|
t = (bar * beats + b) * spb
|
|
if b in (0, 3):
|
|
m.add(t, kick(0.2), 0.5)
|
|
if b == 4 and bar % 2 == 1:
|
|
m.add(t, bass(-5 - 12, spb * 2, drive=1.1, decay=1.6), 0.5)
|
|
if rng.random() < 0.2:
|
|
m.add(t + spb * 0.5, hat(dur=0.12, rng=rng, gain=0.05))
|
|
return m
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT, exist_ok=True)
|
|
print("writing music:")
|
|
build_calm().wav(os.path.join(OUT, "calm.wav"))
|
|
build_tense().wav(os.path.join(OUT, "tense.wav"))
|
|
build_noir().wav(os.path.join(OUT, "noir.wav"))
|
|
build_jolly().wav(os.path.join(OUT, "jolly.wav"))
|
|
build_eerie().wav(os.path.join(OUT, "eerie.wav"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|