Finishing the step I left open last iteration rather than starting something new. Offsets and packet counts were in hand; what was missing was proof they are actually waves. slb_extract_wave.py wraps a (bank, offset, packets, channels, rate) slice in a synthesized XMA1 RIFF, following the layout the Rust decoder already uses. The three located cues decode to 0.533 s, 0.344 s and 1.016 s of mono 48 kHz audio, audible from sample 0, each with the percussive attack-and-decay envelope of a UI blip. Bitrates come out at 12-15 kB/s, about half the stereo BGM rate, which is what mono should be. The control matters more than the results. The SAME wrapper applied to BGM_001's first wave decodes to 173.808875 s -- identical to the duration that bank's own on-disc RIFF header produced back when Q10 was answered. So the header I synthesized is not approximately right, it reproduces a known-good decode exactly, and the cue durations are trustworthy for the same reason. I did not commit the decoded audio. Three commands regenerate it from the disc, and the corpus's job is measurements and tooling rather than extracted game assets. The offsets, the packet counts and the tool are the deliverable.
59 lines
2.4 KiB
Python
Executable File
59 lines
2.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Extract one wave out of a `.slb` bank as a standalone XMA1 RIFF.
|
|
|
|
For banks with no internal delimiters -- `Static.slb`, which is a packed run of
|
|
whole 2048-byte XMA packets -- a wave is defined only by (offset, packet count).
|
|
Those come from the running game: launch Canary with `--xma_param_probe=true`,
|
|
trigger the sound, and the log prints the stream's packet count and first 32
|
|
bytes; search those bytes in the bank to get the offset.
|
|
See docs/re/menu-audio-cues.md.
|
|
|
|
slb_extract_wave.py <bank.slb name> <offset> <packets> [channels] [rate] [out.riff]
|
|
slb_extract_wave.py Static.slb 0x1ec0 4 # the d-pad cursor cue
|
|
|
|
Decode with: ffmpeg -i out.riff out.wav
|
|
"""
|
|
import os, struct, sys
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
exec(open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"slb_segment_phase.py")).read().split("def cmd_phases")[0])
|
|
|
|
XMA1_PACKET = 2048
|
|
|
|
def synth_fmt(channels, mask, rate):
|
|
"""The 32-byte XMAWAVEFORMAT ffmpeg's `xma1` decoder wants."""
|
|
f = b"fmt " + struct.pack("<I", 32)
|
|
f += struct.pack("<HHHH", 0x0165, 16, 0, 0) # tag=XMA1, bits, opts, skip
|
|
f += struct.pack("<H", 1) + bytes([0, 3]) # streams, loopcount, version
|
|
f += struct.pack("<IIII", rate * channels * 2, rate, 0, 0)
|
|
f += bytes([4, channels]) + struct.pack("<H", mask)
|
|
return f
|
|
|
|
def riff(fmt, data):
|
|
body = b"WAVE" + fmt + b"data" + struct.pack("<I", len(data)) + data
|
|
return b"RIFF" + struct.pack("<I", len(body)) + body
|
|
|
|
def main():
|
|
a = sys.argv[1:]
|
|
if len(a) < 3:
|
|
print(__doc__); return 1
|
|
name, off, pkts = a[0], int(a[1], 0), int(a[2], 0)
|
|
ch = int(a[3]) if len(a) > 3 else 1
|
|
rate = int(a[4]) if len(a) > 4 else 48000
|
|
out = a[5] if len(a) > 5 else f"{name.split('.')[0]}_{off:#x}.riff"
|
|
disc = os.environ.get("SYLPHEED_DISC", "/work/sylph_extract")
|
|
pak = Pak(os.path.join(disc, "dat", "sound.pak"))
|
|
i = pak.find(name)
|
|
if i is None:
|
|
print(f"{name}: not in sound.pak"); return 2
|
|
b = pak.read(i)
|
|
data = b[off:off + pkts * XMA1_PACKET]
|
|
if len(data) < pkts * XMA1_PACKET:
|
|
print(f"warning: short read -- {len(data)} of {pkts * XMA1_PACKET} bytes")
|
|
open(out, "wb").write(riff(synth_fmt(ch, 1 if ch == 1 else 2, rate), data))
|
|
print(f"{out}: {len(data)} bytes, {pkts} packets, {ch}ch {rate}Hz")
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|