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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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("&", "&").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()
|
||||
+205
-32
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user