Files
BT411/tools/mksoundboard.py
T

328 lines
15 KiB
Python

#!/usr/bin/env python3
"""mksoundboard.py -- build SOUNDBOARD_BT411.zip, the distributable tester
soundboard (no Python needed on the tester's machine).
Run: python tools/mksoundboard.py (from the repo root, or anywhere)
Output: dist/SOUNDBOARD_BT411.zip containing
index.html one self-contained page (inline CSS/JS, works from file://)
AUDIO/ every soundbank wav (copies; a few ultra-low-rate zones get
their header rate rebased x2^m so browsers can decode them --
the page's playbackRate compensates exactly)
README.txt tester instructions
The page plays every sample at its GAME rate:
game rate = WAV header rate x 2^((note - 60) / 12)
via <audio>.playbackRate with preservesPitch=false (the engine RESAMPLES --
pitch and speed shift together, so pitch-preserving time-stretch would be
wrong). No fetch()/XHR of the wavs -- plain <audio src> works from file://.
The trigger-note census is shared with tools/soundboard.py (imported).
"""
import io, json, os, struct, sys, zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from soundboard import (GAME_TRIGGERS, VOICE_PRESETS, SEQ_PRIMARY_PATCHES,
AUDIO, load_zones, wav_fmt, note_shift)
OUT = os.path.normpath(os.path.join(HERE, "..", "dist", "SOUNDBOARD_BT411.zip"))
MIN_BROWSER_RATE = 3000 # Chromium refuses to decode wav below ~3 kHz
def build_entries():
zones = load_zones()
files = sorted(f for f in os.listdir(AUDIO) if f.lower().endswith(".wav"))
by_patch = {}
for b, p, notes, trig in GAME_TRIGGERS:
by_patch.setdefault((b, p), []).extend((n, trig) for n in notes)
entries = []
for f in files:
fmt = wav_fmt(os.path.join(AUDIO, f))
hz = fmt[0] if fmt else 0
z = zones.get(f)
plays, seen = [], set()
if z:
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)
plays.append(dict(n=n, s=round(note_shift(n), 6), t=trig))
if z["keyLo"] <= 60 <= z["keyHi"] and 60 not in seen:
plays.append(dict(n=60, s=1.0, t="default start (note 60)"))
# primary: sequence-driven patches lead with their first authored
# sequence note; everything else leads with the note-60 default
if (z["bank"], z["patch"]) not in SEQ_PRIMARY_PATCHES:
plays.sort(key=lambda e: (e["n"] != 60, e["n"]))
if not plays:
plays = [dict(n=60, s=1.0, t="default start (note 60)")]
# zip header rate: rebase ultra-low rates so browsers can decode
zh, m = hz, 0
while 0 < zh < MIN_BROWSER_RATE:
zh, m = zh * 2, m + 1
e = dict(f=f, hz=hz, zh=zh,
b=z["bank"] if z else 0, p=z["patch"] if z else -1,
lo=z["keyLo"] if z else 0, hi=z["keyHi"] if z else 127,
v=1 if (z and (z["bank"], z["patch"]) in VOICE_PRESETS) else 0,
plays=plays)
entries.append(e)
return entries
README = """BT411 SOUNDBOARD -- what every game sound is called
=====================================================
1. Unzip this whole folder anywhere (keep index.html next to the AUDIO
folder).
2. Double-click index.html -- it opens in your browser. Nothing to
install, nothing goes online.
3. Type in the filter box to narrow the list, click a sound to hear it.
4. Found the sound from your bug? Hit COPY on its card and PASTE the
reference string straight into your Discord report. That string is
exactly what we need -- no descriptions like "the low buzzy one"
required (though those are fun too).
The board plays each sound the way the GAME plays it -- some sounds are
sped up or slowed down in-game from how they are stored (the "x0.25"
tags). The RAW toggle lets you hear the stored file instead, in case
you are chasing a pitch/speed bug specifically. Sounds with several
small note buttons play at several different pitches in-game (warning
voice phrases, explosion layers, the weapon ready clunk/blip) -- click
each note button to hear each pitch.
Cards tagged VOICE are the cockpit warning voice. Cards tagged UNUSED
are zones no game event ever triggers (we still include them for
completeness).
Thanks for testing!
"""
def esc(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def build_html(entries):
data = json.dumps(entries, separators=(",", ":"))
n_files = len(entries)
n_shift = sum(1 for e in entries for p in e["plays"] if p["s"] != 1.0)
return """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BT411 Soundboard</title>
<style>
:root { color-scheme: dark; }
body { background:#101418; color:#cfd8e3; font:14px/1.4 "Segoe UI",system-ui,sans-serif; margin:0; }
header { padding:14px 18px 10px; border-bottom:1px solid #2a3542; background:#151b22; position:sticky; top:0; z-index:5; }
h1 { font-size:18px; margin:0 0 4px; color:#e8eef5; }
.sub { color:#8fa3b8; font-size:12.5px; max-width:1000px; }
.sub b { color:#c8d6e5; }
.controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:10px; }
input[type=text] { background:#0c1013; color:#dfe8f2; border:1px solid #33414f; border-radius:4px; padding:5px 8px; width:260px; }
button { background:#22303e; color:#dfe8f2; border:1px solid #3a4c5e; border-radius:4px; padding:5px 10px; cursor:pointer; }
button:hover { background:#2c3d4e; }
label.tog { color:#a9bccd; user-select:none; cursor:pointer; }
#count { color:#6f8398; font-size:12px; }
#grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:6px; padding:12px 18px 60px; }
.card { background:#161d25; border:1px solid #26313d; border-radius:6px; padding:7px 9px; }
.card.playing { border-color:#4d8edb; box-shadow:0 0 0 1px #4d8edb inset; }
.ref { font-family:Consolas,monospace; font-size:13px; color:#e4ecf4; cursor:pointer; background:none; border:none; padding:0; text-align:left; width:100%; }
.ref:hover { color:#8fc1f7; background:none; }
.meta { margin-top:4px; display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
.tag { font-size:10.5px; padding:1px 6px; border-radius:3px; background:#243240; color:#9db4c9; }
.tag.shift { background:#3d2f19; color:#e8b25a; }
.tag.voice { background:#3a2440; color:#d79be8; }
.tag.unused { background:#3a2626; color:#d99; }
.noteb { font-size:11px; padding:1px 7px; background:#1d2a37; border:1px solid #33414f; border-radius:3px; cursor:pointer; color:#bcd; }
.noteb:hover { background:#2c3d4e; }
.copy { font-size:11px; padding:1px 8px; margin-left:auto; }
#status { position:fixed; bottom:0; left:0; right:0; background:#151b22; border-top:1px solid #2a3542;
padding:6px 18px; font-family:Consolas,monospace; font-size:12.5px; color:#9db4c9; }
</style>
</head>
<body>
<header>
<h1>BT411 SOUNDBOARD <span style="color:#6f8398;font-weight:normal">&mdash; __NFILES__ samples, played the way the game plays them</span></h1>
<div class="sub">
Click a sound to hear it <b>at its in-game speed</b> (an <b>x0.25</b> tag means the game
plays that file 4x slower than it is stored &mdash; the board reproduces that). Small
<b>n##</b> buttons appear when one sample fires at several pitches in-game &mdash; click each
to hear each. <b>When you report a sound in Discord: hit COPY on its card and paste the
reference string</b> (it looks like <span style="font-family:Consolas,monospace">[2:113] Warnings01_z0 n16</span>) &mdash;
that string tells us exactly which sound you mean. VOICE = the cockpit warning voice.
</div>
<div class="controls">
<input type="text" id="filter" placeholder="filter... (e.g. laser, warn, foot)">
<button id="stopb">STOP</button>
<button id="playall">Play All (filtered)</button>
<label class="tog"><input type="checkbox" id="raw"> RAW file rate (ignore game shift)</label>
<span id="count"></span>
</div>
</header>
<div id="grid"></div>
<div id="status">ready &mdash; __NSHIFT__ (sample, trigger) pairs play rate-shifted in-game</div>
<script>
"use strict";
const DATA = __DATA__;
const grid = document.getElementById("grid");
const statusEl = document.getElementById("status");
const rawEl = document.getElementById("raw");
const filterEl = document.getElementById("filter");
const audioCache = new Map();
let currentAudio = null, currentCard = null, playAllTimer = null;
function refString(e, note) {
let s = (e.p >= 0 ? "[" + e.b + ":" + e.p + "] " : "[-:-] ") + e.f.replace(/\\.wav$/i, "");
if (note !== undefined && note !== 60) s += " n" + note;
return s;
}
function getAudio(e) {
let a = audioCache.get(e.f);
if (!a) {
a = new Audio("AUDIO/" + e.f); // plain element src: file://-safe, no fetch
audioCache.set(e.f, a);
}
return a;
}
function applyNoPreserve(a) {
a.preservesPitch = false; // the engine RESAMPLES: pitch+speed together
a.mozPreservesPitch = false;
a.webkitPreservesPitch = false;
}
function stopAll() {
if (playAllTimer) { clearTimeout(playAllTimer); playAllTimer = null; }
if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; }
if (currentCard) currentCard.classList.remove("playing");
currentAudio = null; currentCard = null;
}
function playEntry(e, play, card) {
stopAll();
const a = getAudio(e);
applyNoPreserve(a);
// zh = the header rate of the wav in this zip (rebased x2^m when the true
// rate is too low for browsers to decode); target = what the game outputs
const target = rawEl.checked ? e.hz : Math.round(e.hz * play.s);
let rate = target / e.zh;
const clamped = rate < 0.0625 || rate > 16;
rate = Math.min(16, Math.max(0.0625, rate));
a.playbackRate = rate;
a.currentTime = 0;
a.play().catch(err => { statusEl.textContent = "PLAY FAILED " + e.f + " -- " + err; });
currentAudio = a; currentCard = card;
if (card) card.classList.add("playing");
statusEl.textContent = "PLAYING " + refString(e, play.n) + " | " +
(rawEl.checked ? "raw " + e.hz + " Hz" : "game " + target + " Hz (x" + play.s + ")") +
" | " + play.t + (clamped ? " [browser rate clamp -- approximate]" : "");
}
function copyText(txt, btn) {
const done = () => { const old = btn.textContent; btn.textContent = "copied"; setTimeout(() => btn.textContent = old, 900); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(done, () => fallbackCopy(txt, done));
} else fallbackCopy(txt, done);
}
function fallbackCopy(txt, done) {
const ta = document.createElement("textarea");
ta.value = txt; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta); done();
}
function shiftTag(s) {
return s >= 0.1 ? "x" + s.toFixed(2).replace(/0$/, "") : "x" + s.toFixed(3);
}
function build() {
const f = filterEl.value.toLowerCase();
grid.textContent = "";
let shown = 0;
for (const e of DATA) {
if (f && e.f.toLowerCase().indexOf(f) < 0) continue;
shown++;
const card = document.createElement("div"); card.className = "card";
const primary = e.plays[0];
const ref = document.createElement("button"); ref.className = "ref";
ref.textContent = refString(e, primary.n) + (primary.s !== 1 ? " " + shiftTag(primary.s) : "");
ref.title = primary.t + " | stored " + e.hz + " Hz, game " + Math.round(e.hz * primary.s) + " Hz";
ref.addEventListener("click", () => playEntry(e, primary, card));
card.appendChild(ref);
const meta = document.createElement("div"); meta.className = "meta";
if (e.v) { const t = document.createElement("span"); t.className = "tag voice"; t.textContent = "VOICE"; meta.appendChild(t); }
const seqOnly = !(e.lo <= 60 && 60 <= e.hi);
if (seqOnly && e.plays.every(p => p.n === 60)) {
const t = document.createElement("span"); t.className = "tag unused"; t.textContent = "UNUSED IN GAME"; meta.appendChild(t);
}
if (e.plays.length > 1) {
for (const p of e.plays) {
const nb = document.createElement("button"); nb.className = "noteb";
nb.textContent = "n" + p.n + " " + shiftTag(p.s);
nb.title = p.t;
nb.addEventListener("click", () => playEntry(e, p, card));
meta.appendChild(nb);
}
} else if (primary.s !== 1) {
const t = document.createElement("span"); t.className = "tag shift";
t.textContent = "game " + shiftTag(primary.s); t.title = primary.t; meta.appendChild(t);
}
const cp = document.createElement("button"); cp.className = "copy"; cp.textContent = "COPY";
cp.addEventListener("click", () => copyText(refString(e, primary.n), cp));
meta.appendChild(cp);
card.appendChild(meta);
grid.appendChild(card);
}
document.getElementById("count").textContent = shown + " shown / " + DATA.length + " total";
}
function playAll() {
stopAll();
const f = filterEl.value.toLowerCase();
const list = DATA.filter(e => !f || e.f.toLowerCase().indexOf(f) >= 0);
let i = 0;
const step = () => {
if (i >= list.length) { statusEl.textContent = "play-all done"; playAllTimer = null; return; }
const e = list[i++];
playEntry(e, e.plays[0], null);
playAllTimer = setTimeout(step, 1100);
};
step();
}
filterEl.addEventListener("input", build);
document.getElementById("stopb").addEventListener("click", () => { stopAll(); statusEl.textContent = "stopped"; });
document.getElementById("playall").addEventListener("click", playAll);
document.addEventListener("keydown", ev => { if (ev.key === "Escape") { stopAll(); statusEl.textContent = "stopped"; } });
build();
</script>
</body>
</html>
""".replace("__DATA__", data).replace("__NFILES__", str(n_files)).replace("__NSHIFT__", str(n_shift))
def rebased_wav_bytes(path, new_rate):
"""the wav with only fmt.dwSamplesPerSec/dwAvgBytesPerSec rewritten."""
fmt = wav_fmt(path)
data = bytearray(open(path, "rb").read())
_, off, block_align = fmt
struct.pack_into("<I", data, off + 4, new_rate)
struct.pack_into("<I", data, off + 8, new_rate * block_align)
return bytes(data)
def main():
entries = build_entries()
html = build_html(entries)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
rebased = []
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
zf.writestr("SOUNDBOARD_BT411/index.html", html)
zf.writestr("SOUNDBOARD_BT411/README.txt", README)
for e in entries:
src = os.path.join(AUDIO, e["f"])
arc = "SOUNDBOARD_BT411/AUDIO/" + e["f"]
if e["zh"] != e["hz"]:
zf.writestr(arc, rebased_wav_bytes(src, e["zh"]))
rebased.append((e["f"], e["hz"], e["zh"]))
else:
zf.write(src, arc)
size = os.path.getsize(OUT)
n_shift = sum(1 for e in entries for p in e["plays"] if p["s"] != 1.0)
print("wrote %s (%.1f MB, %d wavs, %d shifted (sample,trigger) pairs)" % (
OUT, size / 1e6, len(entries), n_shift))
if rebased:
print("header-rebased for browser decode (playbackRate compensates):")
for f, hz, zh in rebased:
print(" %-28s %6d -> %d Hz" % (f, hz, zh))
if __name__ == "__main__":
main()