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:
co-authored by
Claude Opus 4.8
parent
e454e97617
commit
e017f257a5
@@ -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()
|
||||
Reference in New Issue
Block a user