Skip to main content

oxideav_mod/
decoder.rs

1//! MOD codec decoder — ProTracker playback.
2//!
3//! Consumes the whole-file packet from the MOD container, parses the
4//! header + patterns + sample bodies, and drives a `PlayerState` forward,
5//! emitting PCM until the song ends.
6//!
7//! Two output modes are available:
8//!
9//! - [`ModDecoder`] (codec id [`crate::CODEC_ID_STR`] = `"mod"`) produces
10//!   mixed stereo S16 interleaved PCM. One `AudioFrame` every
11//!   `CHUNK_FRAMES` samples.
12//! - [`ModPlanarDecoder`] (codec id [`crate::CODEC_ID_PLANAR_STR`] =
13//!   `"mod_planar"`) produces one planar `AudioFrame` per tick, with one
14//!   S16P plane per MOD tracker channel. Downstream consumers that want
15//!   to mix / pan / analyse channels independently (DAWs, visualizers,
16//!   per-instrument tooling) drive the decoder via this codec id.
17
18use oxideav_core::{
19    AudioFrame, CodecCapabilities, CodecId, CodecParameters, Error, Frame, Packet, Result,
20};
21use oxideav_core::{CodecInfo, CodecRegistry, Decoder};
22
23use crate::container::OUTPUT_SAMPLE_RATE;
24use crate::header::parse_header;
25use crate::player::{parse_patterns, PlayerState};
26use crate::samples::extract_samples;
27
28pub fn register(reg: &mut CodecRegistry) {
29    let mixed_caps = CodecCapabilities::audio("mod_sw")
30        .with_lossy(false)
31        .with_lossless(true)
32        .with_intra_only(false)
33        .with_max_channels(32)
34        .with_max_sample_rate(OUTPUT_SAMPLE_RATE);
35    reg.register(
36        CodecInfo::new(CodecId::new(crate::CODEC_ID_STR))
37            .capabilities(mixed_caps)
38            .decoder(make_mixed_decoder),
39    );
40
41    let planar_caps = CodecCapabilities::audio("mod_sw_planar")
42        .with_lossy(false)
43        .with_lossless(true)
44        .with_intra_only(false)
45        .with_max_channels(32)
46        .with_max_sample_rate(OUTPUT_SAMPLE_RATE);
47    reg.register(
48        CodecInfo::new(CodecId::new(crate::CODEC_ID_PLANAR_STR))
49            .capabilities(planar_caps)
50            .decoder(make_planar_decoder),
51    );
52
53    // STM — parsing-only decoder. Emits a clear `unsupported` error on
54    // `send_packet`; structural inspection (title, patterns, instruments)
55    // is available through `oxideav_mod::stm::parse_header` etc.
56    let stm_caps = CodecCapabilities::audio("stm_sw")
57        .with_lossy(false)
58        .with_lossless(true)
59        .with_intra_only(false)
60        .with_max_channels(4)
61        .with_max_sample_rate(OUTPUT_SAMPLE_RATE);
62    reg.register(
63        CodecInfo::new(CodecId::new(crate::CODEC_ID_STM_STR))
64            .capabilities(stm_caps)
65            .decoder(make_stm_stub_decoder),
66    );
67
68    // XM — parsing-only decoder. Same rationale as STM: playback
69    // requires a broader pitch/envelope model than the MOD mixer, so
70    // the decoder validates the packet then returns an explicit
71    // `unsupported` error. Callers use `oxideav_mod::xm::parse_header`
72    // / `parse_patterns` / `parse_instruments` / `extract_sample_bodies`
73    // directly on the packet payload.
74    let xm_caps = CodecCapabilities::audio("xm_sw")
75        .with_lossy(false)
76        .with_lossless(true)
77        .with_intra_only(false)
78        .with_max_channels(32)
79        .with_max_sample_rate(OUTPUT_SAMPLE_RATE);
80    reg.register(
81        CodecInfo::new(CodecId::new(crate::CODEC_ID_XM_STR))
82            .capabilities(xm_caps)
83            .decoder(make_xm_stub_decoder),
84    );
85}
86
87fn make_mixed_decoder(_params: &CodecParameters) -> Result<Box<dyn Decoder>> {
88    Ok(Box::new(ModDecoder {
89        codec_id: CodecId::new(crate::CODEC_ID_STR),
90        state: DecoderState::AwaitingPacket,
91    }))
92}
93
94fn make_planar_decoder(_params: &CodecParameters) -> Result<Box<dyn Decoder>> {
95    Ok(Box::new(ModPlanarDecoder {
96        codec_id: CodecId::new(crate::CODEC_ID_PLANAR_STR),
97        state: DecoderState::AwaitingPacket,
98    }))
99}
100
101fn make_stm_stub_decoder(_params: &CodecParameters) -> Result<Box<dyn Decoder>> {
102    Ok(Box::new(StmStubDecoder {
103        codec_id: CodecId::new(crate::CODEC_ID_STM_STR),
104    }))
105}
106
107fn make_xm_stub_decoder(_params: &CodecParameters) -> Result<Box<dyn Decoder>> {
108    Ok(Box::new(XmStubDecoder {
109        codec_id: CodecId::new(crate::CODEC_ID_XM_STR),
110    }))
111}
112
113/// Stub XM decoder: like `StmStubDecoder`, validates the packet as an
114/// XM file but returns `unsupported` from `send_packet`. XM playback
115/// needs an envelope-capable mixer that supports linear + Amiga pitch
116/// tables, per-instrument volume/pan envelopes, vibrato/fadeout, etc.
117/// Until that lands, structural consumers use
118/// `crate::xm::parse_header` / `parse_patterns` / `parse_instruments`
119/// / `extract_sample_bodies` on the packet payload directly.
120struct XmStubDecoder {
121    codec_id: CodecId,
122}
123
124impl Decoder for XmStubDecoder {
125    fn codec_id(&self) -> &CodecId {
126        &self.codec_id
127    }
128
129    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
130        if !crate::xm::is_xm(&packet.data) {
131            return Err(Error::invalid(
132                "XM: packet does not start with the 'Extended Module: ' banner",
133            ));
134        }
135        // Perform a structural sanity parse; surface parse errors as
136        // `invalid` rather than `unsupported` so callers can distinguish
137        // "malformed file" from "playback intentionally not wired".
138        crate::xm::parse_header(&packet.data)?;
139        Err(Error::unsupported(
140            "XM playback is not yet wired through the MOD mixer; use \
141             oxideav_mod::xm::parse_header() / parse_patterns() / \
142             parse_instruments() / extract_sample_bodies() directly for \
143             structural access",
144        ))
145    }
146
147    fn receive_frame(&mut self) -> Result<Frame> {
148        Err(Error::Eof)
149    }
150
151    fn flush(&mut self) -> Result<()> {
152        Ok(())
153    }
154
155    fn reset(&mut self) -> Result<()> {
156        Ok(())
157    }
158}
159
160/// Stub STM decoder: exists so the codec id resolves cleanly, but returns
161/// an explicit `unsupported` error on `send_packet` rather than silently
162/// emitting zeros. STM uses C3-frequency-based sample pitching rather
163/// than the Amiga period model the MOD mixer assumes, so driving the
164/// existing `PlayerState` with STM data would produce nonsense — better
165/// to fail loudly until the mixer abstraction is broadened. Callers that
166/// want to inspect STM files structurally should use
167/// [`crate::stm::parse_header`] / [`crate::stm::parse_patterns`] /
168/// [`crate::stm::extract_samples`] directly off the packet payload.
169struct StmStubDecoder {
170    codec_id: CodecId,
171}
172
173impl Decoder for StmStubDecoder {
174    fn codec_id(&self) -> &CodecId {
175        &self.codec_id
176    }
177
178    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
179        // Light validation so `unsupported` is only returned for otherwise
180        // well-formed STM files. If the blob is not recognisable as STM,
181        // surface an `invalid` error instead.
182        if !crate::stm::is_stm(&packet.data) {
183            return Err(Error::invalid(
184                "STM: packet does not carry a valid Scream Tracker v1 header",
185            ));
186        }
187        Err(Error::unsupported(
188            "STM playback is not yet wired through the MOD mixer; use \
189             oxideav_mod::stm::parse_header() / parse_patterns() / extract_samples() \
190             directly for structural access",
191        ))
192    }
193
194    fn receive_frame(&mut self) -> Result<Frame> {
195        Err(Error::Eof)
196    }
197
198    fn flush(&mut self) -> Result<()> {
199        Ok(())
200    }
201
202    fn reset(&mut self) -> Result<()> {
203        Ok(())
204    }
205}
206
207struct ModDecoder {
208    codec_id: CodecId,
209    state: DecoderState,
210}
211
212enum DecoderState {
213    /// Haven't seen the file yet.
214    AwaitingPacket,
215    /// File parsed; the player is driving the mixer.
216    Playing {
217        player: Box<PlayerState>,
218        emit_pts: i64,
219    },
220    /// All samples produced.
221    Done,
222}
223
224const CHUNK_FRAMES: u32 = 1024;
225
226impl Decoder for ModDecoder {
227    fn codec_id(&self) -> &CodecId {
228        &self.codec_id
229    }
230
231    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
232        // The MOD "container" delivers the whole file in one packet.
233        if !matches!(self.state, DecoderState::AwaitingPacket) {
234            return Err(Error::other(
235                "MOD decoder received a second packet; only one is expected per song",
236            ));
237        }
238        let header = parse_header(&packet.data)?;
239        let samples = extract_samples(&header, &packet.data);
240        let patterns = parse_patterns(&header, &packet.data);
241        let player = PlayerState::new(&header, samples, patterns, OUTPUT_SAMPLE_RATE);
242        self.state = DecoderState::Playing {
243            player: Box::new(player),
244            emit_pts: 0,
245        };
246        Ok(())
247    }
248
249    fn receive_frame(&mut self) -> Result<Frame> {
250        match &mut self.state {
251            DecoderState::AwaitingPacket => Err(Error::NeedMore),
252            DecoderState::Done => Err(Error::Eof),
253            DecoderState::Playing { player, emit_pts } => {
254                // Allocate stereo interleaved buffer.
255                let mut pcm = vec![0i16; CHUNK_FRAMES as usize * 2];
256                let produced = player.render(&mut pcm);
257                if produced == 0 {
258                    self.state = DecoderState::Done;
259                    return Err(Error::Eof);
260                }
261                // Truncate to what we actually produced.
262                pcm.truncate(produced * 2);
263
264                // Convert to little-endian S16 byte buffer.
265                let mut bytes = Vec::with_capacity(pcm.len() * 2);
266                for s in &pcm {
267                    bytes.extend_from_slice(&s.to_le_bytes());
268                }
269
270                let pts = *emit_pts;
271                *emit_pts += produced as i64;
272                Ok(Frame::Audio(AudioFrame {
273                    samples: produced as u32,
274                    pts: Some(pts),
275                    data: vec![bytes],
276                }))
277            }
278        }
279    }
280
281    fn flush(&mut self) -> Result<()> {
282        if let DecoderState::Playing { .. } = self.state {
283            // Draining is implicit — `receive_frame` will return Eof once
284            // the player reports no more samples.
285        }
286        Ok(())
287    }
288
289    fn reset(&mut self) -> Result<()> {
290        // The entire PlayerState (pattern-order cursor, per-channel mixer
291        // state with sample position, volume/period history, arpeggio /
292        // vibrato phases, BPM + tick counter) is dropped. The MOD
293        // container delivers the whole file in one packet, so after a
294        // reset we go back to `AwaitingPacket` and the container is
295        // expected to re-send the file from the top of the song.
296        self.state = DecoderState::AwaitingPacket;
297        Ok(())
298    }
299}
300
301/// Planar per-channel decoder. Emits one `AudioFrame` per render chunk,
302/// with `AudioFrame.channels` equal to the MOD tracker channel count and
303/// `AudioFrame.data` holding one S16P plane per channel (post-volume,
304/// pre-pan, pre-mix).
305struct ModPlanarDecoder {
306    codec_id: CodecId,
307    state: DecoderState,
308}
309
310impl Decoder for ModPlanarDecoder {
311    fn codec_id(&self) -> &CodecId {
312        &self.codec_id
313    }
314
315    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
316        if !matches!(self.state, DecoderState::AwaitingPacket) {
317            return Err(Error::other(
318                "MOD decoder received a second packet; only one is expected per song",
319            ));
320        }
321        let header = parse_header(&packet.data)?;
322        let samples = extract_samples(&header, &packet.data);
323        let patterns = parse_patterns(&header, &packet.data);
324        let player = PlayerState::new(&header, samples, patterns, OUTPUT_SAMPLE_RATE);
325        self.state = DecoderState::Playing {
326            player: Box::new(player),
327            emit_pts: 0,
328        };
329        Ok(())
330    }
331
332    fn receive_frame(&mut self) -> Result<Frame> {
333        match &mut self.state {
334            DecoderState::AwaitingPacket => Err(Error::NeedMore),
335            DecoderState::Done => Err(Error::Eof),
336            DecoderState::Playing { player, emit_pts } => {
337                let n_ch = player.channels.len();
338                let n_frames = CHUNK_FRAMES as usize;
339
340                // One i16 buffer per MOD channel, pre-sized to the chunk.
341                let mut bufs: Vec<Vec<i16>> = (0..n_ch).map(|_| vec![0i16; n_frames]).collect();
342                let produced = {
343                    let mut views: Vec<&mut [i16]> =
344                        bufs.iter_mut().map(|b| b.as_mut_slice()).collect();
345                    player.render_per_channel(&mut views, n_frames)
346                };
347                if produced == 0 {
348                    self.state = DecoderState::Done;
349                    return Err(Error::Eof);
350                }
351
352                // Truncate each plane and convert to little-endian bytes.
353                let mut planes: Vec<Vec<u8>> = Vec::with_capacity(n_ch);
354                for buf in &bufs {
355                    let mut bytes = Vec::with_capacity(produced * 2);
356                    for &s in &buf[..produced] {
357                        bytes.extend_from_slice(&s.to_le_bytes());
358                    }
359                    planes.push(bytes);
360                }
361
362                let pts = *emit_pts;
363                *emit_pts += produced as i64;
364                Ok(Frame::Audio(AudioFrame {
365                    samples: produced as u32,
366                    pts: Some(pts),
367                    data: planes,
368                }))
369            }
370        }
371    }
372
373    fn flush(&mut self) -> Result<()> {
374        Ok(())
375    }
376
377    fn reset(&mut self) -> Result<()> {
378        self.state = DecoderState::AwaitingPacket;
379        Ok(())
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::player::tests::synth_square_mod;
387    use oxideav_core::TimeBase;
388
389    #[test]
390    fn decoder_emits_nonsilent_pcm() {
391        let bytes = synth_square_mod();
392        let params = CodecParameters::audio(CodecId::new(crate::CODEC_ID_STR));
393        let mut dec = make_mixed_decoder(&params).unwrap();
394        let pkt = Packet::new(0, TimeBase::new(1, OUTPUT_SAMPLE_RATE as i64), bytes);
395        dec.send_packet(&pkt).unwrap();
396
397        let mut total_samples = 0u64;
398        let mut total_nonzero = 0u64;
399        loop {
400            match dec.receive_frame() {
401                Ok(Frame::Audio(a)) => {
402                    total_samples += a.samples as u64;
403                    // Count non-zero bytes in the PCM plane.
404                    let plane = &a.data[0];
405                    for chunk in plane.chunks_exact(2) {
406                        let s = i16::from_le_bytes([chunk[0], chunk[1]]);
407                        if s != 0 {
408                            total_nonzero += 1;
409                        }
410                    }
411                }
412                Ok(_) => unreachable!("MOD emits audio only"),
413                Err(Error::Eof) => break,
414                Err(e) => panic!("unexpected decode error: {e:?}"),
415            }
416        }
417        assert!(
418            total_samples > 1000,
419            "expected substantial sample output, got {total_samples}"
420        );
421        assert!(
422            total_nonzero > 100,
423            "expected non-silent PCM, got {total_nonzero} non-zero samples"
424        );
425    }
426
427    #[test]
428    fn planar_decoder_emits_one_plane_per_channel() {
429        let bytes = synth_square_mod();
430        let params = CodecParameters::audio(CodecId::new(crate::CODEC_ID_PLANAR_STR));
431        let mut dec = make_planar_decoder(&params).unwrap();
432        let pkt = Packet::new(0, TimeBase::new(1, OUTPUT_SAMPLE_RATE as i64), bytes);
433        dec.send_packet(&pkt).unwrap();
434
435        let mut got_frame = false;
436        let mut ch0_nonzero = 0u64;
437        let mut other_nonzero = 0u64;
438        loop {
439            match dec.receive_frame() {
440                Ok(Frame::Audio(a)) => {
441                    got_frame = true;
442                    // synth_square_mod uses the 4-channel "M.K." layout.
443                    assert_eq!(a.data.len(), 4, "one plane per MOD channel");
444                    let expected_plane_len = a.samples as usize * 2;
445                    for plane in &a.data {
446                        assert_eq!(plane.len(), expected_plane_len);
447                    }
448                    for (idx, plane) in a.data.iter().enumerate() {
449                        for chunk in plane.chunks_exact(2) {
450                            let s = i16::from_le_bytes([chunk[0], chunk[1]]);
451                            if s != 0 {
452                                if idx == 0 {
453                                    ch0_nonzero += 1;
454                                } else {
455                                    other_nonzero += 1;
456                                }
457                            }
458                        }
459                    }
460                }
461                Ok(_) => unreachable!("MOD emits audio only"),
462                Err(Error::Eof) => break,
463                Err(e) => panic!("unexpected decode error: {e:?}"),
464            }
465        }
466        assert!(got_frame, "planar decoder produced no frames");
467        // synth_square_mod triggers notes only on channel 0; other
468        // channels must be pure silence.
469        assert!(
470            ch0_nonzero > 100,
471            "expected channel-0 signal, got {ch0_nonzero} non-zero samples"
472        );
473        assert_eq!(
474            other_nonzero, 0,
475            "expected silence on channels 1..=3 (got {other_nonzero} non-zero samples)"
476        );
477    }
478}