ot_tools_io/
samples.rs

1/*
2SPDX-License-Identifier: GPL-3.0-or-later
3Copyright © 2024 Mike Robeson [dijksterhuis]
4*/
5
6//! Types and ser/de of `*.ot` binary data files.
7
8use crate::settings::{LoopMode, TimeStretchMode, TrigQuantizationMode};
9use crate::slices::{Slice, Slices};
10use crate::traits::SwapBytes;
11use crate::{
12    HasChecksumField, HasFileVersionField, HasHeaderField, OctatrackFileIO, OtToolsIoError,
13};
14use ot_tools_io_derive::IntegrityChecks;
15use serde::{Deserialize, Serialize};
16use serde_big_array::BigArray;
17use thiserror::Error;
18
19#[cfg(test)]
20mod test_utils {
21    use super::{
22        LoopConfig, LoopMode, SampleSettingsError, SampleSettingsFile, Slice, Slices,
23        TimeStretchMode, TrigQuantizationMode, TrimConfig, DEFAULT_GAIN, DEFAULT_TEMPO,
24        SAMPLES_FILE_VERSION, SAMPLES_HEADER,
25    };
26
27    pub(crate) fn create_mock_new(
28        tempo: Option<u32>,
29        gain: Option<u16>,
30    ) -> Result<SampleSettingsFile, SampleSettingsError> {
31        let trim_config = TrimConfig {
32            start: 0,
33            end: 0,
34            length: 0,
35        };
36
37        let loop_config = LoopConfig {
38            start: 0,
39            length: 0,
40            mode: LoopMode::default(),
41        };
42
43        let default_slice = Slice {
44            trim_start: 0,
45            trim_end: 0,
46            loop_start: 0xFFFFFFFF,
47        };
48
49        let slices: [Slice; 64] = [default_slice; 64];
50
51        let slice_conf = Slices { slices, count: 0 };
52
53        SampleSettingsFile::new(
54            &tempo.unwrap_or(DEFAULT_TEMPO),
55            &TimeStretchMode::Off,
56            &TrigQuantizationMode::PatternLength,
57            &gain.unwrap_or(DEFAULT_GAIN),
58            &trim_config,
59            &loop_config,
60            &slice_conf,
61        )
62    }
63
64    pub(crate) fn mock_0_slice() -> SampleSettingsFile {
65        SampleSettingsFile {
66            header: SAMPLES_HEADER,
67            datatype_version: SAMPLES_FILE_VERSION,
68            unknown: 1,
69            tempo: 2281,
70            trim_len: 800,
71            loop_len: 800,
72            stretch: 2,
73            loop_mode: 0,
74            gain: 48,
75            quantization: 255,
76            trim_start: 0,
77            trim_end: 890839,
78            loop_start: 0,
79            slices: Slices::default().slices,
80            slices_len: 0,
81            checksum: 998,
82        }
83    }
84}
85
86#[derive(Debug, Error)]
87pub enum SampleSettingsError {
88    #[error("invalid gain, must be u16 in range 0 <= x <= 96: {value}")]
89    GainOutOfBounds { value: u32 },
90    #[error("invalid tempo value, must be u32 in range 720 <= x <= 7200: {value}")]
91    TempoOutOfBounds { value: u32 },
92}
93
94// in `hexdump -C` format:
95// ```
96// FORM....DPS1SMPA
97// ......
98// ```
99/// Standard header bytes in a sample settings file
100pub const SAMPLES_HEADER: [u8; 21] = [
101    0x46, 0x4F, 0x52, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x44, 0x50, 0x53, 0x31, 0x53, 0x4D, 0x50, 0x41,
102    0x00, 0x00, 0x00, 0x00, 0x00,
103];
104
105/// Current/supported datatype version for a sample settings file
106pub const SAMPLES_FILE_VERSION: u8 = 2;
107
108pub const DEFAULT_TEMPO: u32 = 2880;
109pub const DEFAULT_GAIN: u16 = 48;
110
111/// Struct to create a valid Octatrack `.ot` file a.k.a. a sample settings file.
112/// General metadata for the sample's configuration on the OT
113/// and the slice array with pointer positions for the sliced WAV.
114///
115/// NOTE: On the naming for this -- the Octatrack manual specifically refers to
116/// > SAVE SAMPLE SETTINGS will save the trim, slice and attribute settings in a
117/// > separate file and link it to the sample currently being edited.
118/// > -- page 87.
119///
120/// So ... this is the SETTINGS file.
121#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Hash, IntegrityChecks)]
122pub struct SampleSettingsFile {
123    /// Header
124    pub header: [u8; 21],
125
126    /// Datatype's version ID//
127    pub datatype_version: u8,
128
129    /// Unknown data -- can sometimes be 1, usually 0.
130    pub unknown: u8,
131
132    /// Tempo is always the machine UI's BPM multiplied by 24
133    pub tempo: u32,
134
135    /// Number of bars for the sample trim length marker.
136    /// By default, trim length should be equal to trim end,
137    /// and probably loop length too for drum hit sample chains.
138    pub trim_len: u32,
139
140    /// Number of bars for the sample loop length marker.
141    /// By default, loop length should be equal to trim length for sample chains.
142    pub loop_len: u32,
143
144    /// Default time-stretch algorithm applied to the sample.
145    /// See the [`TimeStretchMode`] enum for suitable choices.
146    pub stretch: u32,
147
148    /// Default loop mode applied to the sample.
149    /// See the [`LoopMode`] enum for suitable choices.
150    pub loop_mode: u32,
151
152    /// Gain of the sample.
153    /// -24.0 db <= x <= +24 db range in the machine's UI, with increments of 0.5 db changes.
154    /// 0 <= x <= 96 range in binary data file.
155    pub gain: u16,
156
157    /// Default trig quantization mode applied to the sample.
158    /// See the [`TrigQuantizationMode`] enum for suitable choices.
159    pub quantization: u8,
160
161    /// Where the trim start marker is placed for the sample, measured in bars.
162    /// Default is 0 (start of sample).
163    pub trim_start: u32,
164
165    /// Where the trim end marker is placed for the sample.
166    /// When the sample is being played in normal mode (i.e. not using slices),
167    /// the Octatrack will not play samples past this point.
168    /// By default, trim length should be equal to trim end,
169    /// and probably loop length too for drum hit sample chains.
170    pub trim_end: u32,
171
172    /// Start position for any loops. Default should be the same as trim start.
173    /// Measured in bars.
174    /// A note from the Octatrack manual on loop point/start behaviour:
175    /// > If a loop point is set, the sample will play from the start point to the
176    /// > end point, then loop from the loop point to the end point
177    pub loop_start: u32,
178
179    /// 64 length array containing `Slice`s.
180    /// See the `Slice` struct for more details.
181    /// Any empty slice positions should have zero-valued struct fields.
182    #[serde(with = "BigArray")]
183    pub slices: [Slice; 64],
184
185    /// Number of usable `Slice`s in this sample.
186    /// Used by the Octatrack to ignore zero-valued `Slice`s in the `slices` array when loading the sample.
187    pub slices_len: u32,
188
189    /// Checksum value for the struct.
190    /// This must be calculated **after** the struct is created on little-endian systems
191    /// (requires byte swapping all struct fields to get the correct checksum value).
192    pub checksum: u16,
193}
194
195impl SampleSettingsFile {
196    /// Create a new `SampleSettingsFile`
197    ///
198    /// Values like `tempo` and `gain` need to be normalized to relevant OT
199    /// bounds before using this method.
200    /// ```rust
201    /// # use std::array::from_fn;
202    /// # use ot_tools_io::samples::{SampleSettingsFile, TrimConfig, LoopConfig};
203    /// # use ot_tools_io::settings::{
204    /// #    TrigQuantizationMode,
205    /// #    LoopMode,
206    /// #    TimeStretchMode,
207    /// # };
208    /// # use ot_tools_io::slices::{Slices, Slice};
209    /// # let slice_arr: [Slice; 64] = from_fn(|_| Slice::default());
210    /// # let slices: Slices = Slices { slices: slice_arr, count: 0 };
211    /// let x = SampleSettingsFile::new(
212    ///     &2880,
213    ///     // ...
214    ///     # &TimeStretchMode::default(),
215    ///     # &TrigQuantizationMode::default(),
216    ///     &48,
217    ///     // ...
218    ///     # &TrimConfig { start: 0, end: 100, length: 25 },
219    ///     # &LoopConfig { start: 0, length: 100, mode: LoopMode::Off },
220    ///     # &slices,
221    /// ).unwrap();
222    /// assert_eq!(x.tempo, 2880);
223    /// assert_eq!(x.gain, 48);
224    /// ```
225    pub fn new(
226        tempo: &u32,
227        stretch: &TimeStretchMode,
228        quantization: &TrigQuantizationMode,
229        gain: &u16,
230        trim_config: &TrimConfig,
231        loop_config: &LoopConfig,
232        slices: &Slices,
233    ) -> Result<Self, SampleSettingsError> {
234        #[allow(clippy::manual_range_contains)]
235        if tempo < &720 || tempo > &7200 {
236            return Err(SampleSettingsError::TempoOutOfBounds { value: *tempo });
237        }
238
239        #[allow(clippy::manual_range_contains)]
240        if gain < &0 || gain > &96 {
241            return Err(SampleSettingsError::GainOutOfBounds {
242                value: *gain as u32,
243            });
244        }
245
246        Ok(Self {
247            header: SAMPLES_HEADER,
248            datatype_version: SAMPLES_FILE_VERSION,
249            unknown: 0,
250            gain: *gain,
251            stretch: (*stretch).into(),
252            tempo: *tempo,
253            quantization: (*quantization).into(),
254            trim_start: trim_config.start,
255            trim_end: trim_config.end,
256            trim_len: trim_config.length, // bin lengths are multiplied by 100?
257            loop_start: loop_config.start,
258            loop_len: loop_config.length, // bin lengths are multiplied by 100?
259            loop_mode: loop_config.mode.into(),
260            slices: slices.slices,
261            slices_len: slices.count,
262            checksum: 0,
263        })
264    }
265}
266
267#[cfg(test)]
268mod new {
269    use super::test_utils::create_mock_new;
270    use crate::OtToolsIoError;
271    #[test]
272    fn invalid_oob_temp() -> Result<(), OtToolsIoError> {
273        assert_eq!(
274            create_mock_new(Some(7201), None).unwrap_err().to_string(),
275            "invalid tempo value, must be u32 in range 720 <= x <= 7200: 7201".to_string(),
276        );
277        Ok(())
278    }
279
280    #[test]
281    fn invalid_oob_gain() -> Result<(), OtToolsIoError> {
282        assert_eq!(
283            create_mock_new(None, Some(97)).unwrap_err().to_string(),
284            "invalid gain, must be u16 in range 0 <= x <= 96: 97".to_string(),
285        );
286        Ok(())
287    }
288}
289
290impl SwapBytes for SampleSettingsFile {
291    fn swap_bytes(self) -> Self {
292        let mut bswapped_slices: [Slice; 64] = self.slices;
293
294        for (i, slice) in self.slices.iter().enumerate() {
295            bswapped_slices[i] = slice.swap_bytes();
296        }
297
298        Self {
299            header: SAMPLES_HEADER,
300            datatype_version: SAMPLES_FILE_VERSION,
301            unknown: self.unknown,
302            tempo: self.tempo.swap_bytes(),
303            trim_len: self.trim_len.swap_bytes(),
304            loop_len: self.loop_len.swap_bytes(),
305            stretch: self.stretch.swap_bytes(),
306            loop_mode: self.loop_mode.swap_bytes(),
307            gain: self.gain.swap_bytes(),
308            quantization: self.quantization.swap_bytes(),
309            trim_start: self.trim_start.swap_bytes(),
310            trim_end: self.trim_end.swap_bytes(),
311            loop_start: self.loop_start.swap_bytes(),
312            slices: bswapped_slices,
313            slices_len: self.slices_len.swap_bytes(),
314            checksum: self.checksum.swap_bytes(),
315        }
316    }
317}
318
319impl OctatrackFileIO for SampleSettingsFile {
320    /// Encodes struct data to binary representation, after some pre-processing.
321    ///
322    /// Before serializing, will:
323    /// 1. modify tempo and gain values to machine ranges
324    /// 2. swaps bytes of values (when current system is little-endian)
325    /// 3. generate checksum value
326    fn encode(&self) -> Result<Vec<u8>, OtToolsIoError> {
327        let mut chkd = self.clone();
328        chkd.checksum = self.calculate_checksum()?;
329
330        let encoded = if cfg!(target_endian = "little") {
331            bincode::serialize(&chkd.swap_bytes())?
332        } else {
333            bincode::serialize(&chkd)?
334        };
335        Ok(encoded)
336    }
337
338    /// Decode raw bytes of a `.ot` data file into a new struct,
339    /// swap byte values if system is little-endian then do some minor
340    /// post-processing to get user-friendly settings values.
341    fn decode(bytes: &[u8]) -> Result<Self, OtToolsIoError> {
342        let decoded: Self = bincode::deserialize(bytes)?;
343        let mut bswapd = decoded.clone();
344
345        // swapping bytes is one required when running on little-endian systems
346        if cfg!(target_endian = "little") {
347            bswapd = decoded.swap_bytes();
348        }
349
350        Ok(bswapd)
351    }
352}
353
354#[cfg(test)]
355mod decode {
356    use crate::read_bin_file;
357    use crate::samples::test_utils::mock_0_slice;
358    use crate::test_utils::get_samples_dirpath;
359    use crate::{OctatrackFileIO, OtToolsIoError, SampleSettingsFile};
360    #[test]
361    fn valid() -> Result<(), OtToolsIoError> {
362        let path = get_samples_dirpath().join("checksum").join("0slices.ot");
363        let bytes = read_bin_file(&path)?;
364        let s = SampleSettingsFile::decode(&bytes)?;
365        assert_eq!(s, mock_0_slice());
366        Ok(())
367    }
368}
369
370#[cfg(test)]
371mod encode {
372    use crate::read_bin_file;
373    use crate::samples::test_utils::mock_0_slice;
374    use crate::test_utils::get_samples_dirpath;
375    use crate::{OctatrackFileIO, OtToolsIoError};
376    #[test]
377    fn valid() -> Result<(), OtToolsIoError> {
378        let path = get_samples_dirpath().join("checksum").join("0slices.ot");
379        let bytes = read_bin_file(&path)?;
380        let b = mock_0_slice().encode()?;
381        assert_eq!(b, bytes);
382        Ok(())
383    }
384}
385
386impl HasChecksumField for SampleSettingsFile {
387    // tests for this are in the main tests directory -- `samples_settings_files.rs`
388    fn calculate_checksum(&self) -> Result<u16, OtToolsIoError> {
389        let bytes = bincode::serialize(&self)?;
390
391        // skip header and checksum byte values
392        let checksum_bytes = &bytes[16..bytes.len() - 2];
393
394        let chk: u32 = checksum_bytes
395            .iter()
396            .map(|x| *x as u32)
397            .sum::<u32>()
398            .rem_euclid(u16::MAX as u32 + 1);
399
400        Ok(chk as u16)
401    }
402
403    fn check_checksum(&self) -> Result<bool, OtToolsIoError> {
404        Ok(self.checksum == self.calculate_checksum()?)
405    }
406}
407
408#[cfg(test)]
409mod checksum_field {
410    use super::test_utils::create_mock_new;
411    use crate::{HasChecksumField, OtToolsIoError};
412    #[test]
413    fn valid() -> Result<(), OtToolsIoError> {
414        let mut x = create_mock_new(None, None)?;
415        x.checksum = x.calculate_checksum()?;
416        assert!(x.check_checksum()?);
417        Ok(())
418    }
419
420    #[test]
421    fn invalid() -> Result<(), OtToolsIoError> {
422        let mut x = create_mock_new(None, None)?;
423        x.checksum = x.calculate_checksum()?;
424        x.checksum = 0;
425        assert!(!x.check_checksum()?);
426        Ok(())
427    }
428}
429
430impl HasHeaderField for SampleSettingsFile {
431    fn check_header(&self) -> Result<bool, OtToolsIoError> {
432        Ok(self.header == SAMPLES_HEADER)
433    }
434}
435
436#[cfg(test)]
437mod header_field {
438    use super::test_utils::create_mock_new;
439    use crate::{HasHeaderField, OtToolsIoError};
440    #[test]
441    fn valid() -> Result<(), OtToolsIoError> {
442        assert!(create_mock_new(None, None)?.check_header()?);
443        Ok(())
444    }
445
446    #[test]
447    fn invalid() -> Result<(), OtToolsIoError> {
448        let mut mutated = create_mock_new(None, None)?;
449        mutated.header[0] = 0x00;
450        mutated.header[20] = 111;
451        assert!(!mutated.check_header()?);
452        Ok(())
453    }
454}
455
456impl HasFileVersionField for SampleSettingsFile {
457    fn check_file_version(&self) -> Result<bool, OtToolsIoError> {
458        Ok(self.datatype_version == SAMPLES_FILE_VERSION)
459    }
460}
461
462#[cfg(test)]
463mod file_version_field {
464    use super::test_utils::create_mock_new;
465    use crate::{HasFileVersionField, OtToolsIoError};
466    #[test]
467    fn valid() -> Result<(), OtToolsIoError> {
468        assert!(create_mock_new(None, None)?.check_file_version()?);
469        Ok(())
470    }
471
472    #[test]
473    fn invalid() -> Result<(), OtToolsIoError> {
474        let mut mutated = create_mock_new(None, None)?;
475        mutated.datatype_version = 0;
476        assert!(!mutated.check_file_version()?);
477        Ok(())
478    }
479}
480
481/// A helper struct for the OT Sample's Trim settings
482
483#[derive(PartialEq, Debug, Clone, Copy)]
484pub struct TrimConfig {
485    /// Start of full audio sample (n samples)
486    pub start: u32,
487    /// End of full audio sample (n samples)
488    pub end: u32,
489    /// Length of audio sample to play before stopping/looping playback
490    /// NOTE: This is measured in number of bars.
491    pub length: u32,
492}
493
494impl From<SampleSettingsFile> for TrimConfig {
495    fn from(decoded: SampleSettingsFile) -> Self {
496        Self {
497            start: decoded.trim_start,
498            end: decoded.trim_end,
499            length: decoded.trim_len,
500        }
501    }
502}
503
504impl From<&SampleSettingsFile> for TrimConfig {
505    fn from(decoded: &SampleSettingsFile) -> Self {
506        Self::from(decoded.clone())
507    }
508}
509
510#[cfg(test)]
511mod trim_config_from {
512    use super::{test_utils::create_mock_new, TrimConfig};
513    use crate::OtToolsIoError;
514
515    #[test]
516    fn owned() -> Result<(), OtToolsIoError> {
517        let s = create_mock_new(None, None)?;
518        assert_eq!(
519            TrimConfig::from(s),
520            TrimConfig {
521                start: 0,
522                end: 0,
523                length: 0,
524            }
525        );
526        Ok(())
527    }
528    #[test]
529    fn borrowed() -> Result<(), OtToolsIoError> {
530        let s = create_mock_new(None, None)?;
531        assert_eq!(
532            TrimConfig::from(&s),
533            TrimConfig {
534                start: 0,
535                end: 0,
536                length: 0,
537            }
538        );
539        Ok(())
540    }
541}
542
543/// A helper struct for the OT Sample's Loop settings
544
545#[derive(PartialEq, Debug, Clone, Copy)]
546pub struct LoopConfig {
547    /// Loop start position for the audio sample (n samples).
548    pub start: u32,
549
550    /// Length of the loop for the audio sample (n samples).
551    pub length: u32,
552
553    /// Type of looping mode.
554    pub mode: LoopMode,
555}
556
557impl LoopConfig {
558    pub fn new(start: u32, length: u32, mode: LoopMode) -> Self {
559        Self {
560            start,
561            length,
562            mode,
563        }
564    }
565}
566
567#[cfg(test)]
568mod loop_config_new {
569
570    use crate::samples::{LoopConfig, LoopMode};
571
572    #[test]
573    fn test_new_sample_loop_config_loop_off() {
574        assert_eq!(
575            LoopConfig::new(0, 10, LoopMode::Off),
576            LoopConfig {
577                start: 0,
578                length: 10,
579                mode: LoopMode::Off
580            }
581        );
582    }
583
584    #[test]
585    fn test_new_sample_loop_config_umin_start_umax_length() {
586        assert_eq!(
587            LoopConfig::new(u32::MIN, u32::MAX, LoopMode::Off),
588            LoopConfig {
589                start: u32::MIN,
590                length: u32::MAX,
591                mode: LoopMode::Off
592            }
593        );
594    }
595
596    #[test]
597    fn test_new_sample_loop_config_loop_normal() {
598        assert_eq!(
599            LoopConfig::new(0, 10, LoopMode::Normal),
600            LoopConfig {
601                start: 0,
602                length: 10,
603                mode: LoopMode::Normal
604            }
605        );
606    }
607
608    #[test]
609    fn test_new_sample_loop_config_loop_pingpong() {
610        assert_eq!(
611            LoopConfig::new(0, 10, LoopMode::PingPong),
612            LoopConfig {
613                start: 0,
614                length: 10,
615                mode: LoopMode::PingPong
616            }
617        );
618    }
619}
620
621impl From<SampleSettingsFile> for LoopConfig {
622    fn from(decoded: SampleSettingsFile) -> Self {
623        Self::new(
624            decoded.loop_start,
625            decoded.loop_len,
626            LoopMode::try_from(&(decoded.loop_mode as u8)).unwrap_or(LoopMode::default()),
627        )
628    }
629}
630
631impl From<&SampleSettingsFile> for LoopConfig {
632    fn from(decoded: &SampleSettingsFile) -> Self {
633        Self::from(decoded.clone())
634    }
635}
636
637#[cfg(test)]
638mod loop_config_from {
639    use super::{
640        test_utils::create_mock_new, LoopConfig, LoopMode, OtToolsIoError, SampleSettingsFile,
641        Slices, DEFAULT_GAIN, DEFAULT_TEMPO, SAMPLES_FILE_VERSION, SAMPLES_HEADER,
642    };
643
644    #[test]
645    fn owned() -> Result<(), OtToolsIoError> {
646        let s = create_mock_new(None, None)?;
647        assert_eq!(
648            LoopConfig::from(s),
649            LoopConfig {
650                start: 0,
651                length: 0,
652                mode: LoopMode::default(),
653            }
654        );
655        Ok(())
656    }
657    #[test]
658    fn borrowed() -> Result<(), OtToolsIoError> {
659        let s = create_mock_new(None, None)?;
660        assert_eq!(
661            LoopConfig::from(&s),
662            LoopConfig {
663                start: 0,
664                length: 0,
665                mode: LoopMode::default(),
666            }
667        );
668        Ok(())
669    }
670
671    fn mock_for_loop_conf(
672        loop_start: u32,
673        loop_len: u32,
674        loop_mode: LoopMode,
675    ) -> SampleSettingsFile {
676        SampleSettingsFile {
677            loop_len,
678            loop_start,
679            loop_mode: loop_mode.into(),
680            header: SAMPLES_HEADER,
681            datatype_version: SAMPLES_FILE_VERSION,
682            tempo: DEFAULT_TEMPO,
683            unknown: 0,
684            trim_len: 0,
685            stretch: 0,
686            gain: DEFAULT_GAIN,
687            quantization: 0,
688            trim_start: 0,
689            trim_end: 0,
690            slices: Slices::default().slices,
691            slices_len: 0,
692            checksum: 0,
693        }
694    }
695
696    #[test]
697    fn test_umin_start_umax_len() {
698        assert_eq!(
699            LoopConfig::from(mock_for_loop_conf(u32::MIN, u32::MAX, LoopMode::Off)),
700            LoopConfig {
701                start: u32::MIN,
702                length: u32::MAX,
703                mode: LoopMode::Off
704            }
705        );
706    }
707
708    #[test]
709    fn test_loop_off() {
710        assert_eq!(
711            LoopConfig::from(mock_for_loop_conf(0, 10, LoopMode::Off)),
712            LoopConfig {
713                start: 0,
714                length: 10,
715                mode: LoopMode::Off
716            }
717        );
718    }
719
720    #[test]
721    fn test_loop_normal() {
722        assert_eq!(
723            LoopConfig::from(mock_for_loop_conf(0, 10, LoopMode::Normal)),
724            LoopConfig {
725                start: 0,
726                length: 10,
727                mode: LoopMode::Normal
728            }
729        );
730    }
731
732    #[test]
733    fn test_loop_pingpong() {
734        assert_eq!(
735            LoopConfig::from(mock_for_loop_conf(0, 10, LoopMode::PingPong)),
736            LoopConfig {
737                start: 0,
738                length: 10,
739                mode: LoopMode::PingPong
740            }
741        );
742    }
743}