nomeansnoquest/tools/gen_music.py
m3ultra 2650c8b024 v1.1 — full MODELBEAST painting pass + jazzcore soundtrack
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>
2026-07-26 21:18:00 +10:00

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()