Skip to main content

ot_tools_io/projects/
slots.rs

1/*
2SPDX-License-Identifier: GPL-3.0-or-later
3Copyright © 2026 Mike Robeson [dijksterhuis]
4*/
5
6//! Types and parsing of the attributes data for a project's sample slots.
7//! Used in the [`crate::projects::ProjectFile`] type.
8//!
9//! NOTE: Sample slot attributes here refer to the non-'playback markers' data.
10//! See [crate::markers::MarkersFile] for information on trim/loop/slice
11//! settings for a project's sample slot.
12
13/*
14Example data:
15[SAMPLE]\r\nTYPE=FLEX\r\nSLOT=001\r\nPATH=../AUDIO/flex.wav\r\nTRIM_BARSx100=173\r\nTSMODE=2\r\nLOOPMODE=1\r\nGAIN=48\r\nTRIGQUANTIZATION=-1\r\n[/SAMPLE]
16-----
17
18[SAMPLE]
19TYPE=FLEX
20SLOT=001
21PATH=../AUDIO/flex.wav
22TRIM_BARSx100=173
23TSMODE=2
24LOOPMODE=1
25GAIN=48
26TRIGQUANTIZATION=-1
27[/SAMPLE]
28*/
29mod deserialize;
30mod parsing;
31mod serialize;
32
33use crate::generics::{PlaybackSlots, RecordingBufferSlots, Slots};
34use crate::settings::{LoopMode, SlotType, TimeStretchMode, TrigQuantizationMode};
35use crate::OtToolsIoError;
36use ot_tools_io_derive::{AsMutDerive, AsRefDerive};
37use serde::Serialize;
38use std::array::from_fn;
39use std::path::PathBuf;
40use thiserror::Error;
41
42#[cfg(test)]
43mod test_utils {
44    use crate::projects::slots::SlotAttributes;
45    use crate::settings::LoopMode;
46    use crate::settings::SlotType;
47    use crate::settings::TimeStretchMode;
48    use crate::settings::TrigQuantizationMode;
49    use crate::OtToolsIoError;
50    use std::path::PathBuf;
51
52    pub(crate) fn new_slot_attr_full_args() -> Result<SlotAttributes, OtToolsIoError> {
53        SlotAttributes::new(
54            SlotType::Static,
55            100,
56            Some(PathBuf::from("../AUDIO/location.wav")),
57            Some(TimeStretchMode::default()),
58            Some(LoopMode::default()),
59            Some(TrigQuantizationMode::default()),
60            Some(48),
61            Some(3360),
62        )
63    }
64    pub(crate) fn new_slot_attr_minimal_args() -> Result<SlotAttributes, OtToolsIoError> {
65        SlotAttributes::new(SlotType::Static, 100, None, None, None, None, None, None)
66    }
67}
68
69#[allow(clippy::enum_variant_names)]
70#[derive(Debug, Error)]
71pub enum ProjectSlotsError {
72    #[error("invalid slot_id ({value}), must be in range 1 <= x <= 128")]
73    SlotIdOutOfBounds { value: u8 },
74    #[error("invalid tempo ({value}), must be in range 720 <= x <= 7200")]
75    TempoOutOfBounds { value: u16 },
76    #[error("invalid gain ({value}), must be in range 24 <= x <= 120")]
77    GainOutOfBounds { value: u8 },
78    #[error("failed to parse sample settings data")]
79    SettingsRead,
80    #[error("failed to canonicalize slot path ")]
81    SlotPathCanon(#[from] std::io::Error),
82}
83
84/// Default tempo for the new slots.
85pub const DEFAULT_TEMPO: u16 = 2880;
86
87/// Default gain for new slots.
88pub const DEFAULT_GAIN: u8 = 48;
89
90/// A sample slot's global playback settings -- trig quantization, bpm,
91/// timestrech mode ... anything applied to the sample globally .
92/// The Octatrack only stores data when an audio file has been assigned to a sample slot.
93///
94/// # Naming
95/// The Octatrack manual specifically refers to
96/// > SAVE SAMPLE SETTINGS will save the trim, slice and **attribute** settings in a
97/// > separate file and link it to the sample currently being edited.
98/// > -- page 87
99///
100/// So ... these are the SLOT ATTRIBUTES which are saved to a SETTINGS FILE.
101///
102/// # Non-`Copy`
103///
104/// This type is non-`Copy` becaue of the `path` field ([`PathBuf`][std::path::PathBuf]).
105///
106/// It is **super** frustrating in downstream applications, but I can't really do much about it
107/// without
108/// - abandonding the ability to transcode into YAML and JSON
109/// - doing a hefty work-around where we decode into array data for the rust type
110///
111/// That last option is quite a lot of work as it is basically re-implementing `OsString` using
112/// fixed array sizes, then writing abstractions for all path methods that we might want to use
113/// on this "path that is really some fixed arrays, but we want everyone to think is a path".
114///
115/// # No-`Default` trait
116///
117/// It doesn't make sense to have default field values for `slot_type`, `slot_id` or `path`.
118// todo: See note on SampleSettingsFile ref `SlotModes` / `PlaybackSpeed` structs.
119#[derive(
120    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, AsMutDerive, AsRefDerive,
121)]
122pub struct SlotAttributes {
123    /// Type of sample: STATIC or FLEX
124    pub slot_type: SlotType,
125
126    /// String ID Number of the slot the sample is assigned to e.g. 001, 002, 003...
127    /// Maximum of 128 entries for STATIC sample slots, but can be up to 136 for flex
128    /// slots as there are 8 recorders + 128 flex slots.
129    pub slot_id: u8,
130
131    /// Relative path to the file on the card from the project directory.
132    ///
133    /// Recording buffer flex slots by default have an empty path attribute,
134    /// which basically means 'no path'. In idiomatic rust that's an option.
135    pub path: Option<PathBuf>,
136
137    /// Current `TimestrechModes` setting for the specific slot. Example: `TSMODE=2`
138    /// See [TimeStretchMode].
139    pub timestrech_mode: TimeStretchMode,
140
141    /// Current `LoopMode` setting for the specific slot.
142    /// See [LoopMode].
143    pub loop_mode: LoopMode,
144
145    /// Current `TrigQuantizationModes` setting for this specific slot.
146    /// This is not used for recording buffer 'flex' tracks.
147    /// See [TrigQuantizationMode].
148    pub trig_quantization_mode: TrigQuantizationMode,
149
150    /// Sample gain. 48 is default as per sample attributes file. maximum 96, minimum 0.
151    pub gain: u8,
152
153    /// BPM of the sample in this slot. The stored representation is the 'real' bpm (float to 2
154    /// decimal places) multiplied by 24.
155    /// Default value is 2880 (120 BPM).
156    /// Max value is 7200 (300 BPM).
157    /// Min value is 720 (30 BPM).
158    pub bpm: u16,
159}
160
161#[allow(clippy::too_many_arguments)] // not my fault there's a bunch of inputs for this...
162impl SlotAttributes {
163    pub fn new(
164        slot_type: SlotType,
165        slot_id: u8,
166        path: Option<PathBuf>,
167        timestretch_mode: Option<TimeStretchMode>,
168        loop_mode: Option<LoopMode>,
169        trig_quantization_mode: Option<TrigQuantizationMode>,
170        gain: Option<u8>,
171        bpm: Option<u16>,
172    ) -> Result<Self, OtToolsIoError> {
173        // cannot be zero, flex slots go up to 128 + 8 (136), static up to 128
174        match slot_type {
175            SlotType::Static => {
176                if !(1..=128).contains(&slot_id) {
177                    return Err(ProjectSlotsError::SlotIdOutOfBounds { value: slot_id }.into());
178                }
179            }
180            SlotType::Flex => {
181                if !(1..=128).contains(&slot_id) {
182                    return Err(ProjectSlotsError::SlotIdOutOfBounds { value: slot_id }.into());
183                }
184            }
185        }
186
187        // human range multiplied by 24
188        if let Some(tempo) = bpm {
189            if !(720..=7200).contains(&tempo) {
190                return Err(ProjectSlotsError::TempoOutOfBounds { value: tempo }.into());
191            }
192        }
193
194        // -24.0 to +24.0 with 0.2 step
195        if let Some(amp) = gain {
196            if !(24..=120).contains(&amp) {
197                return Err(ProjectSlotsError::GainOutOfBounds { value: amp }.into());
198            }
199        }
200
201        Ok(Self {
202            slot_type,
203            slot_id,
204            path,
205            timestrech_mode: timestretch_mode.unwrap_or_default(),
206            loop_mode: loop_mode.unwrap_or_default(),
207            trig_quantization_mode: trig_quantization_mode.unwrap_or_default(),
208            gain: gain.unwrap_or(DEFAULT_GAIN),
209            bpm: bpm.unwrap_or(DEFAULT_TEMPO),
210        })
211    }
212
213    /// "I don't care about optional arguments, just let me create a [`SlotAttributes`]"
214    pub fn new_no_opts(
215        slot_type: SlotType,
216        slot_id: u8,
217        path: Option<PathBuf>,
218    ) -> Result<Self, OtToolsIoError> {
219        SlotAttributes::new(slot_type, slot_id, path, None, None, None, None, None)
220    }
221}
222
223#[cfg(test)]
224mod new_slot_attributes {
225    use crate::projects::slots::test_utils;
226    use crate::projects::slots::SlotAttributes;
227    use crate::settings::SlotType;
228    use crate::OtToolsIoError;
229    #[test]
230    fn valid_all_args() -> Result<(), OtToolsIoError> {
231        test_utils::new_slot_attr_full_args()?;
232        Ok(())
233    }
234
235    #[test]
236    fn valid_no_args() -> Result<(), OtToolsIoError> {
237        test_utils::new_slot_attr_minimal_args()?;
238        Ok(())
239    }
240
241    #[test]
242    fn invalid_slot_id_too_low_static() -> Result<(), OtToolsIoError> {
243        let r = SlotAttributes::new(SlotType::Static, 0, None, None, None, None, None, None);
244        assert!(r.is_err());
245        Ok(())
246    }
247
248    #[test]
249    fn invalid_slot_id_too_high_static() -> Result<(), OtToolsIoError> {
250        let r = SlotAttributes::new(SlotType::Static, 129, None, None, None, None, None, None);
251        assert!(r.is_err());
252        Ok(())
253    }
254
255    #[test]
256    fn invalid_slot_id_too_high_flex() -> Result<(), OtToolsIoError> {
257        let r = SlotAttributes::new(SlotType::Flex, 137, None, None, None, None, None, None);
258        assert!(r.is_err());
259        Ok(())
260    }
261
262    #[test]
263    fn invalid_gain_too_high() -> Result<(), OtToolsIoError> {
264        let r = SlotAttributes::new(
265            SlotType::Static,
266            100,
267            None,
268            None,
269            None,
270            None,
271            Some(200),
272            None,
273        );
274        assert!(r.is_err());
275        Ok(())
276    }
277
278    #[test]
279    fn invalid_gain_too_low() -> Result<(), OtToolsIoError> {
280        let r = SlotAttributes::new(SlotType::Static, 100, None, None, None, None, Some(1), None);
281        assert!(r.is_err());
282        Ok(())
283    }
284
285    #[test]
286    fn invalid_tempo_too_high() -> Result<(), OtToolsIoError> {
287        let r = SlotAttributes::new(
288            SlotType::Static,
289            100,
290            None,
291            None,
292            None,
293            None,
294            None,
295            Some(8000),
296        );
297        assert!(r.is_err());
298        Ok(())
299    }
300
301    #[test]
302    fn invalid_tempo_too_low() -> Result<(), OtToolsIoError> {
303        let r = SlotAttributes::new(
304            SlotType::Static,
305            100,
306            None,
307            None,
308            None,
309            None,
310            None,
311            Some(20),
312        );
313        assert!(r.is_err());
314        Ok(())
315    }
316}
317
318/// Helper function to create default recorder flex slots, no public
319fn recording_buffer_defaults(i: usize) -> Option<SlotAttributes> {
320    Some(SlotAttributes {
321        slot_type: SlotType::Flex,
322        // WARN: i is a 0-indexed iterable, slot IDs need to be 1-indexed!
323        slot_id: i as u8 + 129,
324        path: None,
325        timestrech_mode: TimeStretchMode::default(),
326        loop_mode: LoopMode::default(),
327        trig_quantization_mode: TrigQuantizationMode::default(),
328        gain: 72, // recording buffers are set to +12.0 dB, other slots are created with 0.0dB
329        bpm: DEFAULT_TEMPO,
330    })
331}
332
333impl Default for Slots<Option<SlotAttributes>> {
334    fn default() -> Self {
335        Self {
336            static_slots: PlaybackSlots::<Option<SlotAttributes>>::new(from_fn(|_| None)),
337            flex_slots: PlaybackSlots::<Option<SlotAttributes>>::new(from_fn(|_| None)),
338            recording_buffers: RecordingBufferSlots::<Option<SlotAttributes>>::new(from_fn(
339                recording_buffer_defaults,
340            )),
341        }
342    }
343}