mrpquest/tools/gen_music.py
type-two e391417cf9 Web + music: chiptune soundtrack, browser shell, one-command deploy
- tools/gen_music.py: pure-stdlib 8-bit synth -> music/{calm,eerie,tense,
  jolly,spooky}.wav, five loop-clean mood tracks (the shop floor gets a
  four-on-the-floor house groove, obviously). Engine plays them per room;
  web build serves them next to the wasm.
- web/index.html: MORPQUEST page shell (EGA palette, README tab, no
  account chrome).
- deploy.sh: builds the wasm via MRPGI/web/build.sh with this game baked
  in and swaps it into forum-nginx:/usr/share/nginx/html/morpquest/,
  right next door to /beyondmorp/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:53:06 +10:00

133 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""8-bit chiptune generator -> music/{calm,eerie,tense,jolly,spooky}.wav
Pure stdlib (wave/math/struct), deterministic, loop-clean (whole bars, note
envelopes close before the bar line). The engine plays these per room mood at
low volume; anything missing falls back to the built-in synth.
"""
import math, os, struct, wave
SR = 22050
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'music')
os.makedirs(OUT, exist_ok=True)
def note(name):
"""'c4' -> Hz ('-' = rest -> 0)."""
if name in ('-', ''):
return 0.0
names = {'c': 0, 'cs': 1, 'd': 2, 'ds': 3, 'e': 4, 'f': 5, 'fs': 6,
'g': 7, 'gs': 8, 'a': 9, 'as': 10, 'b': 11}
pitch, octave = name[:-1], int(name[-1])
return 440.0 * 2 ** ((names[pitch] - 9) / 12 + octave - 4)
def square(freq, t, duty=0.5):
return 1.0 if (t * freq) % 1.0 < duty else -1.0
def tri(freq, t):
p = (t * freq) % 1.0
return 4 * p - 1 if p < 0.5 else 3 - 4 * p
_lfsr = 0xACE1
def _noise_step():
global _lfsr
bit = ((_lfsr >> 0) ^ (_lfsr >> 2) ^ (_lfsr >> 3) ^ (_lfsr >> 5)) & 1
_lfsr = (_lfsr >> 1) | (bit << 15)
return (_lfsr & 0xFF) / 127.5 - 1.0
def render(length_s, voices):
"""voices: list of (events, wave_fn, gain). events: (start_s, dur_s, freq[, duty])."""
n = int(length_s * SR)
buf = [0.0] * n
for events, fn, gain in voices:
for ev in events:
start, dur, freq = ev[0], ev[1], ev[2]
duty = ev[3] if len(ev) > 3 else 0.5
if freq == 0:
continue
i0, i1 = int(start * SR), min(int((start + dur) * SR), n)
body = i1 - i0
atk, rel = int(0.004 * SR), max(1, int(0.05 * SR))
nz = freq < 0 # negative freq = noise burst
for i in range(body):
t = i / SR
env = min(1.0, i / atk if atk else 1.0, (body - i) / rel)
if nz:
s = _noise_step()
elif fn is square:
s = square(freq, t, duty)
else:
s = fn(freq, t)
buf[i0 + i] += s * env * gain
peak = max(1e-6, max(abs(s) for s in buf))
scale = 0.82 / peak
return b''.join(struct.pack('<h', int(max(-1, min(1, s * scale)) * 32767)) for s in buf)
def write(name, pcm):
with wave.open(os.path.join(OUT, f'{name}.wav'), 'wb') as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(pcm)
print(f'{name}.wav {len(pcm) // 2 / SR:.1f}s')
def seq(pattern, step, wave_fn, gain, dur_frac=0.9, duty=0.5, t0=0.0):
"""'c4 e4 - g4' -> events on a fixed step grid."""
ev = []
for i, tok in enumerate(pattern.split()):
f = -1.0 if tok == 'x' else note(tok)
if f:
ev.append((t0 + i * step, step * dur_frac, f, duty))
return (ev, wave_fn, gain)
# --- calm: the last calm place before you choose suffering on purpose -------
# 60 bpm, 4 bars. Cmaj7 / Fmaj7 arps over a soft triangle bass.
B = 1.0 # beat
calm = render(16.0, [
seq('c3 - - - f2 - - - c3 - - - f2 - g2 -', B, tri, 0.50, 0.95),
seq('e4 g4 b4 g4 a4 c5 e5 c5 e4 g4 b4 g4 a4 c5 d5 b4', B, square, 0.16, 0.55, 0.25),
seq('- - c5 - - - f5 - - - g5 - - - e5 -', B, tri, 0.12, 0.5),
])
write('calm', calm)
# --- eerie: the alley knows your name ----------------------------------------
# Slow tritone drift (A / Eb), long gaps, one distant ping per 4 bars.
eerie = render(16.0, [
([(0.0, 7.6, note('a2')), (8.0, 7.6, note('ds3'))], tri, 0.40),
([(0.0, 7.6, note('a2') * 1.006), (8.0, 7.6, note('ds3') * 1.007)], tri, 0.22),
seq('- - - a4 - - - - - - ds5 - - - - -', B, square, 0.07, 0.3, 0.18),
([(11.5, 0.12, -1.0)], square, 0.05),
])
write('eerie', eerie)
# --- tense: owner energy approaches ------------------------------------------
# 120 bpm staccato D-minor bass, ticking hat, no resolution on purpose.
S = 0.25 # 16th at 120
tense = render(12.0, [
seq(('d2 - d2 - f2 - d2 - d2 - d2 - as1 - c2 - ' * 3), S * 2, square, 0.40, 0.45, 0.5),
seq(('x - - - x - - - x - - - x - x - ' * 6), S, tri, 0.10, 0.15),
seq('- - - - - - - - d4 - - - - - - - - - - - - - - - a3 - - - - - - - '
'- - - - - - - - f4 - - - - - - - - - - - - - - - e4 - - - - - - -', S * 1.5 / 2, square, 0.09, 0.3, 0.25),
])
write('tense', tense)
# --- jolly: the shop floor is a house party at 33rpm -------------------------
# 118-ish bpm four-on-floor: noise kick+hat, octave square bass, major arp.
K = 0.508 # beat (≈118bpm), 8 bars of 4 = 16.26s
jolly = render(K * 32, [
seq('x - - - x - - - x - - - x - - - ' * 2, K / 1, tri, 0.55, 0.06), # kick thump
seq('- - x - - - x - - - x - - - x - ' * 2, K / 1, square, 0.08, 0.05), # offbeat hat
seq(('c2 c3 g2 c3 a1 a2 e2 a2 f2 f3 c3 f3 g2 g3 b2 g3 ' * 2), K / 2, square, 0.34, 0.5, 0.5),
seq('e4 g4 c5 g4 a4 c5 e5 c5 f4 a4 c5 a4 g4 b4 d5 g5 '
'e4 g4 c5 g4 a4 c5 e5 c5 f4 a4 c5 f5 g4 d5 b4 g4', K / 2, square, 0.13, 0.55, 0.25),
])
write('jolly', jolly)
# --- spooky: the back room keeps its own temperature -------------------------
spooky = render(16.0, [
([(0.0, 15.6, note('e2'))], tri, 0.42),
([(0.0, 15.6, note('e2') * 0.5)], tri, 0.30),
seq('- - - - - g4 - - - - - - fs4 - - -', B, square, 0.06, 0.5, 0.12),
([(3.0, 1.2, -1.0), (11.0, 1.6, -1.0)], square, 0.03),
])
write('spooky', spooky)