Audio: internals
The reference half of the audio tour: how the sound files are found, the track-swap handshake bit by bit, Sony's sequencer and SPU libraries function by function, the port's audio model, and how the port's sound is measured against a retail emulator.
Finding the sound files
Retail reads sound data by PROT.DAT entry number. A dev-station path (h:\...) survives in the same functions but is never taken on a retail disc: the split is the build-mode global _DAT_8007B8C2, and the dev arm opens files through a host trap that has no disc to read.
| Piece | What it does |
|---|---|
| Path-string cluster | The extensions the sound code appends to scene-asset paths - .spk, .LZS, .dpk, .MAP, .PCH, .pac, STR - plus the master file bse.dat. Layout on sound-driver outputs. The .MAP / .PCH / .spk / .pac SoundArtist intermediates are not present as separate retail chunks |
Sound init FUN_8001FA88 | Loads bse.dat - the battle occupant of the >= 0x200 SFX descriptor bank - at battle-scene setup; its only caller is battle init FUN_800513F0 |
Streaming loader FUN_8001FC00 | Builds paths under sound\; the XA / .pac / STR consumer and the streamer every bank install goes through |
Bank installer FUN_8001E54C | Walks the streamed chunk list: takes the header buffer and the VAB slot from the 12-byte mixer record at 0x80091508 + category*12, opens the bank (SsVabOpenHead + SsVabTransBody), and opens a type-2 chunk as the slot's score with SsSeqOpen |
Monster bank loader FUN_8003E104 | Reads monster.snd's per-monster TOC ([start_lba, end_lba + 1] pairs) and streams one bank into slot 7 or 8; also streams the victory-voice tail clips at results time |
The victory-voice pick, in the results sequencer FUN_8004E568: a pose tier from the pose actor's HP (≥ 3/4, ≥ 1/2, ≥ 1/4, below; a status in mask 0x107B forces tier 4), a pose action id from the per-character table at 0x800788A0, then a clip byte at 0x80078867 + action + char_id*8. Per-character clip bands: Vahn 0xB8..0xBC, Gala 0xBD..0xC3, Noa 0xC4..0xCB.
A third “mode-aware extension dispatcher” that turns up in call graphs is not a sound consumer at all - it is the character-mesh equipment group-transform swap (character mesh).
The track-swap handshake
The BGM poller FUN_800243F0 runs a stage counter through a seven-entry jump table while a track change is in flight: wait for CD idle and arm a 30-frame settle; tear down and close the BGM slot unless bit 0 below is set, then kick the async load; count the settle down; install the score; re-attach the slot unless bit 1 is set; latch. The sound-flag word _DAT_8007B750 coordinates it with the script:
0xA: the old score holds its slot until the script releases it, so a cutscene picks the exact beat the outgoing music dies on.| Bit | Meaning | Set by | Cleared by |
|---|---|---|---|
| 0 | script-owned start pending (defer the slot teardown) | sub-op 9 | poller commit, scene entry |
| 1 | BGM slot paused / detached | sub-op 2, 3, the dance overlay | sub-op 1, 4, 0xA, scene entry, game over |
| 2 | keep the audio across the next scene load: the scene initializer skips its music level ramp and its effect-voice cut | sub-op 6 | field overlay |
| 3 | load settled - payload staged and delay elapsed | the poller | poller commit |
| 4 | release-ack - the script has released the old occupant | sub-op 0xA | poller commit |
After setting bit 3 the poller stalls while bit 0 is set and bit 4 is not, so a swap started by sub-op 9 keeps the old track until the script commits with sub-op 0xA, which detaches and closes the old occupant, sets bit 4 and clears bit 1. A port that honours the sub-op 2 pause but drops the 0xA commit leaves the music paused after the cutscene.
The libsnd calls under the control words settle their meaning: the pause raises a status flag and keeps the cursor; the play core rewinds the cursor to the start for every mode before it plays - which is why sub-op 4 is a replay from the first event, not a resume.
Sony's sequencer and SPU driver
libsnd (0x80061B18..0x800681D8) walks the SEQ bytes, keeps per-channel state in 16 slots of 0xB0-byte records, and turns note events into key-ons; Legaia installs two function-pointer hooks to wire the script VM's sequence context in. libspu (0x80068..0x8006D) pokes the chip's registers, runs the four-mode DMA transfer engine and the block allocator. Being the same libraries every PsyQ game shipped is what lets the functions be named.
- Key-on volume chain. On the sequencer path (
FUN_80067550, in retail’s order and integer steps): the channel volume (CC7) folded into the velocity asvel × chvol / 127; velocity × bank volume × program volume × tone volume widened into the 14-bit register domain; the per-side sequence volume/ 0x7F, which the game sets to107for every BGM sequence ((215 << 15) >> 16throughFUN_80064890); one pan attenuation per source (tone, program, channel); and a closing square taperv × v / 0x3FFF. A mid-note CC7 or CC10 re-runs the whole chain for every sounding voice on the channel. Against three retail save states, every sounding BGM voice’s pitch, envelope, left / right volume and reverb send matches what the engine programs. - A sequence-volume change re-volumes the held notes.
SsSeqSetVolon a playing slot, and every step of its slide, runsFUN_80067E9C: it stores the new volume and then recomputes every voice the sequence owns through the same chain, so a note held across a summon duck comes down with it. The port does the same at the next flush. - The duck's volume is the audio level halved. Every site that turns a level into a sequence volume halves the word (
(level << 15) >> 16orsra 1), so a level of 210 plays at 105 - not the 104 a107 × level / 215scaling floors to. The library's summon states bear it out record by record. The level is applied at the top of each frame, before the battle logic steps it, so the music follows the duck one frame behind. - Monaural. The options screen’s mono mode sounds each voice at the larger of its two volumes on both sides (
0x800677F4..0x80067818), not at their average. That fold is allSsSetMonochanges: XA voice, movie audio and the reverb return stay stereo. The port's separate Mono output setting (downmix_mono) is the full downmix for a single speaker. - Pitch law. A note is pitched against its tone's
centerthrough a two-octave table into a 14-bit SPU PITCH (clamped0x3FFF). - Bank open validates the
pBAVmagic, builds the program-to-tone-page rank map into the reserved ProgAtr words, allocates SPU memory and stashes per-VAG addresses. - Tempo becomes an integer tick step where the wall clock meets the sequencer; the decoder does not consume a whole event per call. A tempo set (the open
FUN_80062410and the tempo metaFUN_80061954share one tail) stores the integer60000000 / us_per_qnas the BPM and installs a budget of tenths of a tick per call,resolution × bpm × 10 / (divisor × 60)rounded to nearest, with the divisor0x801CD2BCreading60in every catalogued save state. A track therefore runs atbudget × 6ticks a second rather than at its written tempo - 0.44 % off on average over themusic_01scores,120BPM exact,24.83BPM playing 3.4 % slow. - Not audio: the
0x801CE628cluster next door is libpad. And none of this is libgpu - battle and field code fire cues during render passes, which is why the cluster looks renderer-adjacent in call graphs.
libsnd functions
| Address | PsyQ name | Role |
|---|---|---|
FUN_80062340 | SsSeqOpen | Walks the 16-bit slot bitmap at _DAT_801CD2B8, marks the first free slot, initialises it |
FUN_80062410 | _SsSeqInit | Validates magic + version, reads PPQN base, BPM, ticks-per-quarter |
FUN_80061D18 | SsSeqClose | Key-off, clears the bitmap bit, resets the 16 channel records |
FUN_8006275C / FUN_800628F0 / FUN_800641EC | pause / play / stop | Flag edits on the per-slot status word at +0x98: the pause raises 0x2 and keeps the cursor; the play core rewinds the cursor for every mode, then plays; the stop kills the notes and rewinds |
FUN_80061C68 | _SsSeqGetVar | Varint delta-time decode into the running tick at +0x88 |
FUN_80061EDC / FUN_8006206C | SsSeqSetVol / _SsSetSlideVolume | Volume fetch and N-tick ramp |
FUN_80067550 | _SsVoNoteOn | The key-on volume chain |
FUN_80067E9C | _SsSeqNoteOn | Sequence-driven key-on; calls the program-change |
FUN_80065978 | _SsVoKeyOnDirect | Voice allocation, region lookup, pitch + base note |
FUN_80066E50 / FUN_8006C6E4 | _SsPitchFromKey / _SsKey2Pitch | 12-entry key table at _DAT_8007A940; two-octave table |
FUN_80068B98 | VAB program-change | Installs the current-bank globals, reads the packed tone-page index |
FUN_80068D94 | SsVabOpenHead | Bank open (see above) |
FUN_800684CC | Key-off by owner | Keys off every 0x36-stride voice record at 0x801CDB60 whose owner key equals the argument (seq | track << 8) - not a VAB-id search |
FUN_8006CA7C / FUN_8006CDB0 / FUN_8006DDC8 | SsSeqGetStatus / SetCallback / SetMarkCallback | Resolve the context through the Legaia hook _DAT_801CE564 |
FUN_80062AA0 / FUN_80065440 | SPU command shims | ×0x81 scaling, 0..127 to 0..16383 |
libspu globals, primitives and the transfer engine
| Address | PsyQ name | Role |
|---|---|---|
_DAT_8007AF40 | SPU register base | MMIO 0x1F801C00; +0x180 main volume, +0x1AA SPUCNT, +0x184 reverb output volume, +0x1B0 CD input volume, +0x1C0..0x1FE reverb block |
_DAT_8007AFA4 / _DAT_8007AFF8 | Block table / master attribute struct | Free bit 0x80000000, end bit 0x40000000; 10 modes × 0x44 |
FUN_8006ACBC / FUN_8006C048 | SpuSetVoiceAttr | Mask-driven dispatcher; 24-voice broadcaster over 1 << i |
FUN_8006BCB4 | SpuSetCommonAttr | Master volume, reverb registers, SPUCNT bits |
FUN_8006B1B4 | SpuSetReverbModeParam | 30-attribute reverb commit |
FUN_80069B18 | _spu_t | Four-mode transfer state machine (arm read, arm write, stage address, commit); 0xF00-poll timeout |
FUN_800697E0 / FUN_80069DA8 | _SpuTransfer / SpuWrite | CPU-copy path in 0x40-byte chunks; DMA-vs-CPU pick |
FUN_8006A158 / FUN_8006A728 / FUN_8006A420 | SsSpuMalloc / SpuFree / compactor | First-fit walk, high-bit free, coalesce |
An earlier “SsSepOpen / VAP SEP loader” reading of the bank open is falsified - the magic is the VAB's. See do-not-re-walk.
The port's audio model
crates/engine-audio is a from-scratch model; engine-session's AudioDirector turns field-VM sound events into sequencers and fires SFX cues against the banks their category names, generic over the output so the native window (cpal) and the browser (WebAudio) share it.
| Module | Models |
|---|---|
spu::Spu, spu::voice | 24 voices with sample address, loop point, pitch, ADSR, L/R volume and reverb send; 4-point Gaussian resampling as the hardware does |
spu::adsr, spu::adpcm | Five-phase envelope with linear / exponential modes; streaming 28-samples-per-block decoder, one per voice |
spu::reverb | Register-driven network (same / different-side IIR, 4-tap comb, 2 all-pass) with the nine standard presets |
spu::ram | 512 KB model, libspu-shaped transfer engine, block allocator |
vab_bind::VabBank | Uploads every VAG into SPU RAM; turns a MIDI key into voice config + key-on. Programs are slot-indexed |
Sequencer | The SsSeqOpen / Play / Close / SetVol / Rewind surface; round-robin voice allocation tracking (channel, key); pitch bend against per-tone limits; loop markers from CC99 (20 = start, 30 = forever) |
sfx | The cue catalog built from the disc's descriptor table, fired against the bank each cue's category names, through a frame-accurate scheduler |
AudioOut / WebAudioOut | One output stream draining the SPU at 44.1 kHz; hard-cut track swaps with a few frames of click guard; CD-XA clips mixed outside the 24 voices |
- Reverb is Studio C, always on, on every voice. Every captured retail state - field, town, battle, summon, title, minigames - has the master enable set and the reverb registers byte-identical to the Studio C preset, and each voice's send set by the tone it was keyed from (
mode & 4). The output depth, which no preset carries, is a measured fixed pair. No SEQ carries a CC91 reverb-depth event. - Timebase. The sequencer ticks once per SPU sample with an exact integer accumulator - locked to the audio clock, no drift, bit-deterministic for replays. It spaces events at retail's quantised rate (above) by default;
Sequencer::set_exact_tempo(true)plays the score as written. Retail fires an event at the firstSsSeqCalcafter it falls due and writes its key-on at that call's flush, so every onset lands on a vsync; the port runs the same pump, spending one vsync's budget per flush period, re-reading it after every event so a tempo meta retunes the vsync it lands in, and carrying the overshoot. With the pump, two measured tracks match every key-on of their retail census; firing each event on its own sample instead moves notes near a vsync boundary into the neighbouring flush after any tempo change. A track whose header tempo is its only tempo plays at the header's rounded BPM, where a tempo meta truncates. The SEQ header tempo is a 240 BPM placeholder overridden by the first body tempo event, which carries no MIDI length byte; mis-parsing it pins playback ~3x fast (seq). - XA comes in through the CD input, and two minigames reverb it. Voice lines, announcer lines and movie audio reach the mix through the SPU’s CD input rather than a voice, at the volume the game sets there (one step under full scale while a line plays, muted between lines). The CD input’s own reverb send is off everywhere except the Baka Fighter duel and the Muscle Dome hub: their overlays turn it on at entry and off as a dome fight starts or the minigame exits, so the announcer rings through the room and the fight does not. The port follows the same switch.
- Not modelled: pitch modulation, noise and FM (Legaia uses none), and asynchronous DMA timing. The reverb network runs behind the hardware's 39-tap half-band FIR on both sides, so send content above 11 kHz is filtered out before the 22 kHz tank instead of folding back into it.
Measuring the port against retail
The audio-trace oracle compares which SPU voices are active, frame by frame, against a real emulator running the retail disc. Two retail capture shapes feed it: a single-cycle snapshot lifted from a mednafen save state's SPU section, and a multi-frame trace captured in PCSX-Redux. A retail frame converges when some engine frame's active-voice set covers it with every start address matching; a start-address or master-volume mismatch is a hard failure, and so is an engine trace that sounded nothing where retail had voices. CLI: legaia-engine audio-trace.
- A save state cannot say which voices belong to the score. A voice leaves its off phase on key-on and returns only when a release runs all the way down, so a save taken mid-playthrough reports nearly every voice non-off - the residue of every cue since boot. Deciding which voices the music is sounding needs the per-vsync trace.
- Compare the same track over the same stretch. An engine trace plays the track the scene's entry script selects; a retail save plays whatever the playthrough left loaded. Paired on one track and window, the two sides hold the same number of concurrent voices - nineteen at the peak of the measured track, which is exactly what its score decodes to.
- Every sounding note is one the port programs. Across the library's mednafen states - field, town, battle, summon, minigame, menu, ending - each sounding sequence voice's pitch, envelope words, left and right volume and reverb send is one the engine programs for the same track at the same sequence volume. The track, the volume and the mono setting are read off the state itself, not off its scene.
- Count key-ons, not envelope edges. A breakpoint census of every retail key-on matches the engine note for note on the measured field, battle, title and minigame states; the envelope a per-vsync capture records runs on the emulator's audio thread, not the emulated clock.
History: “the engine is much the quieter side”
The comparison once read as the engine routing no reverb and sounding about half retail's voices. Both were instrument defects: the reverb figure was read from the SPU control register rather than the per-voice reverb-enable mask, and the oracle had built a bare SPU where the shipped host sets retail reverb. The voice-count gap was two different pieces of music - a town save reached from the world map still holds the overworld track.
How we know
| Function | Address | What it proves |
|---|---|---|
| Sound init | FUN_8001FA88 | bse.dat at battle init (caller FUN_800513F0), dev / retail split on _DAT_8007B8C2 |
| Streaming loader | FUN_8001FC00 | The sound\ path builder every bank install goes through |
| Bank installer | FUN_8001E54C / FUN_8002630C | Category to mixer record to slot; open through SsVabOpenHead |
| Record initialiser / SPU bases | FUN_8001D424 / FUN_800265E8 | Twelve records, four pairs sharing a header and a base |
| Monster bank loader | FUN_8003E104 | monster.snd TOC and LBA range; slots 7 / 8 |
| Per-actor SFX helper | FUN_800250D4 / FUN_80065034 | Descriptor decode; program-change before program lookup |
| BGM poller | FUN_800243F0 | Resolver arithmetic, the seven-stage swap ladder, sound-flag bits |
Op 0x35 | FUN_801DE840 case 0x35 | Sub-op semantics 1 / 2 / 3 / 4 / 6 / 9 / 0xA (script VM) |
| Arts shout selector | FUN_8004C140 / FUN_8003D53C | Channel pool pick; CD-XA clip player; confirmed by PCSX-Redux call-site traces |
| Melee roll pair | FUN_801EC3E4 | The blocked-strike grunt (gated on the defender's +0x1F3 block entry) and the 0x10C impact cue |
| Victory spoils | FUN_8004E568 | Pose tier, clip byte, reward bank slot 11 |
| Reverb | SPU register file | Studio C in every captured state |
Full write-up: docs/subsystems/audio.md.