#!/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("", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(-1 * (e.delta // 120), "units")) self.bind("", 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()