Files
RP412/tools/rp_sf2extract.py
T
CydandClaude Opus 5 d361a0b8be The sound effects play at the pitch they were written at
Red Planet's original AWE32 soundbanks are back in the tree, and the game's
sound effects are now generated from them instead of from an incomplete
one-off extraction.

AUDIO1.RES and AUDIO2.RES come from the 1996 release in the TeslaRel410
archive, hash-identical. AUDIO.INI has named them all along - they were
simply never carried into the port. tools/rp_sf2extract.py reads them and
regenerates both the WAV set and RP_L4/WTPresets.cpp, so the assets are
reproducible from the banks rather than hand-maintained.

Two things were wrong with the old set:

Pitch. Every shipped WAV was flat 44100 Hz with the banks' tuning discarded,
so 202 of the 219 zones played at the wrong speed - the worst by nine
semitones. The EMU8000's per-zone root key and tuning are now baked into
each file's declared sample rate, which is exact and needs no engine change.
Layers that were meant to be deep now are: a collision sub-thud that lasted
18 milliseconds at the wrong rate is a 0.66 second one at 1228 Hz.

Missing layers. 93 presets were short of zones and 176 were missing outright,
219 of 395. Nothing was lost recovering them - the 46 preset slots that
disappeared were all empty placeholders. The old files were also over-read,
running past the end of their sample into whatever PCM came next;
WellheadDrill02a was six seconds where the bank says eight hundred
milliseconds. Every one of the 395 files now matches its bank record exactly.

Also baked in: per-zone layer attenuation, and the static resonant low-pass
the EMU8000 applied in hardware.

Measured while doing it, and worth knowing: RP's banks contain no key-splits
at all - every multi-zone preset is a pure layer stack - and no preset has
more than four zones, which is what the engine's own "AWE appears to only
play 1st 4 voices" warning has been asserting since 1995.

Still to do: loop regions and the release fades, which 349 zones ask for and
which need new SAMPLEINFO fields. And voice demand per sound has gone from
about one zone to about two and a half, so the per-event alGenSources and
alDeleteSources churn roughly doubles - the BT tree measured pooling as the
fix for that, and a CPU win besides.

Builds clean. The extreme baked rates, 1228 Hz up to 88200, were checked
through the real path - libsndfile, alBufferData, alSourcePlay - and all
load. Not yet listened to on the pod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:16:13 -05:00

327 lines
13 KiB
Python

