XA-ADPCM streams Confirmed
The voices in the opening movie, the sung theme, the streamed music under a cutscene, and the shout each character gives when an Art lands - none of that is played from the sound chip's sample memory. It is read straight off the CD, sector by sector, in the PlayStation's streaming audio format, CD-XA. Each .XA file on the disc carries several tracks braided together, compressed with a small 4-bits-per-sample codec (ADPCM - adaptive differential PCM). This page covers the sector format, the bit-exact decoder, and how to demux every track into WAV files.
At a glance
- In the game
- Cutscene dialogue and FMV audio, streamed music tracks, and the per-character Art shouts in battle (Vahn's file is
XA2, Noa'sXA4, Gala'sXA6). - Magic / marker
- No file magic - per-sector CD-XA subheader with submode
AUDIO | FORM2(0x04 | 0x20) - Lives in
- Top-level disc files
XA1.XA,XA2.XA, ... (one or more per release) - not inside PROT.DAT; each file multiplexes up to 16 channels keyed by(file_no, ch_no) - Parser
crates/xa- ADPCM decoder (lib.rs), sector demuxer (demux.rs), WAV export; CLIxa demux-disc-all- Confidence
- Confirmed - the decoder is bit-exact, sample for sample, against an external lossless reference decode of a real cutscene track (disc-gated oracle
xa_pcm_matches_reference).
Sector layout
A CD stores 2352 bytes per sector. Data files use 2048 of them and spend the rest on error correction; streaming audio (Mode 2 Form 2) trades most of that correction away for 276 more bytes of payload per sector, because a click in an audio stream matters less than a corrupted byte in a program. Each on-disc XA sector is the standard PSX raw layout:
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x000 | 12 | sync | 00 + 10 x FF + 00 |
+0x00C | 4 | header | MM SS FF mode (mode 2) |
+0x010 | 8 | CD-XA subheader | file_no, ch_no, submode, coding_info + a duplicated copy |
+0x018 | 2304 | user data | 18 sound groups x 128 B of ADPCM audio; the trailing 0x14 bytes of the 2324-byte Form 2 payload are padding |
+0x92C | 4 | EDC | Error-detection code |
Subheader bits
Submode bits relevant for audio detection:
| bit | meaning |
|---|---|
0x04 | AUDIO |
0x20 | FORM2 |
Coding-info bits:
| bits | meaning |
|---|---|
0 (0x01) | stereo (vs mono) |
| 2..=3 | sample rate (00 = 37.8 kHz, 01 = 18.9 kHz) |
| 4..=5 | bits/sample (00 = 4-bit, 01 = 8-bit) |
The 18 sound groups inside the user data are 128-byte CD-XA ADPCM blocks; see the lib doc-comment for the per-block layout (8 sound units, 28 lines x 4 bytes).
Demuxing: getting the tracks out
Each .XA file is really many audio channels multiplexed together, one channel per sector, tagged by the per-sector (file_no, ch_no) subheader. crates/xa/src/demux.rs (demux_disc_range) reads raw 2352-byte sectors, parses each subheader, filters to AUDIO + FORM2, and splits the audio data into one buffer per (file_no, ch_no) tuple. After that step the per-channel buffer is a clean concatenation of standard 128-byte sound groups that the ADPCM decoder handles directly.
The xa demux-disc-all subcommand drives this across the whole disc - it walks the ISO9660 tree, finds every *.XA, and demuxes each at its own per-sector sample rate / channel mode read from the subheaders (no guessed global rate). This is the “get the voices and streamed music out” command:
./target/release/xa demux-disc-all \
"/path/to/Legend of Legaia (USA).bin" \
--out extracted/xa_demux
One WAV lands per (file_no, ch_no) channel under extracted/xa_demux/, named <xa-stem>_fileN_chM.wav. The single-file xa demux-disc --lba --size variant remains for targeting one entry by directory offset. legaia-extract runs the demux automatically and writes the WAVs to extracted/XA_WAV/.
Pacing is data-driven per track - the whole point. A track that varies channel mode (the NA disc has 16-channel mono voice files like XA4/XA6 alongside 8-channel stereo music like XA5/XA7/XA8/XA9) decodes each channel at its real width. Channels report their width via coding_info; the decoder handles both 4-bit and 8-bit, and any other (unexpected) width is skipped with a warning rather than mis-decoded.
History: the "non-standard interleave" reading
The earliest extracted-XA tooling truncated each on-disc sector to 2048 bytes (Form 1 mode), which silently dropped 276 bytes per sector of audio and collapsed every channel into a single shuffled byte sequence, because the per-sector (file_no, ch_no) subheader was discarded. Only ~10 % of 128-byte sound groups in that output passed the standard XA rule bytes 8..16 == bytes 0..8. That is all the "non-standard interleave" shorthand of earlier doc revisions amounted to - not a bespoke Legaia muxing scheme, just Form-1-truncation damage. The same truncation made the old convert path read a stereo track as mono and play it at 2x speed. The extracted/XA/*.XA files the disc-extract step still copies are those Form-1-truncated bytes - usable for byte-stable hashing only, never for decoding. More retired readings: do not re-walk.
Sound-group decode (4-bit)
Each 128-byte sound group holds 8 sound units of 28 samples. The decode is bit-exact against an external lossless reference decode of a real cutscene track (every interleaved sample matches), so the layout below is confirmed, not inferred.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0 | 16 | parameter bytes | One (filter << 4) | range byte per sound unit, filter 0..=3, range 0..=12, with the redundant copy interleaved within each half (layout below) |
+16 | 112 | sample nibbles | 28 lines x 4 bytes; unit u reads byte u / 2 of each line, low nibble for even u, high nibble for odd |
Parameter bytes (0..16). The redundant copy is interleaved within each half, not appended:
byte: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
unit: p0 p1 p2 p3 p0 p1 p2 p3 p4 p5 p6 p7 p4 p5 p6 p7
So unit u's parameter byte is at u + (if u < 4 { 0 } else { 4 }). Reading bytes 0..8 as eight sequential params - the "appended mirror" reading - mis-assigns the parameters of units 4..7 and is the classic CD-XA decode trap.
Sample nibbles (16..128). 28 lines of 4 bytes:
line byte: 0 1 2 3
nibble: lo=unit0 lo=unit2 lo=unit4 lo=unit6
hi=unit1 hi=unit3 hi=unit5 hi=unit7
Per-sample reconstruction (filter coefficients in 1/64 units, k0 = {0, 0.9375, 1.796875, 1.53125}, k1 = {0, 0, -0.8125, -0.859375}):
shifted = (sign_extend(nibble, 4) << 12) >> range
value = shifted + k0 * prev1 + k1 * prev2
output = clip16(round_half_away_from_zero(value))
prev2 = prev1; prev1 = value // history is the UNCLAMPED, UNROUNDED value
The predictor history is the full-precision reconstructed value, not the rounded+clamped 16-bit output. Re-feeding the clamped output instead is audible only at high volume - the prediction drifts on loud sound-units and rails to the opposite extreme - which is exactly the symptom that bit-exact history feedback removes.
Stereo de-interleave. The LEFT channel is the even units (0,2,4,6) and the RIGHT channel is the odd units (1,3,5,7); output is L,R interleaved, pairing (0,1),(2,3),(4,5),(6,7). Each channel keeps its own (prev1, prev2) history.
Sound-group decode (8-bit)
The 8-bit mode (subheader coding_info bits 4..5 = 01) uses the same 128-byte group but packs 4 sound units of 28 full-byte samples instead of 8 nibble units:
- Parameter bytes (0..16). The 4 unit params live at bytes 0..4, mirrored three more times at 4..8 / 8..12 / 12..16; the live copy is bytes 0..4. Same
(filter << 4) | rangeencoding. - Sample bytes (16..128). 28 lines of 4 bytes; unit
ureads byteuof each line (one full signed 8-bit sample per byte,0x80= -128). - Reconstruction. Identical filter math, but the sample sits in the top byte of the 16-bit word before the gain shift:
shifted = (sign_extend(byte, 8) << 8) >> range. - Stereo de-interleave. LEFT = even units (0,2), RIGHT = odd units (1,3).
This yields 112 samples/group vs the 4-bit path's 224. Select it with legaia_xa::DecodeOptions { bits: BitsPerSample::Eight, .. } (the demux path maps each channel's reported width automatically; the CLI exposes --bits 8). The whole NA corpus is 4-bit, so 4-bit is the default and the 8-bit path is exercised by synthetic unit tests.
What's still open
- 8-bit ADPCM verification. The NA corpus is entirely 4-bit, 37.8 kHz (
demux-disc-allreportsbits_per_sample = 4for every channel of every*.XAfile), so nothing on the NA disc exercises 8-bit - it is covered by synthetic unit tests, not verified bit-exact against a real 8-bit source. A JP/EU build that uses 8-bit would decode without code changes. - Per-cutscene file-no / ch-no map.
demux-discemits one WAV per channel keyed by(file_no, ch_no). The mapping from cutscene name to expected channel pair lives inside the cutscene-overlay's mode driver, which is not yet captured. Until that is reversed, the WAV-to-cutscene assignment is manual. The Art-shout side is better pinned:crates/art'sarts_voicetables give each character's file and candidate-channel pool.
How we know
| Subject | Source |
|---|---|
| Mode 2 / Form 2 sector layout | PSX BIOS docs + legaia-iso::raw |
| Subheader interpretation | crates/xa/src/demux.rs |
| 4-bit ADPCM filter coefficients | crates/xa/src/lib.rs |
| Sound-group decode (param + nibble layout, predictor) | bit-exact, sample-for-sample, against an external lossless reference decode of a real cutscene track; pinned by the disc-gated xa_pcm_matches_reference oracle in crates/xa/tests/pcm_reference.rs. |
| Form-1-truncation diagnosis | direct comparison: 90 % of 128-byte groups in the Form-1-truncated extracted/XA/*.XA bytes fail the bytes 8..16 == bytes 0..8 invariant. |