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}