From e017f257a59d5955d685f252ab311f30f9edfd46 Mon Sep 17 00:00:00 2001 From: arcattack Date: Thu, 16 Jul 2026 08:01:27 -0500 Subject: [PATCH] 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) --- tools/soundboard.py | 120 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tools/soundboard.py diff --git a/tools/soundboard.py b/tools/soundboard.py new file mode 100644 index 0000000..03d8c7d --- /dev/null +++ b/tools/soundboard.py @@ -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("", + 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 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()