Tools: soundboard.py -- click-to-play UI for the 241-sample soundbank (task #50)

Stdlib-only (tkinter + winsound) sample browser: filter box, per-button
[bank:patch] tags parsed from audiopresets.cpp so an ear-identified sound maps
straight back to the game's (bankID, patchID), Play-All sweep, STOP/ESC.
For verifying sample identity/pitch (the 22050 Hz extraction guess) by ear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-16 08:01:27 -05:00
co-authored by Claude Opus 4.8
parent e454e97617
commit e017f257a5
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""soundboard.py -- click-to-play board for the BT soundbank (content/AUDIO/*.wav).
Run: python tools/soundboard.py (from the repo root, or anywhere)
- Click a button to play that sample (stdlib winsound, async -- click again to
restart, click STOP to silence).
- The label shows [bank:patch] from game/reconstructed/audiopresets.cpp so an
identified sound maps straight back to the game's (bankID, patchID).
- Type in the filter box to narrow (substring, case-insensitive).
- "Play All" steps through the filtered list one per second (ESC/STOP to halt).
"""
import os, re, sys, 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"))
def load_mapping():
"""file -> (bank, patch) from audiopresets.cpp (allPresets[b][p] ... file="X.wav")."""
mapping = {}
try:
text = open(PRESETS_CPP, encoding="utf-8", errors="replace").read()
# blocks look like: allPresets[0][12].samples[0]; ... s.file = "LaserAFire01.wav";
for m in re.finditer(r'allPresets\[(\d+)\]\[(\d+)\]\.samples\[0\];.*?s\.file = "([^"]+)";',
text, re.S):
bank, patch, fname = int(m.group(1)) + 1, int(m.group(2)), m.group(3)
mapping[fname] = (bank, patch)
except OSError:
pass
return mapping
class SoundBoard(tk.Tk):
def __init__(self):
super().__init__()
self.title("BT411 Soundboard -- " + AUDIO)
self.geometry("1150x760")
self.mapping = load_mapping()
self.files = sorted(f for f in os.listdir(AUDIO) if f.lower().endswith(".wav"))
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.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 label_for(self, f):
bp = self.mapping.get(f)
tag = f"[{bp[0]}:{bp[1]}] " if bp else "[-:-] "
return tag + f[:-4]
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 = 4
for i, f in enumerate(shown):
b = ttk.Button(self.grid_frame, text=self.label_for(f), width=38,
command=lambda ff=f: self.play(ff))
b.grid(row=i // cols, column=i % cols, padx=2, pady=1, sticky="w")
self.status.set(f"{len(shown)} shown / {len(self.files)} total")
self.canvas.yview_moveto(0)
def play(self, f):
self.playing_all = False
path = os.path.join(AUDIO, f)
winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
bp = self.mapping.get(f)
self.status.set(f"PLAYING {f}" + (f" (bank {bp[0]}, patch {bp[1]})" if bp else ""))
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
self.after(0, lambda ff=f: self.status.set("PLAYING " + ff))
winsound.PlaySound(os.path.join(AUDIO, f),
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()