328 lines
15 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
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">— __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 — the board reproduces that). Small
|
|
<b>n##</b> buttons appear when one sample fires at several pitches in-game — 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>) —
|
|
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 — __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()
|