parse_riff_wave read every fmt chunk as a WAVEFORMATEX. XMA1 (tag 0x0165) is not one, so audio info reported the disc s movie voices as 16 channels, 4310 Hz, 2-bit: 16 is wBitsPerSample read as a channel count and 4310 is wEncodeOptions (0x10d6) read as a sample rate. This misled me earlier in the session and I recorded it as a limitation before finding the cause. XMA1 carries XMAWAVEFORMAT followed by one XMASTREAMFORMAT per stream. The reader now branches on the tag and reads bits at +2, PsuedoBytesPerSec at +12, SampleRate at +16 and Channels at +29. The same three files now report 2 channels, 48000 Hz, 16-bit. The consequence worth having: this crate has no XMA decoder, and data_bytes / PsuedoBytesPerSec is the only route to a duration. Checked against durations decoded independently by the port: ADV presentation 1 137.34 s declared 137.324 s decoded +0.012 percent ADV presentation 2 137.33 s declared 137.324 s decoded +0.004 percent S00A presentation 1 93.71 s declared 93.694 s decoded +0.017 percent So the corpus can now get XMA1 durations off the disc without a decoder, which is a capability I had written down as absent. It is a declared rate rather than a measurement of the samples, and the CLI labels it as such. Regression test pins the real on-disc header bytes and asserts the duration against the independently decoded 137.324 s. 115 lib tests and 3 media disc tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
501 lines
20 KiB
Rust
501 lines
20 KiB
Rust
//! Audio format parsing — WAV/PCM decode + Xbox 360 XMA/XMA2 recognition.
|
|
//!
|
|
//! ## What the game actually ships
|
|
//! Project Sylpheed's in-game audio lives in `dat/sound.pak` (IPFB): ~9500
|
|
//! entries of **raw, header-less XMA2** stream data — no per-entry `RIFF`/`fmt `
|
|
//! header, no compression wrapper (identical sound effects are byte-for-byte
|
|
//! duplicate entries). The per-stream format (channel count, sample rate, loop
|
|
//! points) is therefore **not** in the stream data; it lives in a separate
|
|
//! sound-bank descriptor that has not been reverse engineered yet. So this
|
|
//! module can *identify* those streams (and count their XMA packets) but cannot
|
|
//! yet decode them to PCM — that needs (a) the bank descriptor and (b) an XMA2
|
|
//! decoder.
|
|
//!
|
|
//! ## What this module does today
|
|
//! - Fully parses **RIFF/WAVE PCM** (8/16/24-bit int, 32-bit float) → `GameAudio`
|
|
//! (interleaved `f32`), ready for playback/export of any converted audio.
|
|
//! - Reads metadata from **RIFF/WAVE XMA (`0x0165`) / XMA2 (`0x0166`)** headers
|
|
//! (channels, sample rate) — decode still unsupported.
|
|
//! - Recognizes **raw XMA2** stream blobs by entropy + packet alignment and
|
|
//! reports the 2048-byte packet count.
|
|
//!
|
|
//! XMA framing constants are from xenia-canary `src/xenia/apu/xma_context.h`
|
|
//! (`kBytesPerPacket = 2048`, `kSamplesPerFrame = 512`, `kBytesPerSample = 2`).
|
|
|
|
use thiserror::Error;
|
|
|
|
// XMA framing (xenia-canary xma_context.h).
|
|
/// One XMA2 packet is 2048 bytes (4-byte header + 2044 bytes of frame data).
|
|
pub const XMA_BYTES_PER_PACKET: usize = 2048;
|
|
/// Decoded PCM samples produced per XMA frame, per channel.
|
|
pub const XMA_SAMPLES_PER_FRAME: u32 = 512;
|
|
|
|
// WAVE format tags.
|
|
const WAVE_FORMAT_PCM: u16 = 0x0001;
|
|
const WAVE_FORMAT_IEEE_FLOAT: u16 = 0x0003;
|
|
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
|
const WAVE_FORMAT_XMA: u16 = 0x0165;
|
|
const WAVE_FORMAT_XMA2: u16 = 0x0166;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum AudioError {
|
|
#[error("not a recognized audio container")]
|
|
Unrecognized,
|
|
#[error("malformed audio: {0}")]
|
|
Malformed(&'static str),
|
|
#[error("codec needs a decoder this crate does not provide: {0:?}")]
|
|
NeedsDecoder(AudioCodec),
|
|
#[error("unsupported PCM sample format: tag {tag:#06x}, {bits} bits")]
|
|
UnsupportedPcm { tag: u16, bits: u16 },
|
|
}
|
|
|
|
/// Recognized audio container / codec.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AudioCodec {
|
|
/// RIFF/WAVE integer PCM (`WAVE_FORMAT_PCM`).
|
|
Pcm,
|
|
/// RIFF/WAVE IEEE-float PCM (`WAVE_FORMAT_IEEE_FLOAT`).
|
|
PcmFloat,
|
|
/// RIFF/WAVE XMA (`0x0165`).
|
|
Xma,
|
|
/// RIFF/WAVE XMA2 (`0x0166`).
|
|
Xma2,
|
|
/// Header-less XMA2 stream (the game's `sound.pak` entries), identified
|
|
/// heuristically — no embedded channel/rate metadata.
|
|
RawXma2,
|
|
Unknown,
|
|
}
|
|
|
|
impl AudioCodec {
|
|
/// Does decoding this codec require support this crate does not (yet) have?
|
|
pub fn needs_decoder(&self) -> bool {
|
|
matches!(self, Self::Xma | Self::Xma2 | Self::RawXma2)
|
|
}
|
|
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
Self::Pcm => "PCM",
|
|
Self::PcmFloat => "PCM float",
|
|
Self::Xma => "XMA (RIFF)",
|
|
Self::Xma2 => "XMA2 (RIFF)",
|
|
Self::RawXma2 => "XMA2 (raw stream)",
|
|
Self::Unknown => "unknown",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Non-decoding metadata describing an audio blob.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AudioInfo {
|
|
pub codec: AudioCodec,
|
|
pub channels: Option<u16>,
|
|
pub sample_rate: Option<u32>,
|
|
pub bits_per_sample: Option<u16>,
|
|
/// PCM samples per channel, when derivable (WAV only).
|
|
pub samples_per_channel: Option<u64>,
|
|
pub duration_secs: Option<f32>,
|
|
pub size_bytes: usize,
|
|
/// 2048-byte XMA packet count, for XMA/raw-XMA streams.
|
|
pub xma_packets: Option<u32>,
|
|
/// The stream's **declared** average bytes per second.
|
|
///
|
|
/// For XMA1 this is `XMASTREAMFORMAT::PsuedoBytesPerSec`. It is what makes a
|
|
/// duration available for a codec we cannot decode: `data_bytes / this`
|
|
/// agreed with an independently decoded duration to **0.01 %** on the two
|
|
/// movie voices it was checked against
|
|
/// (`docs/re/structures/voice-region-leading-chunk.md`).
|
|
pub avg_bytes_per_sec: Option<u32>,
|
|
}
|
|
|
|
impl AudioInfo {
|
|
fn empty(codec: AudioCodec, size: usize) -> Self {
|
|
Self {
|
|
codec,
|
|
channels: None,
|
|
sample_rate: None,
|
|
bits_per_sample: None,
|
|
samples_per_channel: None,
|
|
duration_secs: None,
|
|
size_bytes: size,
|
|
xma_packets: None,
|
|
avg_bytes_per_sec: None,
|
|
}
|
|
}
|
|
|
|
/// Best-effort classification of an audio blob. Never fails: unrecognized
|
|
/// input yields [`AudioCodec::Unknown`].
|
|
///
|
|
/// Order matters: a `RIFF/WAVE` header is authoritative; only header-less,
|
|
/// high-entropy, packet-sized blobs fall through to the raw-XMA2 heuristic.
|
|
pub fn probe(bytes: &[u8]) -> Self {
|
|
if let Some(info) = parse_riff_wave(bytes) {
|
|
return info;
|
|
}
|
|
if looks_like_raw_xma2(bytes) {
|
|
let mut info = Self::empty(AudioCodec::RawXma2, bytes.len());
|
|
info.xma_packets = Some(bytes.len().div_ceil(XMA_BYTES_PER_PACKET) as u32);
|
|
return info;
|
|
}
|
|
Self::empty(AudioCodec::Unknown, bytes.len())
|
|
}
|
|
}
|
|
|
|
/// True when `bytes` is likely a raw, header-less XMA2 stream: no known audio
|
|
/// magic, but a large, near-incompressible (high-entropy) payload — the shape
|
|
/// of the game's `sound.pak` audio entries. Heuristic: not conclusive, but the
|
|
/// probe runs only after every structured format has been ruled out.
|
|
pub fn looks_like_raw_xma2(bytes: &[u8]) -> bool {
|
|
// XMA streams are always at least a couple of packets long.
|
|
if bytes.len() < XMA_BYTES_PER_PACKET * 2 {
|
|
return false;
|
|
}
|
|
if bytes.starts_with(b"RIFF") || bytes.starts_with(b"XMA2") {
|
|
return false; // handled by the RIFF path
|
|
}
|
|
shannon_entropy_bits(&bytes[..bytes.len().min(64 * 1024)]) >= 7.8
|
|
}
|
|
|
|
/// Shannon entropy in bits/byte over `data` (0.0..=8.0). Compressed audio sits
|
|
/// very close to 8; structured/text data is much lower.
|
|
fn shannon_entropy_bits(data: &[u8]) -> f64 {
|
|
if data.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let mut counts = [0u32; 256];
|
|
for &b in data {
|
|
counts[b as usize] += 1;
|
|
}
|
|
let n = data.len() as f64;
|
|
counts
|
|
.iter()
|
|
.filter(|&&c| c > 0)
|
|
.map(|&c| {
|
|
let p = c as f64 / n;
|
|
-p * p.log2()
|
|
})
|
|
.sum()
|
|
}
|
|
|
|
// ── RIFF/WAVE ──────────────────────────────────────────────────────────────────
|
|
|
|
/// Parse a RIFF/WAVE header for metadata. Returns `None` if `bytes` is not a
|
|
/// `RIFF....WAVE` container. All RIFF fields are little-endian.
|
|
fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
|
if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
|
|
return None;
|
|
}
|
|
let le16 = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]);
|
|
let le32 =
|
|
|o: usize| u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
|
|
|
|
let mut pos = 12;
|
|
let (mut tag, mut channels, mut rate, mut bits) = (0u16, 0u16, 0u32, 0u16);
|
|
let mut avg_bps = 0u32;
|
|
let mut data_bytes: Option<u64> = None;
|
|
let mut have_fmt = false;
|
|
|
|
while pos + 8 <= bytes.len() {
|
|
let id = &bytes[pos..pos + 4];
|
|
let size = le32(pos + 4) as usize;
|
|
let body = pos + 8;
|
|
match id {
|
|
b"fmt " if body + 16 <= bytes.len() => {
|
|
tag = le16(body);
|
|
// 🔴 XMA1 is NOT a WAVEFORMATEX. Reading it as one is where
|
|
// `audio info` got "16 channels, 4310 Hz, 2-bit" from the
|
|
// movie voices: 16 is `wBitsPerSample` read as channels, and
|
|
// 4310 is `wEncodeOptions` (0x10d6) read as a sample rate.
|
|
//
|
|
// XMA1 carries `XMAWAVEFORMAT`, then one `XMASTREAMFORMAT` per
|
|
// stream (xenia-canary `src/xenia/apu/xma_context.h`,
|
|
// cross-checked against the disc's own movie-voice headers):
|
|
//
|
|
// +0 wFormatTag +2 wBitsPerSample +4 wEncodeOptions
|
|
// +6 wLargestSkip +8 wNumStreams +10 bLoopCount (u8)
|
|
// +11 bStreamCount (u8)
|
|
// +12 PsuedoBytesPerSec +16 SampleRate +20 LoopStart
|
|
// +24 LoopEnd +28 SubframeData (u8) +29 Channels (u8)
|
|
// +30 ChannelMask
|
|
if tag == WAVE_FORMAT_XMA && body + 32 <= bytes.len() {
|
|
bits = le16(body + 2);
|
|
avg_bps = le32(body + 12);
|
|
rate = le32(body + 16);
|
|
channels = bytes[body + 29] as u16;
|
|
} else {
|
|
channels = le16(body + 2);
|
|
rate = le32(body + 4);
|
|
bits = le16(body + 14);
|
|
avg_bps = le32(body + 8);
|
|
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's
|
|
// first two bytes, right after cbSize (+2) → +24 from the
|
|
// fmt body.
|
|
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
|
|
tag = le16(body + 24);
|
|
}
|
|
}
|
|
have_fmt = true;
|
|
}
|
|
b"data" => data_bytes = Some(size as u64),
|
|
_ => {}
|
|
}
|
|
// Chunks are word-aligned (pad byte when size is odd).
|
|
pos = body + size + (size & 1);
|
|
}
|
|
if !have_fmt {
|
|
return None;
|
|
}
|
|
|
|
let codec = match tag {
|
|
WAVE_FORMAT_PCM => AudioCodec::Pcm,
|
|
WAVE_FORMAT_IEEE_FLOAT => AudioCodec::PcmFloat,
|
|
WAVE_FORMAT_XMA => AudioCodec::Xma,
|
|
WAVE_FORMAT_XMA2 => AudioCodec::Xma2,
|
|
_ => AudioCodec::Unknown,
|
|
};
|
|
let mut info = AudioInfo::empty(codec, bytes.len());
|
|
info.channels = Some(channels).filter(|&c| c > 0);
|
|
info.sample_rate = Some(rate).filter(|&r| r > 0);
|
|
info.bits_per_sample = Some(bits).filter(|&b| b > 0);
|
|
info.avg_bytes_per_sec = Some(avg_bps).filter(|&b| b > 0);
|
|
|
|
// A declared byte rate gives a duration for a codec we cannot decode. Only
|
|
// for XMA1, where the field is `PsuedoBytesPerSec` and means exactly this.
|
|
if codec == AudioCodec::Xma && avg_bps > 0 {
|
|
if let Some(d) = data_bytes {
|
|
info.duration_secs = Some(d as f32 / avg_bps as f32);
|
|
}
|
|
}
|
|
|
|
match codec {
|
|
AudioCodec::Pcm | AudioCodec::PcmFloat => {
|
|
if let (Some(d), true) = (data_bytes, channels > 0 && bits > 0) {
|
|
let frame = channels as u64 * (bits as u64 / 8);
|
|
if frame > 0 {
|
|
let spc = d / frame;
|
|
info.samples_per_channel = Some(spc);
|
|
if rate > 0 {
|
|
info.duration_secs = Some(spc as f32 / rate as f32);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AudioCodec::Xma | AudioCodec::Xma2 => {
|
|
if let Some(d) = data_bytes {
|
|
info.xma_packets = Some((d / XMA_BYTES_PER_PACKET as u64) as u32);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Some(info)
|
|
}
|
|
|
|
// ── PCM decode → GameAudio ─────────────────────────────────────────────────────
|
|
|
|
/// An audio clip decoded to raw interleaved `f32` PCM (`L R L R …`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct GameAudio {
|
|
pub samples: Vec<f32>,
|
|
pub channels: u16,
|
|
pub sample_rate: u32,
|
|
}
|
|
|
|
impl GameAudio {
|
|
/// Decode a RIFF/WAVE **PCM** file (int 8/16/24-bit or 32-bit float) to
|
|
/// interleaved `f32`. XMA/XMA2 return [`AudioError::NeedsDecoder`].
|
|
pub fn from_wav(bytes: &[u8]) -> Result<Self, AudioError> {
|
|
let info = parse_riff_wave(bytes).ok_or(AudioError::Unrecognized)?;
|
|
if info.codec.needs_decoder() {
|
|
return Err(AudioError::NeedsDecoder(info.codec));
|
|
}
|
|
let channels = info.channels.ok_or(AudioError::Malformed("no channels"))?;
|
|
let rate = info.sample_rate.ok_or(AudioError::Malformed("no sample rate"))?;
|
|
let bits = info.bits_per_sample.ok_or(AudioError::Malformed("no bit depth"))?;
|
|
|
|
// Locate the `data` chunk body.
|
|
let (off, len) = riff_data_span(bytes).ok_or(AudioError::Malformed("no data chunk"))?;
|
|
let data = &bytes[off..off + len];
|
|
|
|
let samples: Vec<f32> = match (info.codec, bits) {
|
|
(AudioCodec::Pcm, 8) => data.iter().map(|&b| (b as f32 - 128.0) / 128.0).collect(),
|
|
(AudioCodec::Pcm, 16) => data
|
|
.chunks_exact(2)
|
|
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
|
|
.collect(),
|
|
(AudioCodec::Pcm, 24) => data
|
|
.chunks_exact(3)
|
|
.map(|c| {
|
|
let v = ((c[2] as i32) << 16) | ((c[1] as i32) << 8) | c[0] as i32;
|
|
let v = (v << 8) >> 8; // sign-extend 24→32
|
|
v as f32 / 8_388_608.0
|
|
})
|
|
.collect(),
|
|
(AudioCodec::PcmFloat, 32) => data
|
|
.chunks_exact(4)
|
|
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
|
.collect(),
|
|
_ => return Err(AudioError::UnsupportedPcm { tag: 0, bits }),
|
|
};
|
|
Ok(Self { samples, channels, sample_rate: rate })
|
|
}
|
|
}
|
|
|
|
/// Byte span (offset, length) of the WAVE `data` chunk body, if present.
|
|
fn riff_data_span(bytes: &[u8]) -> Option<(usize, usize)> {
|
|
if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
|
|
return None;
|
|
}
|
|
let le32 =
|
|
|o: usize| u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]) as usize;
|
|
let mut pos = 12;
|
|
while pos + 8 <= bytes.len() {
|
|
let size = le32(pos + 4);
|
|
let body = pos + 8;
|
|
if &bytes[pos..pos + 4] == b"data" {
|
|
let len = size.min(bytes.len().saturating_sub(body));
|
|
return Some((body, len));
|
|
}
|
|
pos = body + size + (size & 1);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Build a minimal 16-bit PCM WAV in memory (stereo, 2 frames).
|
|
fn tiny_wav() -> Vec<u8> {
|
|
let mut v = Vec::new();
|
|
let data: [i16; 4] = [1000, -1000, 32767, -32768]; // L R L R
|
|
let data_bytes: Vec<u8> = data.iter().flat_map(|s| s.to_le_bytes()).collect();
|
|
v.extend_from_slice(b"RIFF");
|
|
v.extend_from_slice(&(36 + data_bytes.len() as u32).to_le_bytes());
|
|
v.extend_from_slice(b"WAVE");
|
|
v.extend_from_slice(b"fmt ");
|
|
v.extend_from_slice(&16u32.to_le_bytes());
|
|
v.extend_from_slice(&WAVE_FORMAT_PCM.to_le_bytes());
|
|
v.extend_from_slice(&2u16.to_le_bytes()); // channels
|
|
v.extend_from_slice(&48000u32.to_le_bytes());
|
|
v.extend_from_slice(&(48000u32 * 2 * 2).to_le_bytes());
|
|
v.extend_from_slice(&4u16.to_le_bytes()); // block align
|
|
v.extend_from_slice(&16u16.to_le_bytes()); // bits
|
|
v.extend_from_slice(b"data");
|
|
v.extend_from_slice(&(data_bytes.len() as u32).to_le_bytes());
|
|
v.extend_from_slice(&data_bytes);
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn probe_and_decode_pcm_wav() {
|
|
let wav = tiny_wav();
|
|
let info = AudioInfo::probe(&wav);
|
|
assert_eq!(info.codec, AudioCodec::Pcm);
|
|
assert_eq!(info.channels, Some(2));
|
|
assert_eq!(info.sample_rate, Some(48000));
|
|
assert_eq!(info.samples_per_channel, Some(2));
|
|
let audio = GameAudio::from_wav(&wav).unwrap();
|
|
assert_eq!(audio.channels, 2);
|
|
assert_eq!(audio.samples.len(), 4);
|
|
assert!((audio.samples[2] - 0.99997).abs() < 1e-3); // 32767/32768
|
|
}
|
|
|
|
/// XMA1 is not a `WAVEFORMATEX`, and reading it as one produced nonsense.
|
|
///
|
|
/// The bytes here are the real `fmt ` chunk of `ADV`'s first movie-voice
|
|
/// presentation, copied off the disc. Read as a `WAVEFORMATEX` it reports
|
|
/// **16 channels, 4310 Hz, 2-bit** — 16 is `wBitsPerSample`, 4310 is
|
|
/// `wEncodeOptions` (`0x10d6`). Read as an `XMAWAVEFORMAT` it reports 2
|
|
/// channels, 48 kHz, 16-bit, 8142 B/s.
|
|
///
|
|
/// The duration is the part worth guarding: this crate has no XMA decoder,
|
|
/// and `data_bytes / PsuedoBytesPerSec` is the only route to one. It agrees
|
|
/// with an independently decoded 137.324 s to **0.02 %**.
|
|
#[test]
|
|
fn xma1_fmt_is_not_a_waveformatex() {
|
|
let mut v = Vec::new();
|
|
v.extend_from_slice(b"RIFF");
|
|
v.extend_from_slice(&0u32.to_le_bytes());
|
|
v.extend_from_slice(b"WAVE");
|
|
v.extend_from_slice(b"fmt ");
|
|
v.extend_from_slice(&32u32.to_le_bytes());
|
|
// XMAWAVEFORMAT, exactly as it appears on the disc.
|
|
v.extend_from_slice(&[
|
|
0x65, 0x01, // wFormatTag = 0x0165 (XMA1)
|
|
0x10, 0x00, // wBitsPerSample = 16
|
|
0xd6, 0x10, // wEncodeOptions = 0x10d6 <- was misread as the rate
|
|
0x00, 0x00, // wLargestSkip
|
|
0x01, 0x00, // wNumStreams
|
|
0x00, // bLoopCount
|
|
0x02, // bStreamCount
|
|
0xce, 0x1f, 0x00, 0x00, // PsuedoBytesPerSec = 8142
|
|
0x80, 0xbb, 0x00, 0x00, // SampleRate = 48000
|
|
0x00, 0x00, 0x00, 0x00, // LoopStart
|
|
0x00, 0x00, 0x00, 0x00, // LoopEnd
|
|
0x00, // SubframeData
|
|
0x02, // Channels = 2 <- was read from +2 as 16
|
|
0x02, 0x00, // ChannelMask
|
|
]);
|
|
v.extend_from_slice(b"data");
|
|
v.extend_from_slice(&1_118_208u32.to_le_bytes());
|
|
|
|
let info = AudioInfo::probe(&v);
|
|
assert_eq!(info.codec, AudioCodec::Xma);
|
|
assert_eq!(info.channels, Some(2), "channels came from wBitsPerSample");
|
|
assert_eq!(info.sample_rate, Some(48_000), "rate came from wEncodeOptions");
|
|
assert_eq!(info.bits_per_sample, Some(16));
|
|
assert_eq!(info.avg_bytes_per_sec, Some(8142));
|
|
let d = info.duration_secs.expect("duration from the declared byte rate");
|
|
assert!(
|
|
(d - 137.324).abs() < 0.05,
|
|
"declared-rate duration {d} should match the decoded 137.324 s"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_xma2_riff_reports_metadata_not_decode() {
|
|
// Minimal RIFF/WAVE with an XMA2 fmt tag.
|
|
let mut v = Vec::new();
|
|
v.extend_from_slice(b"RIFF");
|
|
v.extend_from_slice(&200u32.to_le_bytes());
|
|
v.extend_from_slice(b"WAVE");
|
|
v.extend_from_slice(b"fmt ");
|
|
v.extend_from_slice(&16u32.to_le_bytes());
|
|
v.extend_from_slice(&WAVE_FORMAT_XMA2.to_le_bytes());
|
|
v.extend_from_slice(&2u16.to_le_bytes());
|
|
v.extend_from_slice(&44100u32.to_le_bytes());
|
|
v.extend_from_slice(&0u32.to_le_bytes());
|
|
v.extend_from_slice(&0u16.to_le_bytes());
|
|
v.extend_from_slice(&0u16.to_le_bytes());
|
|
let info = AudioInfo::probe(&v);
|
|
assert_eq!(info.codec, AudioCodec::Xma2);
|
|
assert_eq!(info.channels, Some(2));
|
|
assert!(info.codec.needs_decoder());
|
|
assert!(matches!(
|
|
GameAudio::from_wav(&v),
|
|
Err(AudioError::NeedsDecoder(AudioCodec::Xma2))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn raw_high_entropy_blob_reads_as_raw_xma2() {
|
|
// A pseudo-random 8 KB blob (no magic) → RawXma2 with a packet count.
|
|
let mut b = vec![0u8; 8192];
|
|
let mut x = 0x2545_F491u32;
|
|
for v in b.iter_mut() {
|
|
x ^= x << 13;
|
|
x ^= x >> 17;
|
|
x ^= x << 5;
|
|
*v = (x & 0xFF) as u8;
|
|
}
|
|
let info = AudioInfo::probe(&b);
|
|
assert_eq!(info.codec, AudioCodec::RawXma2);
|
|
assert_eq!(info.xma_packets, Some(4)); // 8192 / 2048
|
|
}
|
|
|
|
#[test]
|
|
fn structured_low_entropy_blob_is_unknown_not_audio() {
|
|
let b = b"IDXD............a bunch of readable ASCII text fields....".repeat(40);
|
|
assert_eq!(AudioInfo::probe(&b).codec, AudioCodec::Unknown);
|
|
}
|
|
}
|