ot_tools_io/
markers.rs

1/*
2SPDX-License-Identifier: GPL-3.0-or-later
3Copyright © 2024 Mike Robeson [dijksterhuis]
4*/
5
6//! Types and ser/de of `markers.*` binary data files.
7//!
8//! A "Markers" file contains data related to the playback settings of loaded
9//! sample slots
10//! - Trim Start
11//! - Trim End
12//! - Loop Point
13//! - Slices (Start / End / Loop Point)
14//!
15//! The Markers file is dependent on a Project's Sample Slots. Switching out a
16//! sample slot updates playback settings in the markers file.
17//!
18//! TODO: Figure out exactly WHEN update occurs (when adding a file to the slot?)
19
20// TODO
21use crate::slices::{Slice, SliceError};
22use crate::traits::SwapBytes;
23use crate::{
24    HasChecksumField, HasFileVersionField, HasHeaderField, IsDefault, OctatrackFileIO,
25    OtToolsIoError,
26};
27use ot_tools_io_derive::{IntegrityChecks, IsDefaultCheck};
28use serde::{Deserialize, Serialize};
29use serde_big_array::{Array, BigArray};
30use std::array::from_fn;
31use thiserror::Error;
32/*
33# ===== DEFAULT DATA FILE ===== #
3400000000  46 4f 52 4d 00 00 00 00  44 50 53 31 53 41 4d 50  |FORM....DPS1SAMP|
3500000010  00 00 00 00 00 04 00 00  00 00 00 00 00 00 00 00  |................|
3600000020  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
37# ***** REPEATS *****
3800032890  00 00 00 00 00 00 00 04                           |........|
3900032898
40*/
41
42#[derive(Debug, Error)]
43pub enum MarkersError {
44    #[error("invalid loop point: {value}")]
45    Loop { value: u32 },
46    #[error("invalid trim: start={start} end={end}")]
47    Trim { start: u32, end: u32 },
48    #[error("invalid slice count: {value}")]
49    SliceCount { value: u32 },
50    #[error("invalid slice")]
51    Slice(#[from] SliceError),
52}
53
54/// Header data for the markers file
55pub const MARKERS_HEADER: [u8; 21] = [
56    0x46, 0x4f, 0x52, 0x4d, 0x00, 0x00, 0x00, 0x00, 0x44, 0x50, 0x53, 0x31, 0x53, 0x41, 0x4d, 0x50,
57    0x00, 0x00, 0x00, 0x00, 0x00,
58];
59
60/// Current/supported version of markers files.
61pub const MARKERS_FILE_VERSION: u8 = 4;
62
63/// The 'markers' file. Contains sample editor data for all slots in a project.
64#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, IntegrityChecks, IsDefaultCheck)]
65pub struct MarkersFile {
66    #[serde(with = "BigArray")]
67    pub header: [u8; 21],
68
69    /// version of data file. used in OS upgrades for patching files (and checks
70    /// performed on files during loading of a project).
71    ///
72    /// ### background / context
73    ///
74    /// Got this error on the device when i messed up the default markers file
75    /// when i didn't include the `4` (current value for 1.40B).
76    /// ```text
77    /// >>> 2025-04-24 22:06:00 ERROR Couldn't read from markers file ...
78    /// >>> '/test-set-bankcopy/PROJECT-BLANK/markers.work' ('INVALID FILESIZE')
79    /// ```
80    ///
81    /// Additionally, Banks for projects created with 1.40X have a 'version'
82    /// number of 23, while the LESSSELF/V1 project started with 1.25E has a
83    /// version of 15.
84    ///
85    /// So yep. These weird numbers are version numbers for the data types /
86    /// structures / files.
87    pub datatype_version: u8,
88
89    /// flex slots playback data
90    pub flex_slots: Box<Array<SlotMarkers, 136>>,
91
92    /// static slots playback data
93    pub static_slots: Box<Array<SlotMarkers, 128>>,
94
95    // i'm assuming u16 checksum here as well?
96    pub checksum: u16,
97}
98
99impl MarkersFile {
100    fn new(
101        flex_slots: [SlotMarkers; 136],
102        static_slots: [SlotMarkers; 128],
103    ) -> Result<Self, OtToolsIoError> {
104        let mut init = Self {
105            header: MARKERS_HEADER,
106            datatype_version: MARKERS_FILE_VERSION,
107            flex_slots: Array(flex_slots).into(),
108            static_slots: Array(static_slots).into(),
109            checksum: 0,
110        };
111
112        init.checksum = init.calculate_checksum()?;
113        init.validate()?;
114        Ok(init)
115    }
116
117    fn validate(&self) -> Result<bool, MarkersError> {
118        for slot in self.flex_slots.iter() {
119            slot.validate()?;
120        }
121        for slot in self.static_slots.iter() {
122            slot.validate()?;
123        }
124
125        Ok(true)
126    }
127}
128
129#[cfg(test)]
130mod markers_file_validate {
131    use crate::{test_utils::get_blank_proj_dirpath, MarkersFile, OctatrackFileIO, OtToolsIoError};
132    #[test]
133    fn valid() -> Result<(), OtToolsIoError> {
134        let path = get_blank_proj_dirpath().join("markers.work");
135        assert!(MarkersFile::from_data_file(&path)?.validate()?);
136        Ok(())
137    }
138
139    #[test]
140    fn invalid_trim_offset() -> Result<(), OtToolsIoError> {
141        let path = get_blank_proj_dirpath().join("markers.work");
142        let mut x = MarkersFile::from_data_file(&path)?;
143        x.flex_slots[0].trim_offset = 100;
144        assert_eq!(
145            x.validate().unwrap_err().to_string(),
146            "invalid trim: start=100 end=0".to_string()
147        );
148        Ok(())
149    }
150
151    #[test]
152    fn invalid_slice_count() -> Result<(), OtToolsIoError> {
153        let path = get_blank_proj_dirpath().join("markers.work");
154        let mut x = MarkersFile::from_data_file(&path)?;
155        x.flex_slots[0].slice_count = 100;
156        assert_eq!(
157            x.validate().unwrap_err().to_string(),
158            "invalid slice count: 100".to_string()
159        );
160        Ok(())
161    }
162
163    #[test]
164    fn invalid_loop_point() -> Result<(), OtToolsIoError> {
165        let path = get_blank_proj_dirpath().join("markers.work");
166        let mut x = MarkersFile::from_data_file(&path)?;
167        x.flex_slots[0].loop_point = 100;
168        assert_eq!(
169            x.validate().unwrap_err().to_string(),
170            "invalid loop point: 100".to_string()
171        );
172        Ok(())
173    }
174}
175
176impl Default for MarkersFile {
177    fn default() -> Self {
178        Self::new(
179            from_fn(|_| SlotMarkers::default()),
180            from_fn(|_| SlotMarkers::default()),
181        )
182        .unwrap()
183    }
184}
185
186impl SwapBytes for MarkersFile {
187    fn swap_bytes(self) -> Self {
188        let mut flex_slots = self.flex_slots.clone();
189        for (i, slot) in self.flex_slots.iter().enumerate() {
190            flex_slots[i] = slot.clone().swap_bytes();
191        }
192
193        let mut static_slots = self.static_slots.clone();
194        for (i, slot) in self.static_slots.iter().enumerate() {
195            static_slots[i] = slot.clone().swap_bytes();
196        }
197
198        Self {
199            header: self.header,
200            datatype_version: self.datatype_version,
201            flex_slots,
202            static_slots,
203            checksum: self.checksum.swap_bytes(),
204        }
205    }
206}
207
208impl OctatrackFileIO for MarkersFile {
209    fn encode(&self) -> Result<Vec<u8>, OtToolsIoError>
210    where
211        Self: Serialize,
212    {
213        let mut chkd = self.clone();
214        chkd.checksum = self.calculate_checksum()?;
215        let encoded = if cfg!(target_endian = "little") {
216            bincode::serialize(&chkd.swap_bytes())?
217        } else {
218            bincode::serialize(&chkd)?
219        };
220        Ok(encoded)
221    }
222
223    fn decode(bytes: &[u8]) -> Result<Self, OtToolsIoError>
224    where
225        Self: Sized,
226        Self: for<'a> Deserialize<'a>,
227    {
228        let mut x: Self = bincode::deserialize(bytes)?;
229        #[cfg(target_endian = "little")]
230        {
231            x = x.swap_bytes();
232        }
233
234        Ok(x)
235    }
236}
237
238#[cfg(test)]
239mod decode {
240    use crate::{
241        read_bin_file, test_utils::get_blank_proj_dirpath, MarkersFile, OctatrackFileIO,
242        OtToolsIoError,
243    };
244    #[test]
245    fn valid() -> Result<(), OtToolsIoError> {
246        let path = get_blank_proj_dirpath().join("markers.work");
247        let bytes = read_bin_file(&path)?;
248        let s = MarkersFile::decode(&bytes)?;
249        assert_eq!(s, MarkersFile::default());
250        Ok(())
251    }
252}
253
254#[cfg(test)]
255mod encode {
256    use crate::{
257        read_bin_file, test_utils::get_blank_proj_dirpath, MarkersFile, OctatrackFileIO,
258        OtToolsIoError,
259    };
260    #[test]
261    fn valid() -> Result<(), OtToolsIoError> {
262        let path = get_blank_proj_dirpath().join("markers.work");
263        let bytes = read_bin_file(&path)?;
264        let b = MarkersFile::default().encode()?;
265        assert_eq!(b, bytes);
266        Ok(())
267    }
268}
269
270impl HasChecksumField for MarkersFile {
271    fn calculate_checksum(&self) -> Result<u16, OtToolsIoError> {
272        let bytes = bincode::serialize(self)?;
273        let mut chk: u16 = 0;
274        for byte in &bytes[16..bytes.len() - 2] {
275            chk = chk.wrapping_add(*byte as u16);
276        }
277        Ok(chk)
278    }
279    fn check_checksum(&self) -> Result<bool, OtToolsIoError> {
280        Ok(self.checksum == self.calculate_checksum()?)
281    }
282}
283
284#[cfg(test)]
285mod checksum_field {
286    use crate::{HasChecksumField, MarkersFile, OtToolsIoError};
287    #[test]
288    fn valid() -> Result<(), OtToolsIoError> {
289        let mut x = MarkersFile::default();
290        x.checksum = x.calculate_checksum()?;
291        assert!(x.check_checksum()?);
292        Ok(())
293    }
294
295    #[test]
296    fn invalid() -> Result<(), OtToolsIoError> {
297        let x = MarkersFile {
298            checksum: u16::MAX,
299            ..Default::default()
300        };
301        assert!(!x.check_checksum()?);
302        Ok(())
303    }
304
305    mod files {
306        use crate::test_utils::{get_blank_proj_dirpath, get_markers_dirpath};
307        use crate::{HasChecksumField, MarkersFile, OctatrackFileIO, OtToolsIoError};
308        use std::path::Path;
309
310        fn helper_test_chksum(fp: &Path) -> Result<(u16, u16), OtToolsIoError> {
311            let valid = MarkersFile::from_data_file(fp)?;
312            let mut test = valid.clone();
313            test.checksum = 0;
314            let chk = test.calculate_checksum()?;
315            Ok((chk, valid.checksum))
316        }
317
318        #[allow(clippy::field_reassign_with_default)]
319        #[test]
320        fn default_method() -> Result<(), OtToolsIoError> {
321            let (_, valid) = helper_test_chksum(&get_blank_proj_dirpath().join("markers.work"))?;
322            let mut x = MarkersFile::default();
323            x.checksum = 0;
324            let test = x.calculate_checksum()?;
325            assert_eq!(test, valid);
326            Ok(())
327        }
328
329        #[test]
330        fn base_proj_default_file() -> Result<(), OtToolsIoError> {
331            let (test, valid) = helper_test_chksum(&get_blank_proj_dirpath().join("markers.work"))?;
332            assert_eq!(test, valid);
333            Ok(())
334        }
335
336        #[test]
337        fn flex_slot_noedit() -> Result<(), OtToolsIoError> {
338            let (test, valid) =
339                helper_test_chksum(&get_markers_dirpath().join("flex-slot-1-noedit.work"))?;
340            assert_eq!(test, valid);
341            Ok(())
342        }
343
344        #[test]
345        fn flex_slot_1_loop() -> Result<(), OtToolsIoError> {
346            let (test, valid) =
347                helper_test_chksum(&get_markers_dirpath().join("flex-slot-1-loop-edit.work"))?;
348            assert_eq!(test, valid);
349            Ok(())
350        }
351
352        #[test]
353        fn flex_slot_1_slice_1_loop() -> Result<(), OtToolsIoError> {
354            let (test, valid) =
355                helper_test_chksum(&get_markers_dirpath().join("flex-slot-1-slice-1-looped.work"))?;
356            assert_eq!(test, valid);
357            Ok(())
358        }
359
360        #[test]
361        fn flex_slot_1_slice_1_noloop() -> Result<(), OtToolsIoError> {
362            let (test, valid) =
363                helper_test_chksum(&get_markers_dirpath().join("flex-slot-1-slice-1-noloop.work"))?;
364            assert_eq!(test, valid);
365            Ok(())
366        }
367
368        #[test]
369        fn flex_slot_1_slice_4_noloop() -> Result<(), OtToolsIoError> {
370            let (test, valid) =
371                helper_test_chksum(&get_markers_dirpath().join("flex-slot-1-slice-4-noloop.work"))?;
372            assert_eq!(test, valid);
373            Ok(())
374        }
375
376        #[test]
377        fn flex_slot_1_start_edit() -> Result<(), OtToolsIoError> {
378            let (test, valid) =
379                helper_test_chksum(&get_markers_dirpath().join("flex-slot-1-start-edit.work"))?;
380            assert_eq!(test, valid);
381            Ok(())
382        }
383
384        #[test]
385        fn flex_slot_128_noedit() -> Result<(), OtToolsIoError> {
386            let (test, valid) =
387                helper_test_chksum(&get_markers_dirpath().join("flex-slot-128-noedit.work"))?;
388            assert_eq!(test, valid);
389            Ok(())
390        }
391
392        #[test]
393        fn recorder_slot_1_noedit() -> Result<(), OtToolsIoError> {
394            let (test, valid) =
395                helper_test_chksum(&get_markers_dirpath().join("recorder-slot-1-noedit.work"))?;
396            assert_eq!(test, valid);
397            Ok(())
398        }
399
400        #[test]
401        fn static_slot_1_noedit() -> Result<(), OtToolsIoError> {
402            let (test, valid) =
403                helper_test_chksum(&get_markers_dirpath().join("static-slot-1-noedit.work"))?;
404            assert_eq!(test, valid);
405            Ok(())
406        }
407
408        #[test]
409        fn static_slot_128_noedit() -> Result<(), OtToolsIoError> {
410            let (test, valid) =
411                helper_test_chksum(&get_markers_dirpath().join("static-slot-128-noedit.work"))?;
412            assert_eq!(test, valid);
413            Ok(())
414        }
415    }
416}
417
418impl HasHeaderField for MarkersFile {
419    fn check_header(&self) -> Result<bool, OtToolsIoError> {
420        Ok(self.header == MARKERS_HEADER)
421    }
422}
423
424#[cfg(test)]
425mod header_field {
426    use crate::{HasHeaderField, MarkersFile, OtToolsIoError};
427    #[test]
428    fn valid() -> Result<(), OtToolsIoError> {
429        assert!(MarkersFile::default().check_header()?);
430        Ok(())
431    }
432
433    #[test]
434    fn invalid() -> Result<(), OtToolsIoError> {
435        let mut mutated = MarkersFile::default();
436        mutated.header[0] = 0x00;
437        mutated.header[20] = 111;
438        assert!(!mutated.check_header()?);
439        Ok(())
440    }
441}
442
443impl HasFileVersionField for MarkersFile {
444    fn check_file_version(&self) -> Result<bool, OtToolsIoError> {
445        Ok(self.datatype_version == MARKERS_FILE_VERSION)
446    }
447}
448
449#[cfg(test)]
450mod file_version_field {
451    use crate::{HasFileVersionField, MarkersFile, OtToolsIoError};
452    #[test]
453    fn valid() -> Result<(), OtToolsIoError> {
454        assert!(MarkersFile::default().check_file_version()?);
455        Ok(())
456    }
457
458    #[test]
459    fn invalid() -> Result<(), OtToolsIoError> {
460        let mut mutated = MarkersFile {
461            datatype_version: 0,
462            ..Default::default()
463        };
464        mutated.datatype_version = 0;
465        assert!(!mutated.check_file_version()?);
466        Ok(())
467    }
468}
469
470/// Slot playback trigger points/markers. Generally, data related to trim, looping and slices for each slot.
471/// Global slot settings like quantization/time-stretch are stored in the [`crate::projects::SlotAttributes`] type
472///
473/// NOTE: On the naming for this -- the Octatrack manual specifically refers to
474/// > SAVE SAMPLE SETTINGS will save the trim, slice and attribute settings in a
475/// > separate file and link it to the sample currently being edited.
476/// > -- page 87
477///
478/// So ... these are the Slot MARKERS (trim/loop/slice markers) which are saved
479/// to a settings file.
480#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Hash)]
481pub struct SlotMarkers {
482    /// main sample trim offset (start)
483    pub trim_offset: u32,
484
485    /// main sample trim end
486    pub trim_end: u32,
487
488    /// main sample loop point
489    pub loop_point: u32,
490
491    /// slices array for slice position editing
492    #[serde(with = "BigArray")]
493    pub slices: [Slice; 64],
494
495    /// number of slices
496    pub slice_count: u32,
497}
498
499impl Default for SlotMarkers {
500    fn default() -> Self {
501        Self {
502            trim_offset: 0,
503            trim_end: 0,
504            loop_point: 0,
505            slices: from_fn(|_| Slice::default()),
506            slice_count: 0,
507        }
508    }
509}
510
511impl SwapBytes for SlotMarkers {
512    fn swap_bytes(self) -> Self {
513        let mut slices: [Slice; 64] = self.slices;
514
515        for (i, slice) in self.slices.iter().enumerate() {
516            slices[i] = slice.swap_bytes();
517        }
518
519        Self {
520            trim_offset: self.trim_offset.swap_bytes(),
521            trim_end: self.trim_end.swap_bytes(),
522            loop_point: self.loop_point.swap_bytes(),
523            slices,
524            slice_count: self.slice_count.swap_bytes(),
525        }
526    }
527}
528
529impl SlotMarkers {
530    fn validate(&self) -> Result<bool, MarkersError> {
531        for slice in self.slices.iter() {
532            slice.validate()?;
533        }
534        if self.trim_offset > self.trim_end {
535            return Err(MarkersError::Trim {
536                start: self.trim_offset,
537                end: self.trim_end,
538            });
539        }
540
541        let slice_count = self.slices.iter().filter(|x| !x.is_default()).count();
542
543        if self.slice_count != slice_count as u32 {
544            return Err(MarkersError::SliceCount {
545                value: self.slice_count,
546            });
547        }
548
549        if !crate::check_loop_point(self.loop_point, self.trim_offset, self.trim_end) {
550            return Err(MarkersError::Loop {
551                value: self.loop_point,
552            });
553        }
554
555        Ok(true)
556    }
557}
558
559#[cfg(test)]
560mod slot_markers_validate {
561    use super::SlotMarkers;
562    use crate::OtToolsIoError;
563    #[test]
564    fn valid() -> Result<(), OtToolsIoError> {
565        assert!(SlotMarkers::default().validate()?);
566        Ok(())
567    }
568
569    #[test]
570    fn invalid_trim_offset() -> Result<(), OtToolsIoError> {
571        let x = SlotMarkers {
572            trim_offset: 100,
573            ..SlotMarkers::default()
574        };
575        assert_eq!(
576            x.validate().unwrap_err().to_string(),
577            "invalid trim: start=100 end=0".to_string()
578        );
579        Ok(())
580    }
581
582    #[test]
583    fn invalid_slice_count() -> Result<(), OtToolsIoError> {
584        let x = SlotMarkers {
585            slice_count: 100,
586            ..SlotMarkers::default()
587        };
588        assert_eq!(
589            x.validate().unwrap_err().to_string(),
590            "invalid slice count: 100".to_string()
591        );
592        Ok(())
593    }
594
595    #[test]
596    fn invalid_loop_point() -> Result<(), OtToolsIoError> {
597        let x = SlotMarkers {
598            loop_point: 100,
599            ..SlotMarkers::default()
600        };
601        assert_eq!(
602            x.validate().unwrap_err().to_string(),
603            "invalid loop point: 100".to_string()
604        );
605        Ok(())
606    }
607}