Files
BT411/tools/soundboard.py
T

294 lines
14 KiB
Python

#!/usr/bin/env python3
"""soundboard.py -- click-to-play board for the BT soundbank (content/AUDIO/*.wav),
playing every sample at its GAME-ACCURATE rate.
Run: python tools/soundboard.py (from the repo root, or anywhere)
THE RATE MODEL (see docs/AUDIO_FIDELITY.md F2 + L4AUDIO.cpp:21-24):
game playback rate = WAV header rate x 2^((note - 60) / 12)
The port bakes each zone's authored SoundFont tuning into the extracted WAV's
declared rate (sf2extract.py), then applies AL_PITCH = 2^((note-60)/12) for
the triggering MIDI note. Sources default to note 60 (AUDSRC.cpp DEFAULT_NOTE)
so most samples play at their file rate -- but AudioControlSequences streamed
from BTL4.RES trigger notes far off 60 (key-splits: the note SELECTS the zone
AND transposes it). The authored (patch, note) census below was parsed out of
BTL4.RES sequence records and cross-checked against live SetupPatch logs [T1].
- Click a button: plays the sample at its PRIMARY game rate (header-rewritten
temp copy; winsound resamples like the engine does -- pitch and speed shift
together).
- Small per-note buttons appear when one sample fires at several pitches
(explosion splits, the Warnings01 voice zones, weapon ready clunk/blip).
- "Raw file rate" toggle: play the untouched WAV for comparison.
- The label shows [bank:patch], the shift (e.g. x0.25), and V for the recorded
VOICE zones (Warnings01/AllWarning -- the project's Yip recordings).
- Filter box narrows (substring, case-insensitive); "Play All" steps through
the filtered list; ESC/STOP halts.
"""
import os, re, struct, sys, tempfile, threading, time
import tkinter as tk
from tkinter import ttk
import winsound
HERE = os.path.dirname(os.path.abspath(__file__))
AUDIO = os.path.normpath(os.path.join(HERE, "..", "content", "AUDIO"))
PRESETS_CPP = os.path.normpath(os.path.join(HERE, "..", "game", "reconstructed", "audiopresets.cpp"))
TMPDIR = os.path.join(tempfile.gettempdir(), "bt411_soundboard")
# ---------------------------------------------------------------------------
# The authored trigger-note census [T1]: every AudioControlSequence in
# BTL4.RES, parsed from the stream records (scratchpad seqparse.py,
# 2026-08-13) and resolved through its mixer chain to the target patch.
# Everything NOT listed here is only ever started at DEFAULT_NOTE 60
# (AUDSRC.cpp:18), i.e. at its file rate. (bank, patch) are 1-based bank
# as logged by SetupPatch. Dynamic pitch-cent modulations (doppler, the
# torso-twist pitch scale) are runtime effects and are NOT part of the
# base rate.
# ---------------------------------------------------------------------------
GAME_TRIGGERS = [
# (bank, patch, [notes in authored order], "trigger")
(2, 113, [16, 19, 23], "coolant-leak voice loop (ReportLeak)"),
(2, 113, [16, 33, 36], "ammo cook-off countdown voice (FireCountdownStarted)"),
(2, 113, [29, 16, 26], "generator-out voice phrase (GeneratorState)"),
(2, 113, [29, 16, 40], "SimulationState warning voice phrase"),
(2, 115, [30, 18], "death phrase A (random pick at death)"),
(2, 115, [49, 43], "death phrase B (random pick at death)"),
(2, 115, [95], "death phrase C (random pick at death)"),
(2, 83, [30, 48, 46, 96, 44, 63, 66], "mech-explosion sequence (brnext.scp)"),
(2, 97, [45, 38], "mech fire-loop sequence (brnext.scp)"),
(2, 64, [36, 70], "subsystem-destroyed interior (SimulationState)"),
(2, 119, [57, 69, 46, 24], "big explosion sequence (bigexp.scp)"),
(2, 120, [57, 69, 46, 24], "big explosion sequence LOD2 (bigexp.scp)"),
(2, 119, [69, 51], "medium explosion sequence (medexp.scp)"),
(2, 120, [69, 51], "medium explosion sequence LOD2 (medexp.scp)"),
(1, 74, [58], "incoming-missile lock beeper (IncomingLock)"),
(1, 83, [51], "weapon-configure ticker (ConfigureActivePress)"),
(1, 36, [36, 84], "missile misfire (WeaponState)"),
(1, 40, [36, 84], "missile loaded/ready (WeaponState)"),
(1, 41, [36, 84], "autocannon misfire (WeaponState)"),
(1, 56, [36, 84], "laser loaded/ready (WeaponState)"),
(1, 70, [36, 84], "autocannon jam (WeaponState)"),
(1, 71, [36, 84], "missile jam (WeaponState)"),
(1, 75, [36, 84], "projectile loaded/ready (WeaponState)"),
]
VOICE_PRESETS = {(2, 113), (2, 123)} # Warnings01, AllWarning -- Yip's recorded voice
# Patches the game drives ONLY through their sequences (live SetupPatch census:
# no note-60 non-ZONESKIP starts) -- their PRIMARY board rate is the first
# authored sequence note, not the note-60 default. ProgramButton01 (1,83) is
# deliberately absent: button presses start it at 60 (live-trace evidence);
# the configure-ticker 51 stays a secondary note button.
SEQ_PRIMARY_PATCHES = {
(1, 36), (1, 40), (1, 41), (1, 56), (1, 70), (1, 71), (1, 75), # loaded/jam/misfire
(1, 74), # lock beeper (F7: sequence is the only path)
(2, 64), (2, 83), (2, 97), (2, 113), (2, 115), (2, 119), (2, 120),
}
def note_shift(note):
return 2.0 ** ((note - 60) / 12.0)
def load_zones():
"""file -> dict(bank, patch, keyLo, keyHi) from audiopresets.cpp Z() lines."""
zones = {}
try:
text = open(PRESETS_CPP, encoding="utf-8", errors="replace").read()
for m in re.finditer(r'Z\((\d+),(\d+),"([^"]+)",(-?\d+),(-?\d+),', text):
zones[m.group(3)] = dict(bank=int(m.group(1)) + 1, patch=int(m.group(2)),
keyLo=int(m.group(4)), keyHi=int(m.group(5)))
except OSError:
pass
return zones
def wav_fmt(path):
"""(rate, fmt_chunk_file_offset, block_align) from the RIFF header."""
with open(path, "rb") as f:
head = f.read(12)
if head[:4] != b"RIFF" or head[8:12] != b"WAVE":
return None
while True:
ch = f.read(8)
if len(ch) < 8:
return None
cid, sz = ch[:4], struct.unpack("<I", ch[4:])[0]
if cid == b"fmt ":
off = f.tell()
data = f.read(sz)
rate, = struct.unpack_from("<I", data, 4)
block_align, = struct.unpack_from("<H", data, 12)
return rate, off, block_align
f.seek(sz + (sz & 1), 1)
def make_rate_copy(src, dst, new_rate):
"""byte-exact copy of src with only the fmt chunk's dwSamplesPerSec and
dwAvgBytesPerSec rewritten to new_rate."""
fmt = wav_fmt(src)
if fmt is None:
return False
_, off, block_align = fmt
data = bytearray(open(src, "rb").read())
struct.pack_into("<I", data, off + 4, new_rate) # dwSamplesPerSec
struct.pack_into("<I", data, off + 8, new_rate * block_align) # dwAvgBytesPerSec
open(dst, "wb").write(data)
return True
class SoundBoard(tk.Tk):
def __init__(self):
super().__init__()
self.title("BT411 Soundboard (game rates) -- " + AUDIO)
self.geometry("1400x800")
os.makedirs(TMPDIR, exist_ok=True)
self.zones = load_zones()
self.files = sorted(f for f in os.listdir(AUDIO) if f.lower().endswith(".wav"))
self.rates = {}
for f in self.files:
fmt = wav_fmt(os.path.join(AUDIO, f))
self.rates[f] = fmt[0] if fmt else 0
self.plays = self.build_play_table()
self.playing_all = False
top = ttk.Frame(self); top.pack(fill="x", padx=6, pady=4)
ttk.Label(top, text="Filter:").pack(side="left")
self.filter_var = tk.StringVar()
ent = ttk.Entry(top, textvariable=self.filter_var, width=32)
ent.pack(side="left", padx=4)
self.filter_var.trace_add("write", lambda *a: self.rebuild())
ttk.Button(top, text="STOP", command=self.stop).pack(side="left", padx=8)
ttk.Button(top, text="Play All (filtered)", command=self.play_all).pack(side="left")
self.raw_var = tk.BooleanVar(value=False)
ttk.Checkbutton(top, text="Raw file rate (ignore game shift)",
variable=self.raw_var).pack(side="left", padx=10)
self.status = tk.StringVar(value=f"{len(self.files)} samples loaded")
ttk.Label(top, textvariable=self.status, foreground="#06c").pack(side="left", padx=12)
# scrollable button grid
wrap = ttk.Frame(self); wrap.pack(fill="both", expand=True)
self.canvas = tk.Canvas(wrap, highlightthickness=0)
sb = ttk.Scrollbar(wrap, orient="vertical", command=self.canvas.yview)
self.canvas.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y"); self.canvas.pack(side="left", fill="both", expand=True)
self.grid_frame = ttk.Frame(self.canvas)
self.canvas.create_window((0, 0), window=self.grid_frame, anchor="nw")
self.grid_frame.bind("<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
self.canvas.bind_all("<MouseWheel>",
lambda e: self.canvas.yview_scroll(-1 * (e.delta // 120), "units"))
self.bind("<Escape>", lambda e: self.stop())
self.rebuild()
def build_play_table(self):
"""file -> list of (note, shift, trigger); primary first.
A zone whose key range contains 60 plays at note 60 (= file rate)
from every plain watcher Start; sequence notes are added on top.
A zone whose range EXCLUDES 60 is reachable ONLY via its sequence
notes -- its file-rate rendering never occurs in game."""
by_patch = {}
for b, p, notes, trig in GAME_TRIGGERS:
by_patch.setdefault((b, p), []).extend((n, trig) for n in notes)
plays = {}
for f in self.files:
z = self.zones.get(f)
entries = []
if z:
seen = set()
for n, trig in by_patch.get((z["bank"], z["patch"]), []):
if z["keyLo"] <= n <= z["keyHi"] and n not in seen:
seen.add(n)
entries.append((n, note_shift(n), trig))
if z["keyLo"] <= 60 <= z["keyHi"] and 60 not in seen:
# default-60 start reaches this zone at file rate
entries.append((60, 1.0, "default start (note 60)"))
if not entries:
entries = [(60, 1.0, "default start (note 60)")]
# primary: sequence-driven patches lead with their first authored
# sequence note; everything else leads with the note-60 default
if not (z and (z["bank"], z["patch"]) in SEQ_PRIMARY_PATCHES):
entries.sort(key=lambda e: (e[0] != 60, e[0]))
plays[f] = entries
return plays
def label_for(self, f):
z = self.zones.get(f)
tag = f"[{z['bank']}:{z['patch']}] " if z else "[-:-] "
if z and (z["bank"], z["patch"]) in VOICE_PRESETS:
tag += "V "
primary = self.plays[f][0]
shift_txt = "" if primary[1] == 1.0 else f" x{primary[1]:.2f}" if primary[1] >= 0.1 else f" x{primary[1]:.3f}"
return tag + f[:-4] + shift_txt
def rebuild(self):
for w in self.grid_frame.winfo_children():
w.destroy()
flt = self.filter_var.get().lower()
shown = [f for f in self.files if flt in f.lower()]
cols = 3
for i, f in enumerate(shown):
cell = ttk.Frame(self.grid_frame)
cell.grid(row=i // cols, column=i % cols, padx=2, pady=1, sticky="w")
b = ttk.Button(cell, text=self.label_for(f), width=44,
command=lambda ff=f: self.play(ff))
b.pack(side="left")
notes = self.plays[f]
if len(notes) > 1:
for n, sh, trig in notes:
txt = f"n{n}"
nb = ttk.Button(cell, text=txt, width=4,
command=lambda ff=f, nn=n: self.play(ff, nn))
nb.pack(side="left")
self.status.set(f"{len(shown)} shown / {len(self.files)} total")
self.canvas.yview_moveto(0)
def path_for(self, f, note):
"""the file to hand winsound: raw, or a header-rewritten game-rate copy."""
if self.raw_var.get() or note == 60:
return os.path.join(AUDIO, f)
game_rate = int(round(self.rates[f] * note_shift(note)))
dst = os.path.join(TMPDIR, f"{f[:-4]}@n{note}.wav")
if not os.path.exists(dst):
if not make_rate_copy(os.path.join(AUDIO, f), dst, game_rate):
return os.path.join(AUDIO, f)
return dst
def play(self, f, note=None):
self.playing_all = False
entries = self.plays[f]
if note is None:
note, shift, trig = entries[0]
else:
shift, trig = next((s, t) for n, s, t in entries if n == note)
path = self.path_for(f, note)
winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
z = self.zones.get(f)
bp = f"bank {z['bank']}, patch {z['patch']}" if z else "unmapped"
mode = "RAW" if self.raw_var.get() else f"note {note} x{shift:.4g}"
game_rate = int(round(self.rates[f] * (1.0 if self.raw_var.get() else note_shift(note))))
self.status.set(f"PLAYING {f} ({bp}; {mode}; {game_rate} Hz; {trig})")
def stop(self):
self.playing_all = False
winsound.PlaySound(None, winsound.SND_PURGE)
self.status.set("stopped")
def play_all(self):
flt = self.filter_var.get().lower()
shown = [f for f in self.files if flt in f.lower()]
self.playing_all = True
def run():
for f in shown:
if not self.playing_all:
return
note = self.plays[f][0][0]
path = self.path_for(f, note)
self.after(0, lambda ff=f: self.status.set("PLAYING " + ff))
winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
time.sleep(1.0)
self.playing_all = False
threading.Thread(target=run, daemon=True).start()
if __name__ == "__main__":
if not os.path.isdir(AUDIO):
print("AUDIO dir not found:", AUDIO); sys.exit(1)
SoundBoard().mainloop()