Skip to main content

rakata_formats/wav/
mod.rs

1//! WAV reader and writer with KotOR obfuscation handling.
2//!
3//! KotOR uses multiple wrappers around audio payloads:
4//! - standard RIFF/WAVE files,
5//! - SFX files with a 470-byte prefix header,
6//! - MP3-in-WAV wrappers (a RIFF chunk ending short of the file, then raw MP3).
7//!
8//! This module implements container-level parsing/serialization only. It does
9//! not decode PCM/ADPCM sample streams.
10//!
11//! ## Telling the three apart
12//!
13//! A `.wav` here is one of three things wearing the same extension, and none
14//! of them is told apart by a signature. An SFX file is caught by position: it
15//! has no `RIFF` at the start and a `RIFF` at byte 470, where its real payload
16//! begins.
17//!
18//! The MP3 wrapper is the one to be careful with, because it *does* start with
19//! `RIFF` and passes a naive signature check. What exposes it is arithmetic:
20//! `riff_size + 8` falls short of the file's length, and the MP3 starts there.
21//! Trusting the tag and stopping yields an empty WAVE and treats the actual
22//! audio as padding. [`classify_wav`] applies both tests.
23//!
24//! Byte-level offsets for all three live in `docs/src/formats/audio/wav.md`,
25//! with the engine's own dispatch sequence.
26
27pub mod adpcm;
28pub mod pcm;
29mod reader;
30mod writer;
31
32pub use reader::{classify_wav, read_wav, read_wav_from_bytes, WAV_CLASSIFY_LEN};
33pub use writer::{
34    write_wav, write_wav_to_vec, write_wav_to_vec_with_options, write_wav_with_options,
35};
36
37use std::fmt;
38
39use num_enum::{IntoPrimitive, TryFromPrimitive};
40use thiserror::Error;
41
42use crate::binary::{self, DecodeBinary, EncodeBinary};
43
44pub(super) const RIFF_MAGIC: [u8; 4] = *b"RIFF";
45pub(super) const WAVE_MAGIC: [u8; 4] = *b"WAVE";
46pub(super) const FMT_CHUNK_ID: [u8; 4] = *b"fmt ";
47pub(super) const DATA_CHUNK_ID: [u8; 4] = *b"data";
48/// The four bytes every retail SFX wrapper opens with.
49///
50/// Write-side only. The engine never tests for this constant -- it appears
51/// nowhere in the executable -- and detects a wrapped file positionally
52/// instead, so the reader must not gate on it. See
53/// `docs/src/formats/audio/wav.md`.
54pub(super) const SFX_MAGIC: [u8; 4] = [0xFF, 0xF3, 0x60, 0xC4];
55pub(super) const SFX_HEADER_SIZE: usize = 470;
56pub(super) const MP3_IN_WAV_HEADER_SIZE: usize = 58;
57/// The header every MP3-in-WAV file in a retail install opens with.
58///
59/// An empty WAVE for 8-bit mono PCM at 22,050 Hz whose RIFF chunk declares 50
60/// bytes, so the MP3 frames start at byte 58, outside the chunk. None of its
61/// fields describes the MP3 behind it. Laid out in
62/// `docs/src/formats/audio/wav.md`.
63pub(super) const MP3_IN_WAV_HEADER: [u8; MP3_IN_WAV_HEADER_SIZE] = [
64    b'R', b'I', b'F', b'F', 50, 0, 0, 0, // RIFF chunk size
65    b'W', b'A', b'V', b'E', // form type
66    b'f', b'm', b't', b' ', 18, 0, 0, 0, // format chunk size
67    1, 0, // PCM
68    1, 0, // mono
69    0x22, 0x56, 0, 0, // 22,050 Hz
70    0x22, 0x56, 0, 0, // bytes per second
71    1, 0, // block align
72    8, 0, // bits per sample
73    0, 0, // no extra format bytes
74    b'f', b'a', b'c', b't', 4, 0, 0, 0, 0, 0, 0, 0, // fact chunk holding 0
75    b'd', b'a', b't', b'a', 0, 0, 0, 0, // empty data chunk
76];
77pub(super) const DEFAULT_MP3_CHANNELS: u16 = 2;
78pub(super) const DEFAULT_MP3_SAMPLE_RATE: u32 = 44_100;
79pub(super) const DEFAULT_MP3_BITS_PER_SAMPLE: u16 = 16;
80
81/// Which of the engine's three forms a `.wav` file takes. See [`classify_wav`].
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum WavWrapperKind {
84    /// Starts with `RIFF` and is not MP3-in-WAV: the file is the RIFF/WAVE.
85    Standard,
86    /// No `RIFF` at the start and a RIFF/WAVE from `0x1D6`, after a 470-byte
87    /// prefix.
88    SfxHeader,
89    /// Starts with a RIFF chunk that ends short of the file, with raw MP3 from
90    /// where it ends.
91    Mp3InWav,
92}
93
94/// KotOR-facing wrapper type used when writing.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum WavType {
97    /// No KotOR wrapper -- plain RIFF/WAVE payload written and read as-is.
98    Standard,
99    /// The MP3-in-WAV form: the 58-byte header vanilla writes, then the MP3.
100    ///
101    /// It holds an MP3 payload only. Voice and most music ship in this form.
102    Vo,
103    /// Sound-effect style wrapper (470-byte prefix starting with `FF F3 60 C4`).
104    Sfx,
105}
106
107impl fmt::Display for WavType {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(match self {
110            Self::Standard => "the plain form",
111            Self::Vo => "the MP3-in-WAV form",
112            Self::Sfx => "the prefixed form",
113        })
114    }
115}
116
117/// Audio payload format represented by [`Wav`].
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119pub enum WavAudioFormat {
120    /// Standard RIFF/WAVE payload.
121    Wave,
122    /// Raw MP3 payload bytes.
123    Mp3,
124}
125
126impl fmt::Display for WavAudioFormat {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(match self {
129            Self::Wave => "a WAVE payload",
130            Self::Mp3 => "an MP3 payload",
131        })
132    }
133}
134
135/// Known WAVE encoding tags.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
137#[repr(u16)]
138pub enum WavEncoding {
139    /// Linear PCM.
140    Pcm = 0x0001,
141    /// Microsoft ADPCM.
142    MsAdpcm = 0x0002,
143    /// A-Law companded PCM.
144    ALaw = 0x0006,
145    /// Mu-Law companded PCM.
146    MuLaw = 0x0007,
147    /// IMA ADPCM (DVI ADPCM).
148    ImaAdpcm = 0x0011,
149    /// MPEG Layer 3 payload tag.
150    Mp3 = 0x0055,
151}
152
153/// Lossless WAVE encoding tag wrapper.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
155pub struct WavEncodingCode(u16);
156
157impl WavEncodingCode {
158    /// Creates an encoding code from a raw on-disk tag value.
159    pub const fn from_raw(raw: u16) -> Self {
160        Self(raw)
161    }
162
163    /// Returns the raw encoding tag value.
164    pub const fn raw(self) -> u16 {
165        self.0
166    }
167
168    /// Returns the known encoding tag when available.
169    pub fn known(self) -> Option<WavEncoding> {
170        WavEncoding::try_from(self.0).ok()
171    }
172}
173
174impl From<WavEncoding> for WavEncodingCode {
175    fn from(value: WavEncoding) -> Self {
176        Self(u16::from(value))
177    }
178}
179
180/// In-memory WAV payload.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Wav {
183    /// Wrapper mode used when writing game-facing bytes.
184    pub wav_type: WavType,
185    /// Audio payload kind.
186    pub audio_format: WavAudioFormat,
187    /// WAVE encoding tag (lossless raw value).
188    pub encoding: WavEncodingCode,
189    /// Channel count.
190    pub channels: u16,
191    /// Sample rate in Hz.
192    pub sample_rate: u32,
193    /// Byte rate from `fmt` chunk.
194    pub bytes_per_sec: u32,
195    /// Block alignment from `fmt` chunk.
196    pub block_align: u16,
197    /// Bits per sample from `fmt` chunk.
198    pub bits_per_sample: u16,
199    /// Audio payload bytes (PCM/ADPCM/MP3 data).
200    pub data: Vec<u8>,
201}
202
203/// Metadata fields for WAVE-format payloads.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
205pub struct WavWaveMetadata {
206    /// WAVE encoding tag (lossless raw value).
207    pub encoding: WavEncodingCode,
208    /// Channel count.
209    pub channels: u16,
210    /// Sample rate in Hz.
211    pub sample_rate: u32,
212    /// Byte rate from `fmt` chunk.
213    pub bytes_per_sec: u32,
214    /// Block alignment from `fmt` chunk.
215    pub block_align: u16,
216    /// Bits per sample from `fmt` chunk.
217    pub bits_per_sample: u16,
218}
219
220impl Wav {
221    /// Creates a WAVE-format container payload.
222    pub fn new_wave(wav_type: WavType, metadata: WavWaveMetadata, data: Vec<u8>) -> Self {
223        Self {
224            wav_type,
225            audio_format: WavAudioFormat::Wave,
226            encoding: metadata.encoding,
227            channels: metadata.channels,
228            sample_rate: metadata.sample_rate,
229            bytes_per_sec: metadata.bytes_per_sec,
230            block_align: metadata.block_align,
231            bits_per_sample: metadata.bits_per_sample,
232            data,
233        }
234    }
235
236    /// Creates an MP3 payload container with canonical metadata defaults.
237    pub fn new_mp3(wav_type: WavType, data: Vec<u8>) -> Self {
238        Self {
239            wav_type,
240            audio_format: WavAudioFormat::Mp3,
241            encoding: WavEncodingCode::from(WavEncoding::Mp3),
242            channels: DEFAULT_MP3_CHANNELS,
243            sample_rate: DEFAULT_MP3_SAMPLE_RATE,
244            bytes_per_sec: 0,
245            block_align: 0,
246            bits_per_sample: DEFAULT_MP3_BITS_PER_SAMPLE,
247            data,
248        }
249    }
250}
251
252impl DecodeBinary for Wav {
253    type Error = WavError;
254
255    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
256        read_wav_from_bytes(bytes)
257    }
258}
259
260impl EncodeBinary for Wav {
261    type Error = WavError;
262
263    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
264        write_wav_to_vec(self)
265    }
266}
267
268/// WAV write target mode.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
270pub enum WavWriteMode {
271    /// Emit KotOR game-facing wrapper style based on [`Wav::wav_type`].
272    #[default]
273    Game,
274    /// Emit clean playable bytes (plain RIFF/WAVE or raw MP3 bytes).
275    Clean,
276}
277
278/// WAV writer options.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
280pub struct WavWriteOptions {
281    /// Output mode.
282    pub mode: WavWriteMode,
283}
284
285/// WAV parse/write errors.
286#[derive(Debug, Error)]
287pub enum WavError {
288    /// I/O read/write failure.
289    #[error(transparent)]
290    Io(#[from] std::io::Error),
291    /// Header-level validation failure.
292    #[error("invalid WAV header: {0}")]
293    InvalidHeader(String),
294    /// RIFF chunk-level validation failure.
295    #[error("invalid WAV chunk: {0}")]
296    InvalidChunk(String),
297    /// The form a game-mode write was asked for cannot hold the payload. The
298    /// plain and prefixed forms wrap a WAVE, and the MP3-in-WAV form an MP3.
299    #[error("{form} cannot hold {payload}")]
300    MismatchedPayload {
301        /// The form the write was asked for.
302        form: WavType,
303        /// The payload the [`Wav`] holds.
304        payload: WavAudioFormat,
305    },
306    /// An MP3-in-WAV write with no MP3 to follow the header. Its RIFF chunk
307    /// would reach the end of the file, so it would read back as a plain WAVE.
308    #[error("the MP3-in-WAV form needs MP3 data after its header")]
309    EmptyMp3,
310}