diff --git a/docs/AUDIO_FIDELITY.md b/docs/AUDIO_FIDELITY.md index 871a73d..56be4c3 100644 --- a/docs/AUDIO_FIDELITY.md +++ b/docs/AUDIO_FIDELITY.md @@ -617,6 +617,60 @@ reduce source-pool starvation. --- + +### 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 `` +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). + +--- + ## 4. Validations and KB corrections (no fidelity gap) diff --git a/tools/mksoundboard.py b/tools/mksoundboard.py new file mode 100644 index 0000000..0c75882 --- /dev/null +++ b/tools/mksoundboard.py @@ -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 .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 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 """ + + + +BT411 Soundboard + + + + + BT411 SOUNDBOARD — __NFILES__ samples, played the way the game plays them + + Click a sound to hear it at its in-game speed (an x0.25 tag means the game + plays that file 4x slower than it is stored — the board reproduces that). Small + n## buttons appear when one sample fires at several pitches in-game — click each + to hear each. When you report a sound in Discord: hit COPY on its card and paste the + reference string (it looks like [2:113] Warnings01_z0 n16) — + that string tells us exactly which sound you mean. VOICE = the cockpit warning voice. + + + + STOP + Play All (filtered) + RAW file rate (ignore game shift) + + + + +ready — __NSHIFT__ (sample, trigger) pairs play rate-shifted in-game + + + +""".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(" %d Hz" % (f, hz, zh)) + +if __name__ == "__main__": + main() diff --git a/tools/soundboard.py b/tools/soundboard.py index 03d8c7d..d50a39d 100644 --- a/tools/soundboard.py +++ b/tools/soundboard.py @@ -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(" 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()