#!/usr/bin/env python3
"""rp_sf2extract.py -- extract Red Planet's AWE32 soundbanks (AUDIO1/2.RES) into
loose WAV files and regenerate RP_L4/WTPresets.cpp.
Adapted from the BattleTech tree's tools/sf2extract.py (same MUNGA engine, same
SoundFont v1.0 / SBK banks) for RP's own PRESETINFO layout. See docs/SOUND.md.
The banks are SoundFont **v1.0** (ifil 1.0, EMU8000): `shdr` records are 16 bytes
{dwStart,dwEnd,dwStartLoop,dwEndLoop} with 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.
RP-specific facts (measured, not assumed -- see docs/SOUND.md):
* 154 presets, 395 zones, max 4 zones in any one preset (fits samples[5]).
* ZERO key-splits: every multi-zone preset is a pure LAYER stack whose zones
all share one keyRange. RP's authored content also predates
NoteAudioControlID, so every source plays at note 60 -- key ranges are
therefore not emitted, because nothing could ever select on them.
* The previously shipped WAVs were all flat 44100 Hz with tuning discarded,
and 93 presets were missing zones.
Baked into the PCM / WAV header (no engine change needed):
samplePitch(55) / overridingRootKey(58) / coarseTune(51) / fineTune(52)
-> the WAV's declared sample rate, so playback at note 60 reproduces the
EMU8000 exactly: rate = 44100 * 2^(((6000 - rootCents) + tune)/1200)
initialAttenuation(48) -> SBK INVERTED scale (127 = full volume), 0.375 dB
per step; layer balance, multiplied into the PCM
initialFilterFc(8)/Q(9) -> the authored static resonant low-pass the EMU8000
applied in hardware, run over the PCM
Read from the bank and emitted to the table:
sampleModes(54) -> LoopAtWill / ForceStatic
pan(17) -> CHANNEL_LEFT / CENTER / RIGHT
NOT yet carried (needs SAMPLEINFO fields + engine work -- docs/SOUND.md F13):
loop regions (shdr dwStartLoop/dwEndLoop) and releaseVolEnv(38) fades.
Usage:
python tools/rp_sf2extract.py --stats # report only, writes nothing
python tools/rp_sf2extract.py # extract WAVs + rewrite the table
"""
import struct, os, sys, wave, math, collections
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
AUDIO = os.path.join(ROOT, "assets", "RP411", "AUDIO")
OUT_CPP = os.path.join(ROOT, "RP_L4", "WTPresets.cpp")
BASE_RATE = 44100 # EMU8000 v1 base rate (awesfx sffile.c: v1 == 44100)
MAX_ZONES = 5 # PRESETINFO.samples[5]
MAX_PRESETS = 100 # allPresets[2][100]
G_FILTER_FC, G_FILTER_Q, G_PAN, G_RELEASE, G_INST, G_KEYRANGE = 8, 9, 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}
# zone 0 keeps the bare preset name; further layers take RP's a/b/c suffix, which
# is the convention the previously shipped assets already used.
ZONE_SUFFIX = ["", "a", "b", "c", "d"]
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", f"{path}: not an sfbk bank"
self.d = d
def find(fc):
p = d.find(fc)
assert p >= 0, f"{path}: missing {fc!r} chunk"
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 < 400):
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, [merged zone-generator dicts])"""
off = self.pho + i * 38
name = self.d[off:off + 20].split(b"\x00")[0].decode("latin1").strip()
preset, bank, pbag = struct.unpack_from("<HHH", self.d, off + 20)
pbag2 = self.u16(off + 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 = self.u16(self.ino + inst * 22 + 20)
ib2 = 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)
for pk in (pglobal, pz): # preset layer: tunes ADD
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]
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)))
looping = (g.get(G_MODES, 0) & 3) in (1, 3)
atten = g.get(G_ATTEN, 127) # SBK: 127 == full volume
gain = 10.0 ** (-((127 - atten) * 0.375) / 20.0) if atten < 127 else 1.0
pan = g.get(G_PAN, 64)
chan = "CHANNEL_LEFT" if pan <= 42 else ("CHANNEL_RIGHT" if pan >= 85 else "CHANNEL_CENTER")
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
return dict(pcm=pcm, rate=rate, looping=looping, gain=gain, chan=chan,
release=release, sid=g[G_SAMPLEID], nframes=en - st,
fc=g.get(G_FILTER_FC, 127), q=g.get(G_FILTER_Q, 0),
lstart=ls - st, lend=le - st)
def bake_lowpass(pcm, rate, fc127, q127):
"""Bake the zone's authored static resonant low-pass into the PCM. Cutoff
follows the AWE NRPN curve (100 + fc*7900/127 Hz); SBK Q 0..127 maps to
0..+12 dB of biquad peak. Designed at the zone's BAKED rate so note-60
playback reproduces the hardware's absolute cutoff."""
cutoff_hz = 100.0 + fc127 * 7900.0 / 127.0
if cutoff_hz >= 0.45 * rate: # at/above Nyquist headroom: no-op
return None
q_biquad = 0.7071 * 10.0 ** ((q127 / 127.0 * 12.0) / 20.0)
w0 = 2.0 * math.pi * cutoff_hz / rate
alpha = math.sin(w0) / (2.0 * q_biquad)
cw = math.cos(w0)
b0 = b2 = (1.0 - cw) / 2.0
b1 = 1.0 - cw
a0 = 1.0 + alpha
a1 = -2.0 * cw
a2 = 1.0 - alpha
b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0
n = len(pcm) // 2
vals = struct.unpack("<%dh" % n, pcm[:n * 2])
out = [0.0] * n
x1 = x2 = y1 = y2 = 0.0
for i, x0 in enumerate(vals):
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
x2, x1 = x1, x0
y2, y1 = y1, y0
out[i] = y0
return out
def write_wav(fname, pcm, rate, gain, fc127=127, q127=0):
"""Whole bake pipeline in float with a single int16 conversion at the end:
a resonance peak can overshoot full scale, so normalize the file down to fit
rather than clipping each stage."""
fvals = None
if fc127 < 127:
fvals = bake_lowpass(pcm, rate, fc127, q127)
if fvals is None and gain < 0.999:
n = len(pcm) // 2
fvals = [float(v) for v in struct.unpack("<%dh" % n, pcm[:n * 2])]
if fvals is not None:
if gain < 0.999:
fvals = [v * gain for v in fvals]
peak = max(1.0, max(abs(v) for v in fvals))
norm = (32767.0 / peak) if peak > 32767.0 else 1.0
pcm = struct.pack("<%dh" % len(fvals), *[int(v * norm) for v in fvals])
with wave.open(os.path.join(AUDIO, fname), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(pcm)
HEADER = """#include "..\\munga_l4\\L4AUDLVL.h"
#pragma warning ( disable : 4482)
//
// GENERATED by tools/rp_sf2extract.py from assets/RP411/AUDIO/AUDIO{1,2}.RES --
// Red Planet's original AWE32 soundbanks. Do not hand-edit; regenerate instead.
//
// Every preset now carries ALL of its instrument zones. RP's banks are pure
// LAYER stacks (no key-splits anywhere), so each zone is a simultaneous voice.
// Per-zone tuning, layer attenuation and the authored static low-pass are baked
// into the WAVs; see docs/SOUND.md and the tool's docstring.
//
// The trailing 0 on each preset is the unused is3d field.
//
PRESETINFO allPresets[2][100] =
{
"""
def emit(table):
lines = [HEADER]
for bn in (1, 2):
lines.append(f"\t//BANK {bn}\n\t{{\n")
for preset in range(MAX_PRESETS):
entry = table[bn].get(preset)
if entry is None:
lines.append("\t\t//(unused)\n\t\t{\n\t\t\t0,\n\t\t\t{\n")
for _ in range(MAX_ZONES):
lines.append('\t\t\t\t{-1, 0, "", SampleChannel::CHANNEL_CENTER,'
' SampleLoop::LoopAtWill},\n')
lines.append("\t\t\t},\n\t\t\t0\n\t\t},\n\n")
continue
name, zs = entry
lines.append(f"\t\t//{name}\n\t\t//BANK {bn} PRESET {preset}\n")
lines.append("\t\t{\n\t\t\t%d,\n\t\t\t{\n" % len(zs))
for z in zs:
loop = "ForceStatic" if not z["looping"] else "LoopAtWill"
lines.append(f'\t\t\t\t{{-1, 1, "{z["file"]}", SampleChannel::{z["chan"]},'
f' SampleLoop::{loop}}},\n')
for _ in range(MAX_ZONES - len(zs)):
lines.append('\t\t\t\t{-1, 0, "", SampleChannel::CHANNEL_CENTER,'
' SampleLoop::LoopAtWill},\n')
lines.append("\t\t\t},\n\t\t\t0\n\t\t},\n\n")
lines.append("\t},\n")
lines.append("};\n")
with open(OUT_CPP, "w", newline="\n") as f:
f.write("".join(lines))
def main():
stats = "--stats" in sys.argv
table = {1: {}, 2: {}}
total_zones = capped = 0
written = []
loopcount = filtered = attenuated = 0
for bn, fn in ((1, "AUDIO1.RES"), (2, "AUDIO2.RES")):
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
if preset >= MAX_PRESETS:
print(f"WARNING: {name} preset {preset} >= {MAX_PRESETS}, skipped")
continue
if len(zones) > MAX_ZONES:
print(f"WARNING: {name} has {len(zones)} zones, capped at {MAX_ZONES}")
capped += 1
zs = []
for k, g in enumerate(zones[:MAX_ZONES]):
z = zone_fields(bank, g)
if not z["pcm"]:
continue
z["file"] = f"{name}{ZONE_SUFFIX[len(zs)]}.wav"
zs.append(z)
if not zs:
continue
for z in zs:
loopcount += z["looping"]
filtered += z["fc"] < 127
attenuated += z["gain"] < 0.999
if not stats:
write_wav(z["file"], z["pcm"], z["rate"], z["gain"], z["fc"], z["q"])
written.append(z["file"])
table[bn][preset] = (name, zs)
total_zones += len(zs)
multi += len(zs) > 1
print(f"bank {bn} ({fn}): {len(table[bn])} presets, {multi} multi-zone")
print(f"total zones: {total_zones} looping: {loopcount} "
f"authored low-pass: {filtered} attenuated layers: {attenuated}")
if capped:
print(f"presets capped at {MAX_ZONES} zones: {capped}")
rates = collections.Counter(
z["rate"] for bn in (1, 2) for _, zs in table[bn].values() for z in zs)
print(f"distinct baked sample rates: {len(rates)} "
f"(most common: {', '.join(f'{r}Hz x{n}' for r, n in rates.most_common(5))})")
if stats:
print("\n--stats: nothing written")
return
emit(table)
print(f"wrote {OUT_CPP}")
live = set(written)
orphans = sorted(f for f in os.listdir(AUDIO)
if f.lower().endswith(".wav") and f not in live)
print(f"wrote {len(live)} wav files; {len(orphans)} pre-existing wav(s) now unreferenced")
for o in orphans:
print(f" orphan: {o}")
if __name__ == "__main__":
main()