294 lines
14 KiB
Python
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()
|