kenwood_thd75/sdcard/
audio.rs

1//! Parser for WAV audio recording files.
2//!
3//! The TH-D75 records TX/RX audio to standard RIFF WAV files.
4//! Per User Manual Chapter 20 and Operating Tips ยง5.14:
5//!
6//! - Format: 16 kHz sample rate, 16-bit signed PCM, mono.
7//! - Maximum file size: 2 GB (approximately 18 hours of audio).
8//!   Recording continues in a new file if the limit is exceeded.
9//! - Recording band selectable: A or B (Menu No. 302).
10//! - Recording starts/stops via Menu No. 301.
11//!
12//! # Location
13//!
14//! `/KENWOOD/TH-D75/AUDIO_REC/*.wav` โ€” maximum 255 files per directory.
15//!
16//! # Details
17//!
18//! This parser validates the RIFF/WAV header and extracts metadata
19//! (sample rate, bit depth, channels, data length, duration).
20//! It does **not** decode PCM sample data.
21
22use super::{SdCardError, read_u16_le, read_u32_le};
23
24/// Expected sample rate for TH-D75 audio recordings (Hz).
25const EXPECTED_SAMPLE_RATE: u32 = 16_000;
26
27/// Expected bits per sample for TH-D75 audio recordings.
28const EXPECTED_BITS_PER_SAMPLE: u16 = 16;
29
30/// Expected channel count for TH-D75 audio recordings (mono).
31const EXPECTED_CHANNELS: u16 = 1;
32
33/// WAV audio format code for PCM.
34const WAV_FORMAT_PCM: u16 = 1;
35
36/// Minimum WAV file size: 44 bytes (RIFF header + fmt chunk + data chunk header).
37const MIN_WAV_SIZE: usize = 44;
38
39/// Metadata extracted from a TH-D75 audio recording WAV file.
40///
41/// Contains only the header information โ€” PCM sample data is not
42/// loaded or decoded.
43#[derive(Debug, Clone, PartialEq)]
44pub struct AudioRecording {
45    /// Sample rate in Hz. Expected: 16000 for TH-D75.
46    pub sample_rate: u32,
47    /// Bits per sample. Expected: 16 for TH-D75.
48    pub bits_per_sample: u16,
49    /// Number of audio channels. Expected: 1 (mono) for TH-D75.
50    pub channels: u16,
51    /// Size of the raw PCM data section in bytes.
52    pub data_length: u32,
53    /// Calculated recording duration in seconds.
54    ///
55    /// Computed as `data_length / (sample_rate * channels * bits_per_sample / 8)`.
56    pub duration_secs: f64,
57}
58
59/// Parse a WAV audio recording file from raw bytes.
60///
61/// Validates the RIFF/WAV header structure and verifies the audio
62/// format matches the TH-D75 specification (16 kHz, 16-bit, mono PCM).
63///
64/// # Errors
65///
66/// Returns [`SdCardError::FileTooSmall`] if the data is shorter than
67/// the minimum WAV header size (44 bytes).
68///
69/// Returns [`SdCardError::InvalidWavHeader`] if the RIFF magic,
70/// WAVE format tag, fmt chunk ID, or audio format code is invalid.
71///
72/// Returns [`SdCardError::UnexpectedAudioFormat`] if the sample rate,
73/// bit depth, or channel count does not match the expected TH-D75
74/// format.
75pub fn parse(data: &[u8]) -> Result<AudioRecording, SdCardError> {
76    if data.len() < MIN_WAV_SIZE {
77        return Err(SdCardError::FileTooSmall {
78            expected: MIN_WAV_SIZE,
79            actual: data.len(),
80        });
81    }
82
83    // Validate RIFF header: bytes 0-3 = "RIFF"
84    if &data[0..4] != b"RIFF" {
85        return Err(SdCardError::InvalidWavHeader {
86            detail: "missing RIFF magic bytes".to_owned(),
87        });
88    }
89
90    // Validate WAVE format: bytes 8-11 = "WAVE"
91    if &data[8..12] != b"WAVE" {
92        return Err(SdCardError::InvalidWavHeader {
93            detail: "missing WAVE format identifier".to_owned(),
94        });
95    }
96
97    // Find the "fmt " sub-chunk. It usually starts at offset 12, but
98    // we search to handle files with extra chunks before fmt.
99    let fmt_offset = find_chunk(data, *b"fmt ").ok_or_else(|| SdCardError::InvalidWavHeader {
100        detail: "fmt chunk not found".to_owned(),
101    })?;
102
103    // fmt chunk needs at least 16 bytes of data (after 8-byte chunk header).
104    if data.len() < fmt_offset + 24 {
105        return Err(SdCardError::FileTooSmall {
106            expected: fmt_offset + 24,
107            actual: data.len(),
108        });
109    }
110
111    let audio_format = read_u16_le(data, fmt_offset + 8);
112    if audio_format != WAV_FORMAT_PCM {
113        return Err(SdCardError::InvalidWavHeader {
114            detail: format!(
115                "unsupported audio format code {audio_format} (expected {WAV_FORMAT_PCM} for PCM)"
116            ),
117        });
118    }
119
120    let channels = read_u16_le(data, fmt_offset + 10);
121    let sample_rate = read_u32_le(data, fmt_offset + 12);
122    let bits_per_sample = read_u16_le(data, fmt_offset + 22);
123
124    // Validate TH-D75 expected format.
125    if sample_rate != EXPECTED_SAMPLE_RATE
126        || bits_per_sample != EXPECTED_BITS_PER_SAMPLE
127        || channels != EXPECTED_CHANNELS
128    {
129        return Err(SdCardError::UnexpectedAudioFormat {
130            sample_rate,
131            bits_per_sample,
132            channels,
133        });
134    }
135
136    // Find the "data" sub-chunk.
137    let data_offset = find_chunk(data, *b"data").ok_or_else(|| SdCardError::InvalidWavHeader {
138        detail: "data chunk not found".to_owned(),
139    })?;
140
141    if data.len() < data_offset + 8 {
142        return Err(SdCardError::FileTooSmall {
143            expected: data_offset + 8,
144            actual: data.len(),
145        });
146    }
147
148    let data_length = read_u32_le(data, data_offset + 4);
149
150    let bytes_per_sample_frame =
151        f64::from(sample_rate) * f64::from(channels) * f64::from(bits_per_sample) / 8.0;
152    let duration_secs = f64::from(data_length) / bytes_per_sample_frame;
153
154    Ok(AudioRecording {
155        sample_rate,
156        bits_per_sample,
157        channels,
158        data_length,
159        duration_secs,
160    })
161}
162
163/// Search for a RIFF chunk by its 4-byte ID, starting after the
164/// 12-byte RIFF header. Returns the offset of the chunk header.
165fn find_chunk(data: &[u8], id: [u8; 4]) -> Option<usize> {
166    let mut offset = 12; // Skip RIFF header (4 + 4 + 4)
167
168    while offset + 8 <= data.len() {
169        if data[offset..offset + 4] == id {
170            return Some(offset);
171        }
172
173        // Chunk size is at offset+4, little-endian u32.
174        let chunk_size = read_u32_le(data, offset + 4) as usize;
175        // Chunks are word-aligned (padded to even size).
176        let padded = (chunk_size + 1) & !1;
177        offset += 8 + padded;
178    }
179
180    None
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    /// Build a minimal valid WAV file with the given parameters and PCM data length.
188    fn build_wav(sample_rate: u32, bits_per_sample: u16, channels: u16, pcm_len: u32) -> Vec<u8> {
189        let mut buf = Vec::new();
190
191        // RIFF header
192        buf.extend_from_slice(b"RIFF");
193        let file_size = 36 + pcm_len;
194        buf.extend_from_slice(&file_size.to_le_bytes());
195        buf.extend_from_slice(b"WAVE");
196
197        // fmt chunk
198        buf.extend_from_slice(b"fmt ");
199        buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
200        buf.extend_from_slice(&WAV_FORMAT_PCM.to_le_bytes()); // audio format
201        buf.extend_from_slice(&channels.to_le_bytes());
202        buf.extend_from_slice(&sample_rate.to_le_bytes());
203        let byte_rate = sample_rate * u32::from(channels) * u32::from(bits_per_sample) / 8;
204        buf.extend_from_slice(&byte_rate.to_le_bytes());
205        let block_align = channels * bits_per_sample / 8;
206        buf.extend_from_slice(&block_align.to_le_bytes());
207        buf.extend_from_slice(&bits_per_sample.to_le_bytes());
208
209        // data chunk
210        buf.extend_from_slice(b"data");
211        buf.extend_from_slice(&pcm_len.to_le_bytes());
212
213        // Append zero-filled PCM data (enough for header parsing).
214        let fill = pcm_len.min(256);
215        buf.resize(buf.len() + fill as usize, 0);
216
217        buf
218    }
219
220    #[test]
221    fn parse_valid_d75_wav() {
222        // 1 second of 16 kHz / 16-bit / mono = 32000 bytes
223        let pcm_len: u32 = 32_000;
224        let wav = build_wav(16_000, 16, 1, pcm_len);
225        let rec = parse(&wav).unwrap();
226
227        assert_eq!(rec.sample_rate, 16_000);
228        assert_eq!(rec.bits_per_sample, 16);
229        assert_eq!(rec.channels, 1);
230        assert_eq!(rec.data_length, pcm_len);
231        assert!((rec.duration_secs - 1.0).abs() < 0.001);
232    }
233
234    #[test]
235    fn parse_duration_calculation() {
236        // 5 minutes = 300 seconds โ†’ 300 * 32000 = 9_600_000 bytes
237        let pcm_len: u32 = 9_600_000;
238        let wav = build_wav(16_000, 16, 1, pcm_len);
239        let rec = parse(&wav).unwrap();
240
241        assert!((rec.duration_secs - 300.0).abs() < 0.001);
242    }
243
244    #[test]
245    fn too_short_returns_error() {
246        let data = b"RIFF";
247        let err = parse(data).unwrap_err();
248        assert!(matches!(err, SdCardError::FileTooSmall { .. }));
249    }
250
251    #[test]
252    fn empty_returns_error() {
253        let err = parse(b"").unwrap_err();
254        assert!(matches!(err, SdCardError::FileTooSmall { .. }));
255    }
256
257    #[test]
258    fn wrong_riff_magic() {
259        let mut wav = build_wav(16_000, 16, 1, 32_000);
260        wav[0..4].copy_from_slice(b"XXXX");
261        let err = parse(&wav).unwrap_err();
262        assert!(matches!(err, SdCardError::InvalidWavHeader { .. }));
263    }
264
265    #[test]
266    fn wrong_wave_format() {
267        let mut wav = build_wav(16_000, 16, 1, 32_000);
268        wav[8..12].copy_from_slice(b"AVI ");
269        let err = parse(&wav).unwrap_err();
270        assert!(matches!(err, SdCardError::InvalidWavHeader { .. }));
271    }
272
273    #[test]
274    fn non_pcm_format_rejected() {
275        let mut wav = build_wav(16_000, 16, 1, 32_000);
276        // Set audio format to 3 (IEEE float) at fmt+8 = offset 20
277        wav[20..22].copy_from_slice(&3u16.to_le_bytes());
278        let err = parse(&wav).unwrap_err();
279        assert!(matches!(err, SdCardError::InvalidWavHeader { .. }));
280    }
281
282    #[test]
283    fn wrong_sample_rate_rejected() {
284        let wav = build_wav(44_100, 16, 1, 88_200);
285        let err = parse(&wav).unwrap_err();
286        assert!(matches!(err, SdCardError::UnexpectedAudioFormat { .. }));
287    }
288
289    #[test]
290    fn wrong_bit_depth_rejected() {
291        let wav = build_wav(16_000, 8, 1, 16_000);
292        let err = parse(&wav).unwrap_err();
293        assert!(matches!(err, SdCardError::UnexpectedAudioFormat { .. }));
294    }
295
296    #[test]
297    fn stereo_rejected() {
298        let wav = build_wav(16_000, 16, 2, 64_000);
299        let err = parse(&wav).unwrap_err();
300        assert!(matches!(err, SdCardError::UnexpectedAudioFormat { .. }));
301    }
302}