Weapon fire (and ~all one-shots) played FOREVER: sf2extract flagged a sample as looping whenever the SF2 shdr had loop points -- but almost every SoundFont sample has loop points; whether to actually loop is the instrument's sampleModes generator (igen oper 54): 0/2 = one-shot, 1 = loop, 3 = loop-until-release. The old heuristic looped 239/241 samples, so SetupPatch set AL_LOOPING=1 on fire/explosion/step sounds. Read sampleModes at the same igen zone as sampleID and loop only on 1/3. Now 166 one-shot / 75 loop: LaserAFire/BigExplosion/BasicClick -> ForceStatic (one-shot), EnginePower/EngineMotor/coolant -> LoopAtWill (correctly sustained). Regenerated audiopresets.cpp; the WAV PCM is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
5.4 KiB
Python
111 lines
5.4 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.
|
|
|
|
The banks are SoundFont **v1.0** (ifil 1.0, EMU8000): the `shdr` sample headers are
|
|
16 bytes {dwStart,dwEnd,dwStartLoop,dwEndLoop} (NO name/rate field -- the v2.0
|
|
46-byte layout does not apply). Samples are 16-bit mono PCM in `smpl`. Preset ->
|
|
sample resolves through phdr->pbag->pgen(inst=41)->inst->ibag->igen(sampleID=53)->shdr.
|
|
|
|
Output:
|
|
content/AUDIO/<PresetName>.wav (one per preset; the L4AUDRES loader sf_opens these)
|
|
game/reconstructed/audiopresets.cpp (the allPresets[2][100] table, replaces the
|
|
btstubs.cpp zero-init stub)
|
|
|
|
Sample rate: SF2 v1.0 shdr has no per-sample rate; these were authored at 22050 Hz
|
|
(SAMPLE_RATE below -- adjust if pitch is wrong). Loop is taken from the shdr loop
|
|
points (a real loop region -> LoopAtWill; else ForceStatic one-shot).
|
|
"""
|
|
import struct, os, wave
|
|
|
|
HERE = os.path.dirname(__file__)
|
|
AUDIO = os.path.join(HERE, "..", "content", "AUDIO")
|
|
OUT_CPP = os.path.join(HERE, "..", "game", "reconstructed", "audiopresets.cpp")
|
|
SAMPLE_RATE = 22050
|
|
|
|
def parse_bank(path):
|
|
d = open(path, "rb").read()
|
|
assert d[:4]==b"RIFF" and d[8:12]==b"sfbk"
|
|
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"); smpl = d[smpl_o:smpl_o+smpl_s]
|
|
pho,_=find(b"phdr"); pbo,_=find(b"pbag"); pgo,_=find(b"pgen")
|
|
ino,_=find(b"inst"); ibo,_=find(b"ibag"); igo,_=find(b"igen")
|
|
sho,shs=find(b"shdr") # 16-byte v1.0 records
|
|
def u16(o): return struct.unpack_from("<H",d,o)[0]
|
|
def genlk(base,g0,g1,oper):
|
|
for g in range(g0,g1):
|
|
op,amt=struct.unpack_from("<HH",d,base+g*4)
|
|
if op==oper: return amt
|
|
return None
|
|
nphdr = 0
|
|
while d[pho+nphdr*38:pho+nphdr*38+3] != b"EOP" and nphdr < 200:
|
|
nphdr += 1
|
|
out = []
|
|
for i in range(nphdr):
|
|
name=d[pho+i*38:pho+i*38+20].split(b"\x00")[0].decode("latin1").strip()
|
|
preset,bank,pbag=struct.unpack_from("<HHH",d,pho+i*38+20)
|
|
pbag2=struct.unpack_from("<H",d,pho+(i+1)*38+24)[0]
|
|
instId=None
|
|
for b in range(pbag,pbag2):
|
|
instId=genlk(pgo,u16(pbo+b*4),u16(pbo+(b+1)*4),41)
|
|
if instId is not None: break
|
|
sampId=None; sampleModes=0
|
|
if instId is not None:
|
|
ib=u16(ino+instId*22+20); ib2=u16(ino+(instId+1)*22+20)
|
|
for b in range(ib,ib2):
|
|
g0=u16(ibo+b*4); g1=u16(ibo+(b+1)*4)
|
|
sid=genlk(igo,g0,g1,53) # oper 53 = sampleID
|
|
if sid is not None:
|
|
sampId=sid
|
|
sm=genlk(igo,g0,g1,54) # oper 54 = sampleModes
|
|
if sm is not None: sampleModes=sm
|
|
break
|
|
if sampId is None: continue
|
|
st,en,ls,le=struct.unpack_from("<IIII", d, sho+sampId*16)
|
|
# SF2 sampleModes: 0/2 = no loop (one-shot), 1 = loop, 3 = loop-until-release.
|
|
# (Presence of shdr loop points does NOT mean loop -- almost every sample has
|
|
# them; only sampleModes decides. The old loop-points heuristic looped ~all
|
|
# 241 samples, so weapon fire / explosions / steps played forever.)
|
|
looping = (sampleModes == 1 or sampleModes == 3)
|
|
out.append((preset, name, smpl[st*2:en*2], looping))
|
|
return out
|
|
|
|
def write_wav(name, pcm):
|
|
with wave.open(os.path.join(AUDIO, name+".wav"), "wb") as w:
|
|
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SAMPLE_RATE)
|
|
w.writeframes(pcm)
|
|
|
|
def main():
|
|
banks = {1:"AUDIO1.RES", 2:"AUDIO2.RES"}
|
|
table = {1:{}, 2:{}}
|
|
for bn, fn in banks.items():
|
|
presets = parse_bank(os.path.join(AUDIO, fn))
|
|
for (preset, name, pcm, looping) in presets:
|
|
if not name or not pcm: continue
|
|
write_wav(name, pcm)
|
|
table[bn][preset] = (name, looping)
|
|
print(f"bank {bn} ({fn}): {len(table[bn])} presets extracted to WAV")
|
|
# emit allPresets[2][100]
|
|
with open(OUT_CPP, "w", newline="\n") as f:
|
|
f.write('// GENERATED by scratchpad/sf2extract.py -- the audio soundbank table.\n')
|
|
f.write('// Replaces the zero-init allPresets stub (btstubs.cpp) with the real\n')
|
|
f.write('// bank/preset -> sample map recovered from the AWE32 SoundFonts\n')
|
|
f.write('// (AUDIO1/2.RES, SF2 v1.0). Each preset -> a loose <Name>.wav the\n')
|
|
f.write('// L4AUDRES loader sf_opens. Regenerate: python scratchpad/sf2extract.py\n')
|
|
f.write('#include <L4AUDLVL.hpp>\n\n')
|
|
f.write('PRESETINFO allPresets[2][128] = {};\n\n')
|
|
f.write('namespace {\nstruct AudioPresetInit {\n\tAudioPresetInit() {\n')
|
|
for bn in (1,2):
|
|
for preset, (name, looping) in sorted(table[bn].items()):
|
|
loop = "SampleLoop::LoopAtWill" if looping else "SampleLoop::ForceStatic"
|
|
f.write(f'\t\t{{ SAMPLEINFO &s = allPresets[{bn-1}][{preset}].samples[0];\n')
|
|
f.write(f'\t\t allPresets[{bn-1}][{preset}].sampleNum = 1;\n')
|
|
f.write(f'\t\t s.implemented = true; s.bufferIndex = -1; s.file = "{name}.wav";\n')
|
|
f.write(f'\t\t s.chan = SampleChannel::CHANNEL_CENTER; s.loop = {loop}; }}\n')
|
|
f.write('\t}\n} s_audioPresetInit;\n}\n')
|
|
total = len(table[1]) + len(table[2])
|
|
print(f"wrote {OUT_CPP} ({total} presets)")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|