Skip to main content

oxideav_mod/
samples.rs

1//! Sample-body extraction for MOD files.
2//!
3//! After the header + pattern data block, the remainder of the file is a
4//! concatenation of raw signed-8-bit sample bodies, in the order samples
5//! appear in the header. The header tells us each body's length in bytes.
6//! Some files are truncated (the last sample's declared length exceeds
7//! the file) — we clamp rather than error.
8
9use crate::header::ModHeader;
10
11/// Per-sample decoded body plus the loop metadata needed by the mixer.
12#[derive(Clone, Debug, Default)]
13pub struct SampleBody {
14    /// Raw signed 8-bit PCM. Empty if the header declared zero length.
15    pub pcm: Vec<i8>,
16    /// Loop start in samples (0 if sample does not loop).
17    pub loop_start: u32,
18    /// Loop length in samples (0 if sample does not loop — spec says
19    /// repeat length of 2 also means "no loop").
20    pub loop_length: u32,
21    /// Default volume 0..=64.
22    pub volume: u8,
23    /// Finetune -8..=7.
24    pub finetune: i8,
25}
26
27impl SampleBody {
28    /// True if this sample has a valid loop region.
29    pub fn is_looped(&self) -> bool {
30        self.loop_length > 2
31    }
32}
33
34impl crate::mixer::SampleSource for SampleBody {
35    fn len(&self) -> usize {
36        self.pcm.len()
37    }
38    fn loop_start(&self) -> usize {
39        if self.is_looped() {
40            self.loop_start as usize
41        } else {
42            0
43        }
44    }
45    fn loop_end(&self) -> usize {
46        if self.is_looped() {
47            (self.loop_start + self.loop_length) as usize
48        } else {
49            self.pcm.len()
50        }
51    }
52    fn loop_kind(&self) -> crate::mixer::LoopKind {
53        if self.is_looped() {
54            crate::mixer::LoopKind::Forward
55        } else {
56            crate::mixer::LoopKind::None
57        }
58    }
59    fn at(&self, idx: usize) -> f32 {
60        self.pcm.get(idx).copied().unwrap_or(0) as f32 / 128.0
61    }
62}
63
64/// Extract all 31 sample bodies from the module bytes.
65///
66/// Samples declared longer than the remaining file are clamped to what's
67/// actually there (many real-world rips are slightly truncated).
68pub fn extract_samples(header: &ModHeader, bytes: &[u8]) -> Vec<SampleBody> {
69    let mut out = Vec::with_capacity(header.samples.len());
70    let mut cursor = header.sample_data_offset();
71    let end = bytes.len();
72
73    for sample in &header.samples {
74        let declared = sample.length as usize;
75        let available = end.saturating_sub(cursor);
76        let take = declared.min(available);
77
78        let pcm: Vec<i8> = if take == 0 {
79            Vec::new()
80        } else {
81            // Reinterpret u8 as i8 (MOD samples are signed 8-bit).
82            bytes[cursor..cursor + take]
83                .iter()
84                .map(|&b| b as i8)
85                .collect()
86        };
87
88        cursor += take;
89
90        // A loop_length of 0 or 2 means "no loop" per the ProTracker spec
91        // (Protracker-effects-MODFIL12.txt §2.2 and Protracker-2.3A-misc-info.txt).
92        // Real-world MOD rips occasionally have loop metadata that exceeds
93        // the actual sample length; clamp to keep the mixer from reading
94        // past the buffer.
95        let (loop_start, loop_length) = if sample.repeat_length > 2 {
96            let pcm_len = pcm.len() as u32;
97            let start = sample.repeat_start.min(pcm_len);
98            let len = sample.repeat_length.min(pcm_len.saturating_sub(start));
99            if len > 2 {
100                (start, len)
101            } else {
102                (0, 0)
103            }
104        } else {
105            (0, 0)
106        };
107
108        out.push(SampleBody {
109            pcm,
110            loop_start,
111            loop_length,
112            volume: sample.volume,
113            finetune: sample.finetune,
114        });
115    }
116
117    out
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::header::parse_header;
124
125    fn build_minimal_mod_with_sample(pcm: &[i8]) -> Vec<u8> {
126        let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
127        // Title
128        out[0..4].copy_from_slice(b"test");
129        // Sample 0: length-in-words at offset 20 + 22..24.
130        let len_words = (pcm.len() / 2) as u16;
131        out[20 + 22..20 + 24].copy_from_slice(&len_words.to_be_bytes());
132        // Volume.
133        out[20 + 25] = 64;
134        // Repeat start.
135        out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
136        // Repeat length.
137        out[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
138        // Song length 1 pattern.
139        out[950] = 1;
140        out[951] = 0x7F;
141        out[952] = 0; // order: pattern 0
142        out[1080..1084].copy_from_slice(b"M.K.");
143        // Pattern 0 — 64 rows × 4 channels × 4 bytes = 1024 bytes of zeros.
144        out.extend(std::iter::repeat_n(0u8, 64 * 4 * 4));
145        // Sample body.
146        out.extend(pcm.iter().map(|&s| s as u8));
147        out
148    }
149
150    #[test]
151    fn extracts_signed_bytes() {
152        let pcm = [10i8, -10, 40, -40, 127, -128];
153        let bytes = build_minimal_mod_with_sample(&pcm);
154        let header = parse_header(&bytes).unwrap();
155        let samples = extract_samples(&header, &bytes);
156        assert_eq!(samples.len(), 31);
157        assert_eq!(samples[0].pcm, pcm);
158        // Remaining samples empty.
159        for s in &samples[1..] {
160            assert!(s.pcm.is_empty());
161        }
162    }
163
164    #[test]
165    fn handles_truncated_body() {
166        let pcm = [1i8, 2, 3, 4];
167        let mut bytes = build_minimal_mod_with_sample(&pcm);
168        // Truncate by 2 bytes.
169        bytes.truncate(bytes.len() - 2);
170        let header = parse_header(&bytes).unwrap();
171        let samples = extract_samples(&header, &bytes);
172        assert_eq!(samples[0].pcm, [1, 2]);
173    }
174}