soundboard game-rate fidelity + distributable: rate formula fileRate x 2^((note-60)/12) [T1 L4AUDIO BTNotePitchFactor via AL_PITCH; notes come ONLY from AudioControlSequence]; BTL4.RES audio-stream record format cracked -- all 324 sequences parsed, 92 (sample,note) pairs play OFF file rate, headline: Warnings01/Yip voice zones trigger at notes 16-40 = x0.079-x0.315 (raw files play the voice 3.2-12.7x TOO FAST -- the long-remembered mismatch, now quantified); dead zones found (Death01 z8-11, AuxExplosion01 z6-7 unreachable by any authored note). tools/soundboard.py plays game rates (header-rewrite temp copies, per-note buttons, raw toggle); NEW tools/mksoundboard.py -> dist/SOUNDBOARD_BT411.zip (31.8MB): self-contained index.html, 603 cards, playbackRate+preservesPitch=false, per-note buttons, COPY reference strings for tester bug reports, no fetch (file:// safe), tester README. AUDIO_FIDELITY.md gains the authored trigger-note census

This commit is contained in:
Joe DiPrima
2026-08-13 16:39:18 -05:00
parent e4546923f8
commit 8e6697312f
3 changed files with 586 additions and 32 deletions
+54
View File
@@ -617,6 +617,60 @@ reduce source-pool starvation.
---
<a name="seqcensus"></a>
### The complete authored trigger-note census (2026-08-13, [T1]) — and the game-rate soundboard
**The playback-rate model, end to end:** effective output rate = WAV declared rate x
2^((note-60)/12) (`L4AUDIO.cpp:21-24` BTNotePitchFactor -> `alSourcef(AL_PITCH)` at :971
per-SetupPatch; per-zone SoundFont tuning is baked into each extracted WAV's declared rate per
F2, so note 60 == authored pitch). Sources default to note 60 (`AUDSRC.cpp:18 DEFAULT_NOTE`);
the ONLY senders of NoteAudioControlID are AudioControlSequences (grep-verified engine-wide),
so **every off-file-rate playback in the game comes from a sequence note**. Dynamic pitch-cents
(doppler, pitch-scale watchers) modulate on top and are not part of the base rate.
**BTL4.RES AudioControlSequence record format** (matches the ctor read order,
`AUDSEQ.cpp:165-194`): `[classID=75 i4][objectID i4][dump i4][looped i4][targetObjID i4]
[divisionsPerBeat i4][tempo i4][eventCount i4][count x (tick i4, ctlID i4, value f4)]`;
ctl 8 = note, 1 = start, 2 = stop, 6 = attack-volume. Every object record in the audio
streams is `[classID i4][objectID i4][payload]` with ClassID = the `VDATA.h` enum
(AudioStateTrigger=28, sequence=75, splitter=76, DirectPatchSource=1001, PatchResource=1004,
PatchLevelOfDetail=1011 — LOD payload `[voiceCount i4][renderType i4][suspendSecs f4]
[bank u1][patch u1][maxFilter i4]`). Patch resources/LODs live in the shared
`StaticAudioStream` (id 0); vehicle `*int.scp` streams reference them cross-stream.
Sequence -> target -> source -> resource -> LOD resolution yields the patch for every one of
the **324 sequences (15 unique patterns)** in the RES; cross-validated against live SetupPatch
logs (mp4l/solo/genedge).
| notes (authored order) | looped | patch | trigger (watcher census) |
|---|---|---|---|
| 16,19,23 | yes | 2:113 Warnings01 | coolant-leak voice loop (ReportLeak, 19 subsystems) |
| 16,33,36 | no | 2:113 | ammo cook-off countdown voice (FireCountdownStarted) |
| 29,16,26 | no | 2:113 | generator-out voice phrase (GeneratorState/GeneratorOn) |
| 29,16,40 | no | 2:113 | Entity.SimulationState warning voice phrase |
| 30,18 / 49,43 / 95 | no | 2:115 Death01 | death phrases — a **random 3-pick list** `[3][seqA][seqB][seqC]` at death |
| 36,84 | no | 1:36/40/41/56/70/71/75 | weapon loaded/jam/misfire clunk-then-blip (WeaponState) |
| 36,70 | no | 2:64 DestroyedInt06 | subsystem destroyed (SimulationState) |
| 58 x6 | yes | 1:74 IncomingAlarm01 | missile-lock beeper (IncomingLock; DistanceToMissile -> tempo) |
| 51,51 | yes | 1:83 ProgramButton01 | weapon-configure ticker (ConfigureActivePress) |
| 30,48,46,96,44,63,66 | no | 2:83 MechExplosion01 | brnext.scp |
| 45,38 | no | 2:97 MechFireLoop01 | brnext.scp |
| 57,69,46,24 / 69,51 | no | 2:119+120 AuxExplosion01(+LOD2) | bigexp.scp / medexp+mbnexp.scp |
Net: **92 (sample-zone, note) pairs play rate-shifted vs their WAV header** (worst: the
Warnings01 voice zones at x0.079-x0.315 — the raw files render Yip's voice 3.2x-12.7x too
fast, which is why the old soundboard's voice sounded wrong; weapon ready blips x4; death
phrase C zones x7.55). Bonus census finding: **Death01 z8-11, AuxExplosion01 z6-7 and
AuxExplosion01LOD2 z3 are reachable by NO authored note** — dead zones.
**Tools:** `tools/soundboard.py` now plays every sample at its game rate (header-rewritten
temp copies, per-note buttons, raw-rate toggle); `tools/mksoundboard.py` builds
`dist/SOUNDBOARD_BT411.zip` — a self-contained tester soundboard page (file://-safe `<audio>`
elements, `preservesPitch=false`, playbackRate = the game shift) whose COPY buttons emit the
canonical `[bank:patch] Name n##` reference string for bug reports. Both share the census
table (`GAME_TRIGGERS` in soundboard.py).
---
<a name="validations"></a>
## 4. Validations and KB corrections (no fidelity gap)
+327
View File
@@ -0,0 +1,327 @@
#!/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()
+205 -32
View File
@@ -1,16 +1,31 @@
#!/usr/bin/env python3
"""soundboard.py -- click-to-play board for the BT soundbank (content/AUDIO/*.wav).
"""soundboard.py -- click-to-play board for the BT soundbank (content/AUDIO/*.wav),
playing every sample at its GAME-ACCURATE rate.
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).
THE RATE MODEL (see docs/AUDIO_FIDELITY.md F2 + L4AUDIO.cpp:21-24):
game playback rate = WAV header rate x 2^((note - 60) / 12)
The port bakes each zone's authored SoundFont tuning into the extracted WAV's
declared rate (sf2extract.py), then applies AL_PITCH = 2^((note-60)/12) for
the triggering MIDI note. Sources default to note 60 (AUDSRC.cpp DEFAULT_NOTE)
so most samples play at their file rate -- but AudioControlSequences streamed
from BTL4.RES trigger notes far off 60 (key-splits: the note SELECTS the zone
AND transposes it). The authored (patch, note) census below was parsed out of
BTL4.RES sequence records and cross-checked against live SetupPatch logs [T1].
- Click a button: plays the sample at its PRIMARY game rate (header-rewritten
temp copy; winsound resamples like the engine does -- pitch and speed shift
together).
- Small per-note buttons appear when one sample fires at several pitches
(explosion splits, the Warnings01 voice zones, weapon ready clunk/blip).
- "Raw file rate" toggle: play the untouched WAV for comparison.
- The label shows [bank:patch], the shift (e.g. x0.25), and V for the recorded
VOICE zones (Warnings01/AllWarning -- the project's Yip recordings).
- Filter box narrows (substring, case-insensitive); "Play All" steps through
the filtered list; ESC/STOP halts.
"""
import os, re, sys, threading, time
import os, re, struct, sys, tempfile, threading, time
import tkinter as tk
from tkinter import ttk
import winsound
@@ -18,28 +33,118 @@ 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"))
TMPDIR = os.path.join(tempfile.gettempdir(), "bt411_soundboard")
def load_mapping():
"""file -> (bank, patch) from audiopresets.cpp (allPresets[b][p] ... file="X.wav")."""
mapping = {}
# ---------------------------------------------------------------------------
# The authored trigger-note census [T1]: every AudioControlSequence in
# BTL4.RES, parsed from the stream records (scratchpad seqparse.py,
# 2026-08-13) and resolved through its mixer chain to the target patch.
# Everything NOT listed here is only ever started at DEFAULT_NOTE 60
# (AUDSRC.cpp:18), i.e. at its file rate. (bank, patch) are 1-based bank
# as logged by SetupPatch. Dynamic pitch-cent modulations (doppler, the
# torso-twist pitch scale) are runtime effects and are NOT part of the
# base rate.
# ---------------------------------------------------------------------------
GAME_TRIGGERS = [
# (bank, patch, [notes in authored order], "trigger")
(2, 113, [16, 19, 23], "coolant-leak voice loop (ReportLeak)"),
(2, 113, [16, 33, 36], "ammo cook-off countdown voice (FireCountdownStarted)"),
(2, 113, [29, 16, 26], "generator-out voice phrase (GeneratorState)"),
(2, 113, [29, 16, 40], "SimulationState warning voice phrase"),
(2, 115, [30, 18], "death phrase A (random pick at death)"),
(2, 115, [49, 43], "death phrase B (random pick at death)"),
(2, 115, [95], "death phrase C (random pick at death)"),
(2, 83, [30, 48, 46, 96, 44, 63, 66], "mech-explosion sequence (brnext.scp)"),
(2, 97, [45, 38], "mech fire-loop sequence (brnext.scp)"),
(2, 64, [36, 70], "subsystem-destroyed interior (SimulationState)"),
(2, 119, [57, 69, 46, 24], "big explosion sequence (bigexp.scp)"),
(2, 120, [57, 69, 46, 24], "big explosion sequence LOD2 (bigexp.scp)"),
(2, 119, [69, 51], "medium explosion sequence (medexp.scp)"),
(2, 120, [69, 51], "medium explosion sequence LOD2 (medexp.scp)"),
(1, 74, [58], "incoming-missile lock beeper (IncomingLock)"),
(1, 83, [51], "weapon-configure ticker (ConfigureActivePress)"),
(1, 36, [36, 84], "missile misfire (WeaponState)"),
(1, 40, [36, 84], "missile loaded/ready (WeaponState)"),
(1, 41, [36, 84], "autocannon misfire (WeaponState)"),
(1, 56, [36, 84], "laser loaded/ready (WeaponState)"),
(1, 70, [36, 84], "autocannon jam (WeaponState)"),
(1, 71, [36, 84], "missile jam (WeaponState)"),
(1, 75, [36, 84], "projectile loaded/ready (WeaponState)"),
]
VOICE_PRESETS = {(2, 113), (2, 123)} # Warnings01, AllWarning -- Yip's recorded voice
# Patches the game drives ONLY through their sequences (live SetupPatch census:
# no note-60 non-ZONESKIP starts) -- their PRIMARY board rate is the first
# authored sequence note, not the note-60 default. ProgramButton01 (1,83) is
# deliberately absent: button presses start it at 60 (live-trace evidence);
# the configure-ticker 51 stays a secondary note button.
SEQ_PRIMARY_PATCHES = {
(1, 36), (1, 40), (1, 41), (1, 56), (1, 70), (1, 71), (1, 75), # loaded/jam/misfire
(1, 74), # lock beeper (F7: sequence is the only path)
(2, 64), (2, 83), (2, 97), (2, 113), (2, 115), (2, 119), (2, 120),
}
def note_shift(note):
return 2.0 ** ((note - 60) / 12.0)
def load_zones():
"""file -> dict(bank, patch, keyLo, keyHi) from audiopresets.cpp Z() lines."""
zones = {}
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)
for m in re.finditer(r'Z\((\d+),(\d+),"([^"]+)",(-?\d+),(-?\d+),', text):
zones[m.group(3)] = dict(bank=int(m.group(1)) + 1, patch=int(m.group(2)),
keyLo=int(m.group(4)), keyHi=int(m.group(5)))
except OSError:
pass
return mapping
return zones
def wav_fmt(path):
"""(rate, fmt_chunk_file_offset, block_align) from the RIFF header."""
with open(path, "rb") as f:
head = f.read(12)
if head[:4] != b"RIFF" or head[8:12] != b"WAVE":
return None
while True:
ch = f.read(8)
if len(ch) < 8:
return None
cid, sz = ch[:4], struct.unpack("<I", ch[4:])[0]
if cid == b"fmt ":
off = f.tell()
data = f.read(sz)
rate, = struct.unpack_from("<I", data, 4)
block_align, = struct.unpack_from("<H", data, 12)
return rate, off, block_align
f.seek(sz + (sz & 1), 1)
def make_rate_copy(src, dst, new_rate):
"""byte-exact copy of src with only the fmt chunk's dwSamplesPerSec and
dwAvgBytesPerSec rewritten to new_rate."""
fmt = wav_fmt(src)
if fmt is None:
return False
_, off, block_align = fmt
data = bytearray(open(src, "rb").read())
struct.pack_into("<I", data, off + 4, new_rate) # dwSamplesPerSec
struct.pack_into("<I", data, off + 8, new_rate * block_align) # dwAvgBytesPerSec
open(dst, "wb").write(data)
return True
class SoundBoard(tk.Tk):
def __init__(self):
super().__init__()
self.title("BT411 Soundboard -- " + AUDIO)
self.geometry("1150x760")
self.mapping = load_mapping()
self.title("BT411 Soundboard (game rates) -- " + AUDIO)
self.geometry("1400x800")
os.makedirs(TMPDIR, exist_ok=True)
self.zones = load_zones()
self.files = sorted(f for f in os.listdir(AUDIO) if f.lower().endswith(".wav"))
self.rates = {}
for f in self.files:
fmt = wav_fmt(os.path.join(AUDIO, f))
self.rates[f] = fmt[0] if fmt else 0
self.plays = self.build_play_table()
self.playing_all = False
top = ttk.Frame(self); top.pack(fill="x", padx=6, pady=4)
@@ -50,6 +155,9 @@ class SoundBoard(tk.Tk):
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.raw_var = tk.BooleanVar(value=False)
ttk.Checkbutton(top, text="Raw file rate (ignore game shift)",
variable=self.raw_var).pack(side="left", padx=10)
self.status = tk.StringVar(value=f"{len(self.files)} samples loaded")
ttk.Label(top, textvariable=self.status, foreground="#06c").pack(side="left", padx=12)
@@ -69,30 +177,94 @@ class SoundBoard(tk.Tk):
self.rebuild()
def build_play_table(self):
"""file -> list of (note, shift, trigger); primary first.
A zone whose key range contains 60 plays at note 60 (= file rate)
from every plain watcher Start; sequence notes are added on top.
A zone whose range EXCLUDES 60 is reachable ONLY via its sequence
notes -- its file-rate rendering never occurs in game."""
by_patch = {}
for b, p, notes, trig in GAME_TRIGGERS:
by_patch.setdefault((b, p), []).extend((n, trig) for n in notes)
plays = {}
for f in self.files:
z = self.zones.get(f)
entries = []
if z:
seen = set()
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)
entries.append((n, note_shift(n), trig))
if z["keyLo"] <= 60 <= z["keyHi"] and 60 not in seen:
# default-60 start reaches this zone at file rate
entries.append((60, 1.0, "default start (note 60)"))
if not entries:
entries = [(60, 1.0, "default start (note 60)")]
# primary: sequence-driven patches lead with their first authored
# sequence note; everything else leads with the note-60 default
if not (z and (z["bank"], z["patch"]) in SEQ_PRIMARY_PATCHES):
entries.sort(key=lambda e: (e[0] != 60, e[0]))
plays[f] = entries
return plays
def label_for(self, f):
bp = self.mapping.get(f)
tag = f"[{bp[0]}:{bp[1]}] " if bp else "[-:-] "
return tag + f[:-4]
z = self.zones.get(f)
tag = f"[{z['bank']}:{z['patch']}] " if z else "[-:-] "
if z and (z["bank"], z["patch"]) in VOICE_PRESETS:
tag += "V "
primary = self.plays[f][0]
shift_txt = "" if primary[1] == 1.0 else f" x{primary[1]:.2f}" if primary[1] >= 0.1 else f" x{primary[1]:.3f}"
return tag + f[:-4] + shift_txt
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
cols = 3
for i, f in enumerate(shown):
b = ttk.Button(self.grid_frame, text=self.label_for(f), width=38,
cell = ttk.Frame(self.grid_frame)
cell.grid(row=i // cols, column=i % cols, padx=2, pady=1, sticky="w")
b = ttk.Button(cell, text=self.label_for(f), width=44,
command=lambda ff=f: self.play(ff))
b.grid(row=i // cols, column=i % cols, padx=2, pady=1, sticky="w")
b.pack(side="left")
notes = self.plays[f]
if len(notes) > 1:
for n, sh, trig in notes:
txt = f"n{n}"
nb = ttk.Button(cell, text=txt, width=4,
command=lambda ff=f, nn=n: self.play(ff, nn))
nb.pack(side="left")
self.status.set(f"{len(shown)} shown / {len(self.files)} total")
self.canvas.yview_moveto(0)
def play(self, f):
def path_for(self, f, note):
"""the file to hand winsound: raw, or a header-rewritten game-rate copy."""
if self.raw_var.get() or note == 60:
return os.path.join(AUDIO, f)
game_rate = int(round(self.rates[f] * note_shift(note)))
dst = os.path.join(TMPDIR, f"{f[:-4]}@n{note}.wav")
if not os.path.exists(dst):
if not make_rate_copy(os.path.join(AUDIO, f), dst, game_rate):
return os.path.join(AUDIO, f)
return dst
def play(self, f, note=None):
self.playing_all = False
path = os.path.join(AUDIO, f)
entries = self.plays[f]
if note is None:
note, shift, trig = entries[0]
else:
shift, trig = next((s, t) for n, s, t in entries if n == note)
path = self.path_for(f, note)
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 ""))
z = self.zones.get(f)
bp = f"bank {z['bank']}, patch {z['patch']}" if z else "unmapped"
mode = "RAW" if self.raw_var.get() else f"note {note} x{shift:.4g}"
game_rate = int(round(self.rates[f] * (1.0 if self.raw_var.get() else note_shift(note))))
self.status.set(f"PLAYING {f} ({bp}; {mode}; {game_rate} Hz; {trig})")
def stop(self):
self.playing_all = False
@@ -107,9 +279,10 @@ class SoundBoard(tk.Tk):
for f in shown:
if not self.playing_all:
return
note = self.plays[f][0][0]
path = self.path_for(f, note)
self.after(0, lambda ff=f: self.status.set("PLAYING " + ff))
winsound.PlaySound(os.path.join(AUDIO, f),
winsound.SND_FILENAME | winsound.SND_ASYNC)
winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
time.sleep(1.0)
self.playing_all = False
threading.Thread(target=run, daemon=True).start()