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 (58-byte prefix followed by raw MP3 data).
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 the
14//! discriminators are not alike. An SFX file is caught by magic: it has no
15//! `RIFF` at the start, just the fixed prefix `FF F3 60 C4`, with the real
16//! payload 470 bytes in.
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//! its header declares a RIFF size of 50, so `riff_size + 8` lands at byte 58
21//! with the rest of the file beyond it. Trusting the tag and stopping there
22//! yields 58 bytes of wrapper and treats the actual audio as padding.
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::{read_wav, read_wav_from_bytes};
33pub use writer::{
34 write_wav, write_wav_to_vec, write_wav_to_vec_with_options, write_wav_with_options,
35};
36
37use num_enum::{IntoPrimitive, TryFromPrimitive};
38use thiserror::Error;
39
40use crate::binary::{self, DecodeBinary, EncodeBinary};
41
42pub(super) const RIFF_MAGIC: [u8; 4] = *b"RIFF";
43pub(super) const WAVE_MAGIC: [u8; 4] = *b"WAVE";
44pub(super) const FMT_CHUNK_ID: [u8; 4] = *b"fmt ";
45pub(super) const DATA_CHUNK_ID: [u8; 4] = *b"data";
46/// The four bytes every retail SFX wrapper opens with.
47///
48/// Write-side only. The engine never tests for this constant -- it appears
49/// nowhere in the executable -- and detects a wrapped file positionally
50/// instead, so the reader must not gate on it. See
51/// `docs/src/formats/audio/wav.md`.
52pub(super) const SFX_MAGIC: [u8; 4] = [0xFF, 0xF3, 0x60, 0xC4];
53pub(super) const SFX_HEADER_SIZE: usize = 470;
54pub(super) const VO_HEADER_SIZE: usize = 20;
55pub(super) const MP3_IN_WAV_RIFF_SIZE: u32 = 50;
56pub(super) const MP3_IN_WAV_HEADER_SIZE: usize = 58;
57pub(super) const DEFAULT_MP3_CHANNELS: u16 = 2;
58pub(super) const DEFAULT_MP3_SAMPLE_RATE: u32 = 44_100;
59pub(super) const DEFAULT_MP3_BITS_PER_SAMPLE: u16 = 16;
60
61/// WAV wrapper variant detected in source bytes.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum WavWrapperKind {
64 /// Standard RIFF/WAVE payload with no KotOR prefix.
65 Standard,
66 /// KotOR SFX-prefixed wrapper (470-byte prefix).
67 SfxHeader,
68 /// KotOR VO-prefixed wrapper (20-byte prefix).
69 VoHeader,
70 /// MP3-in-WAV wrapper (58-byte prefix and raw MP3 payload).
71 Mp3InWav,
72}
73
74/// KotOR-facing wrapper type used when writing.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum WavType {
77 /// No KotOR wrapper -- plain RIFF/WAVE payload written and read as-is.
78 Standard,
79 /// Voice-over style wrapper (20-byte prefix starting with `RIFF`).
80 Vo,
81 /// Sound-effect style wrapper (470-byte prefix starting with `FF F3 60 C4`).
82 Sfx,
83}
84
85/// Audio payload format represented by [`Wav`].
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum WavAudioFormat {
88 /// Standard RIFF/WAVE payload.
89 Wave,
90 /// Raw MP3 payload bytes.
91 Mp3,
92}
93
94/// Known WAVE encoding tags.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
96#[repr(u16)]
97pub enum WavEncoding {
98 /// Linear PCM.
99 Pcm = 0x0001,
100 /// Microsoft ADPCM.
101 MsAdpcm = 0x0002,
102 /// A-Law companded PCM.
103 ALaw = 0x0006,
104 /// Mu-Law companded PCM.
105 MuLaw = 0x0007,
106 /// IMA ADPCM (DVI ADPCM).
107 ImaAdpcm = 0x0011,
108 /// MPEG Layer 3 payload tag.
109 Mp3 = 0x0055,
110}
111
112/// Lossless WAVE encoding tag wrapper.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct WavEncodingCode(u16);
115
116impl WavEncodingCode {
117 /// Creates an encoding code from a raw on-disk tag value.
118 pub const fn from_raw(raw: u16) -> Self {
119 Self(raw)
120 }
121
122 /// Returns the raw encoding tag value.
123 pub const fn raw(self) -> u16 {
124 self.0
125 }
126
127 /// Returns the known encoding tag when available.
128 pub fn known(self) -> Option<WavEncoding> {
129 WavEncoding::try_from(self.0).ok()
130 }
131}
132
133impl From<WavEncoding> for WavEncodingCode {
134 fn from(value: WavEncoding) -> Self {
135 Self(u16::from(value))
136 }
137}
138
139/// In-memory WAV payload.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Wav {
142 /// Wrapper mode used when writing game-facing bytes.
143 pub wav_type: WavType,
144 /// Audio payload kind.
145 pub audio_format: WavAudioFormat,
146 /// WAVE encoding tag (lossless raw value).
147 pub encoding: WavEncodingCode,
148 /// Channel count.
149 pub channels: u16,
150 /// Sample rate in Hz.
151 pub sample_rate: u32,
152 /// Byte rate from `fmt` chunk.
153 pub bytes_per_sec: u32,
154 /// Block alignment from `fmt` chunk.
155 pub block_align: u16,
156 /// Bits per sample from `fmt` chunk.
157 pub bits_per_sample: u16,
158 /// Audio payload bytes (PCM/ADPCM/MP3 data).
159 pub data: Vec<u8>,
160}
161
162/// Metadata fields for WAVE-format payloads.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164pub struct WavWaveMetadata {
165 /// WAVE encoding tag (lossless raw value).
166 pub encoding: WavEncodingCode,
167 /// Channel count.
168 pub channels: u16,
169 /// Sample rate in Hz.
170 pub sample_rate: u32,
171 /// Byte rate from `fmt` chunk.
172 pub bytes_per_sec: u32,
173 /// Block alignment from `fmt` chunk.
174 pub block_align: u16,
175 /// Bits per sample from `fmt` chunk.
176 pub bits_per_sample: u16,
177}
178
179impl Wav {
180 /// Creates a WAVE-format container payload.
181 pub fn new_wave(wav_type: WavType, metadata: WavWaveMetadata, data: Vec<u8>) -> Self {
182 Self {
183 wav_type,
184 audio_format: WavAudioFormat::Wave,
185 encoding: metadata.encoding,
186 channels: metadata.channels,
187 sample_rate: metadata.sample_rate,
188 bytes_per_sec: metadata.bytes_per_sec,
189 block_align: metadata.block_align,
190 bits_per_sample: metadata.bits_per_sample,
191 data,
192 }
193 }
194
195 /// Creates an MP3 payload container with canonical metadata defaults.
196 pub fn new_mp3(wav_type: WavType, data: Vec<u8>) -> Self {
197 Self {
198 wav_type,
199 audio_format: WavAudioFormat::Mp3,
200 encoding: WavEncodingCode::from(WavEncoding::Mp3),
201 channels: DEFAULT_MP3_CHANNELS,
202 sample_rate: DEFAULT_MP3_SAMPLE_RATE,
203 bytes_per_sec: 0,
204 block_align: 0,
205 bits_per_sample: DEFAULT_MP3_BITS_PER_SAMPLE,
206 data,
207 }
208 }
209}
210
211impl DecodeBinary for Wav {
212 type Error = WavError;
213
214 fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
215 read_wav_from_bytes(bytes)
216 }
217}
218
219impl EncodeBinary for Wav {
220 type Error = WavError;
221
222 fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
223 write_wav_to_vec(self)
224 }
225}
226
227/// WAV write target mode.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
229pub enum WavWriteMode {
230 /// Emit KotOR game-facing wrapper style based on [`Wav::wav_type`].
231 #[default]
232 Game,
233 /// Emit clean playable bytes (plain RIFF/WAVE or raw MP3 bytes).
234 Clean,
235}
236
237/// WAV writer options.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
239pub struct WavWriteOptions {
240 /// Output mode.
241 pub mode: WavWriteMode,
242}
243
244/// WAV parse/write errors.
245#[derive(Debug, Error)]
246pub enum WavError {
247 /// I/O read/write failure.
248 #[error(transparent)]
249 Io(#[from] std::io::Error),
250 /// Header-level validation failure.
251 #[error("invalid WAV header: {0}")]
252 InvalidHeader(String),
253 /// RIFF chunk-level validation failure.
254 #[error("invalid WAV chunk: {0}")]
255 InvalidChunk(String),
256}