Files
BT412/tools/sf2extract.py
T
arcattackandClaude Opus 4.8 691e88569a Audio Phase 2 (AUDIO_FIDELITY F1/F2/F13/F14): full-zone soundbank -- key-splits,
layers, baked tuning, loop regions, release fades

Extractor rewrite (tools/sf2extract.py): every sample-bearing instrument zone
becomes a SAMPLEINFO slot -- 603 zones / 241 presets (68/115 + 94/126 multi-
zone, matching the audit exactly).  Per zone: keyRange(43), sampleModes(54),
SBK samplePitch(55) + rootKey(58) + coarse/fineTune(51/52), attenuation(48,
INVERTED SBK scale, 0.375 dB/step [T3], baked into the PCM), releaseVolEnv(38),
pan(17), shdr loop region.

F2 pitch (algebraically exact): WAV rate = round(44100 * 2^(((6000 -
rootCents) + tune)/1200)) -- EMU8000 v1 base 44100.  Cross-checks: FootFall
17300 Hz (the audit's +4.2 st), MissileLoaded low zone 88200 (-24 st),
Warnings01 8-way klaxon split w/ 3 looped zones, Death01 162 Hz extremes.

F1 zone selection: SetupPatch/PlayNote take the authored note;
attach/play only zones whose [keyLo,keyHi] contains it (detach the rest so a
rewound source can't replay a stale buffer).  Live-verified: LaserLoaded/
MissileLoaded note 36 -> low clunk zone, note 84 -> high blip zone (pitch 4);
LaserCFire plays all 3 authored layers incl the looping sustain.  Stereo-pair
zones pan via listener-relative AL_POSITION (distance model is AL_NONE).
MAX_PRESET_SAMPLES=25 (AllExplosion); fixed PRESET_isImplemented's >=5 bound.

F13 loops + releases: authored [loopStart,loopEnd] applied via
AL_SOFT_loop_points at buffer load (0 rejections; kills the latent boom-loop
on MechExplosion's 1.5% sustain slice + the ~2.4 Hz LaserBSustain wrap tick);
StopNote now honors the authored releaseVolEnv (1.1-3.9 s on ~20 looping
presets) with a dB-linear fade serviced from AudioHead::Execute; restarts
reclaim fading sources.  One-shots keep the instant stop (faithful).

Regression (40s drive+fire): stable, loop points accepted, key-splits + layers
verified in the delivery trace, chirp still dead, footfalls fire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:04:34 -05:00

240 lines
11 KiB
Python

#!/usr/bin/env python3
"""sf2extract.py -- extract the BattleTech AWE32 SoundFonts (AUDIO1/2.RES) into
loose WAV files + generate the allPresets[] C++ table for the WinTesla port.
FULL-ZONE extraction (AUDIO_FIDELITY.md F1/F2/F13/F14). The banks are SoundFont
**v1.0** (ifil 1.0, EMU8000): `shdr` records are 16 bytes {dwStart,dwEnd,
dwStartLoop,dwEndLoop} (no name/rate/pitch field). Samples are 16-bit mono PCM
in `smpl`. Preset -> instrument resolves phdr->pbag->pgen(41); every sample-
bearing ibag zone of that instrument becomes a SAMPLEINFO slot.
Per-zone recovered metadata:
keyRange(43) -> keyLo/keyHi (SetupPatch selects zones by the note)
sampleModes(54) -> loop flag (1/3 = loop)
samplePitch(55, SBK) -> root*100+cents, present on every zone
overridingRootKey(58) -> replaces the gen-55 root when present
coarseTune(51)/fineTune(52) -> added cents
initialAttenuation(48) -> SBK INVERTED scale (127=full); 0.375 dB/step [T3],
baked into the WAV PCM (layer balance)
releaseVolEnv(38) -> timecents -> seconds, for the Stop-path gain fade
pan(17) -> 0..127 -> CHANNEL_LEFT/CENTER/RIGHT
shdr loop points -> loopStart/loopEnd frames (AL_SOFT_loop_points)
PITCH (F2, algebraically exact): EMU8000 v1 base rate is 44100 Hz; a zone's true
data rate is baked into the WAV header as
rate = round(44100 * 2^(((6000 - effRootCents) + tuneCents) / 1200))
so the engine's note-60 pitch model (BTNotePitchFactor) reproduces original
playback at every note. effRootCents = gen58*100 if present else gen55.
Output:
content/AUDIO/<PresetName>[_zK].wav
game/reconstructed/audiopresets.cpp (allPresets[2][128], all zones)
Regenerate: python tools/sf2extract.py (add --stats for the audit cross-check)
"""
import struct, os, sys, wave, math
HERE = os.path.dirname(__file__)
AUDIO = os.path.join(HERE, "..", "content", "AUDIO")
OUT_CPP = os.path.join(HERE, "..", "game", "reconstructed", "audiopresets.cpp")
BASE_RATE = 44100 # EMU8000 v1 (awesfx sffile.c hardcodes v1 samplerate 44100)
MAX_ZONES = 25 # PRESETINFO samples[] capacity (AllExplosion = 25 layers)
G_KEYRANGE, G_PAN, G_RELEASE, G_INST, G_KEYRANGE2 = 43, 17, 38, 41, 43
G_ATTEN, G_COARSE, G_FINE, G_SAMPLEID, G_MODES, G_PITCH, G_ROOT = 48, 51, 52, 53, 54, 55, 58
SIGNED_GENS = {G_COARSE, G_FINE, G_RELEASE}
def s16(v):
return v - 0x10000 if v >= 0x8000 else v
class Bank:
def __init__(self, path):
d = open(path, "rb").read()
assert d[:4] == b"RIFF" and d[8:12] == b"sfbk"
self.d = d
def find(fc):
p = d.find(fc)
return p + 8, struct.unpack_from("<I", d, p + 4)[0]
smpl_o, smpl_s = find(b"smpl")
self.smpl = d[smpl_o:smpl_o + smpl_s]
self.pho, _ = find(b"phdr"); self.pbo, _ = find(b"pbag"); self.pgo, _ = find(b"pgen")
self.ino, _ = find(b"inst"); self.ibo, _ = find(b"ibag"); self.igo, _ = find(b"igen")
self.sho, _ = find(b"shdr")
self.nphdr = 0
while d[self.pho + self.nphdr * 38:self.pho + self.nphdr * 38 + 3] != b"EOP" and self.nphdr < 200:
self.nphdr += 1
def u16(self, o):
return struct.unpack_from("<H", self.d, o)[0]
def gens(self, base, g0, g1):
out = {}
for g in range(g0, g1):
op, amt = struct.unpack_from("<HH", self.d, base + g * 4)
out[op] = s16(amt) if op in SIGNED_GENS else amt
return out
def preset_zones(self, i):
"""-> (name, presetNum, [zone-dict]) zone gens merged: inst-global <- inst-zone <- preset adds"""
name = self.d[self.pho + i * 38:self.pho + i * 38 + 20].split(b"\x00")[0].decode("latin1").strip()
preset, bank, pbag = struct.unpack_from("<HHH", self.d, self.pho + i * 38 + 20)
pbag2 = self.u16(self.pho + (i + 1) * 38 + 24)
zones = []
pglobal = {}
for b in range(pbag, pbag2):
pz = self.gens(self.pgo, self.u16(self.pbo + b * 4), self.u16(self.pbo + (b + 1) * 4))
if G_INST not in pz:
pglobal = pz # preset-level global zone
continue
inst = pz[G_INST]
ib, ib2 = self.u16(self.ino + inst * 22 + 20), self.u16(self.ino + (inst + 1) * 22 + 20)
iglobal = {}
for zb in range(ib, ib2):
iz = self.gens(self.igo, self.u16(self.ibo + zb * 4), self.u16(self.ibo + (zb + 1) * 4))
if G_SAMPLEID not in iz:
iglobal = iz # instrument-level global zone
continue
g = dict(iglobal); g.update(iz)
# preset-level: keyRange INTERSECTS, tunes/release ADD (rare in these banks)
for pk in (pglobal, pz):
if G_KEYRANGE in pk and G_KEYRANGE in g:
alo, ahi = g[G_KEYRANGE] & 0xFF, g[G_KEYRANGE] >> 8
blo, bhi = pk[G_KEYRANGE] & 0xFF, pk[G_KEYRANGE] >> 8
g[G_KEYRANGE] = max(alo, blo) | (min(ahi, bhi) << 8)
elif G_KEYRANGE in pk:
g[G_KEYRANGE] = pk[G_KEYRANGE]
for add in (G_COARSE, G_FINE, G_RELEASE):
if add in pk:
g[add] = g.get(add, 0) + pk[add]
zones.append(g)
return name, preset, zones
def zone_fields(bank, g):
st, en, ls, le = struct.unpack_from("<IIII", bank.d, bank.sho + g[G_SAMPLEID] * 16)
pcm = bank.smpl[st * 2:en * 2]
# pitch: gen55 = root*100+cents on every zone; gen58 root override
root_cents = g.get(G_PITCH, 6000)
if G_ROOT in g and 0 <= g[G_ROOT] <= 127:
root_cents = g[G_ROOT] * 100
tune = g.get(G_COARSE, 0) * 100 + g.get(G_FINE, 0)
rate = int(round(BASE_RATE * 2.0 ** (((6000 - root_cents) + tune) / 1200.0)))
kr = g.get(G_KEYRANGE, 0x7F00)
keylo, keyhi = kr & 0xFF, kr >> 8
sm = g.get(G_MODES, 0) & 3
looping = sm in (1, 3)
lstart = max(0, min(ls - st, en - st)) if looping else 0
lend = max(0, min(le - st, en - st)) if looping else 0
if looping and lend <= lstart: # degenerate loop -> whole buffer
lstart, lend = 0, en - st
# SBK attenuation: INVERTED (127 = full volume), 0.375 dB/step [T3 exact curve]
atten = g.get(G_ATTEN, 127)
gain = 10.0 ** (-((127 - atten) * 0.375) / 20.0) if atten < 127 else 1.0
rel_tc = g.get(G_RELEASE, None)
release = min(8.0, 2.0 ** (rel_tc / 1200.0)) if rel_tc is not None else 0.0
pan = g.get(G_PAN, 64)
chan = "CHANNEL_LEFT" if pan <= 42 else ("CHANNEL_RIGHT" if pan >= 85 else "CHANNEL_CENTER")
return dict(pcm=pcm, rate=rate, keylo=keylo, keyhi=keyhi, looping=looping,
lstart=lstart, lend=lend, gain=gain, release=release, chan=chan,
sid=g[G_SAMPLEID], root_cents=root_cents, tune=tune)
def write_wav(fname, pcm, rate, gain):
if gain < 0.999: # bake layer-balance attenuation
n = len(pcm) // 2
vals = struct.unpack("<%dh" % n, pcm[:n * 2])
pcm = struct.pack("<%dh" % n, *[max(-32768, min(32767, int(v * gain))) for v in vals])
with wave.open(os.path.join(AUDIO, fname), "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
w.writeframes(pcm)
def main():
stats = "--stats" in sys.argv
banks = {1: "AUDIO1.RES", 2: "AUDIO2.RES"}
table = {1: {}, 2: {}}
grid_outliers = []
total_zones = 0
for bn, fn in banks.items():
bank = Bank(os.path.join(AUDIO, fn))
multi = 0
for i in range(bank.nphdr):
name, preset, zones = bank.preset_zones(i)
if not name or not zones:
continue
zs = []
for g in zones[:MAX_ZONES]:
z = zone_fields(bank, g)
if not z["pcm"]:
continue
zs.append(z)
if len(zones) > MAX_ZONES:
print(f"WARNING: {name} has {len(zones)} zones, capped at {MAX_ZONES}")
if not zs:
continue
for k, z in enumerate(zs):
z["file"] = f"{name}.wav" if len(zs) == 1 else f"{name}_z{k}.wav"
write_wav(z["file"], z["pcm"], z["rate"], z["gain"])
cents = 1200.0 * math.log2(z["rate"] / 44100.0)
if abs(cents - round(cents / 100.0) * 100) > 3 and not z["tune"] % 100:
grid_outliers.append((name, k, z["rate"], round(cents, 1)))
table[bn][preset] = (name, zs)
total_zones += len(zs)
if len(zs) > 1:
multi += 1
print(f"bank {bn} ({fn}): {len(table[bn])} presets, {multi} multi-zone")
print(f"total zones: {total_zones}")
if grid_outliers:
print(f"rate-grid outliers (expect few): {len(grid_outliers)}")
for o in grid_outliers[:10]:
print(" ", o)
if stats:
for bn in (1, 2):
for preset, (name, zs) in sorted(table[bn].items()):
if len(zs) > 1:
print(f"b{bn} p{preset} {name}: " + " ".join(
f"[{z['keylo']}-{z['keyhi']} r{z['rate']} {'L' if z['looping'] else '1'}"
f"{' rel%.1f' % z['release'] if z['release'] else ''}]" for z in zs))
return
with open(OUT_CPP, "w", newline="\n") as f:
f.write('// GENERATED by tools/sf2extract.py -- the audio soundbank table.\n')
f.write('// FULL-ZONE extraction (AUDIO_FIDELITY.md F1/F2/F13/F14): every preset\n')
f.write('// carries ALL its instrument zones (key-splits, layers, stereo pairs)\n')
f.write('// with per-zone key range, loop region (frames), release seconds and\n')
f.write('// channel; per-zone tuning + attenuation are baked into the WAVs.\n')
f.write('// Regenerate: python tools/sf2extract.py\n')
f.write('#include <L4AUDLVL.hpp>\n\n')
f.write('PRESETINFO allPresets[2][128] = {};\n\n')
f.write('namespace {\n')
f.write('void Z(int b, int p, const char *file, int keyLo, int keyHi, SampleLoop loop,\n')
f.write(' int loopStart, int loopEnd, float releaseSec, SampleChannel chan)\n')
f.write('{\n')
f.write('\tPRESETINFO &pi = allPresets[b][p];\n')
f.write('\tif (pi.sampleNum >= MAX_PRESET_SAMPLES) return;\n')
f.write('\tSAMPLEINFO &s = pi.samples[pi.sampleNum++];\n')
f.write('\ts.implemented = true; s.bufferIndex = -1; s.file = file;\n')
f.write('\ts.keyLo = keyLo; s.keyHi = keyHi; s.loop = loop;\n')
f.write('\ts.loopStart = loopStart; s.loopEnd = loopEnd;\n')
f.write('\ts.releaseSec = releaseSec; s.chan = chan;\n')
f.write('}\n\n')
f.write('struct AudioPresetInit {\n\tAudioPresetInit() {\n')
for bn in (1, 2):
for preset, (name, zs) in sorted(table[bn].items()):
for z in zs:
loop = "LoopAtWill" if z["looping"] else "ForceStatic"
f.write(f'\t\tZ({bn-1},{preset},"{z["file"]}",{z["keylo"]},{z["keyhi"]},'
f'{loop},{z["lstart"]},{z["lend"]},{z["release"]:.3f}f,{z["chan"]});\n')
f.write('\t}\n} s_audioPresetInit;\n}\n')
npresets = len(table[1]) + len(table[2])
print(f"wrote {OUT_CPP} ({npresets} presets, {total_zones} zones)")
if __name__ == "__main__":
main()