ot-tools-io 0.11.3

A library crate for reading/writing binary data files used by the Elektron Octatrack DPS-1.
Documentation
/*
SPDX-License-Identifier: GPL-3.0-or-later
Copyright © 2026 Mike Robeson [dijksterhuis]
*/

//! Types and parsing of the attributes data for a project's sample slots.
//! Used in the [`crate::projects::ProjectFile`] type.
//!
//! NOTE: Sample slot attributes here refer to the non-'playback markers' data.
//! See [crate::markers::MarkersFile] for information on trim/loop/slice
//! settings for a project's sample slot.

/*
Example data:
[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]
-----

[SAMPLE]
TYPE=FLEX
SLOT=001
PATH=../AUDIO/flex.wav
TRIM_BARSx100=173
TSMODE=2
LOOPMODE=1
GAIN=48
TRIGQUANTIZATION=-1
[/SAMPLE]
*/
mod deserialize;
mod parsing;
mod serialize;

use crate::generics::{PlaybackSlots, RecordingBufferSlots, Slots};
use crate::settings::{LoopMode, SlotType, TimeStretchMode, TrigQuantizationMode};
use crate::OtToolsIoError;
use ot_tools_io_derive::{AsMutDerive, AsRefDerive};
use serde::Serialize;
use std::array::from_fn;
use std::path::PathBuf;
use thiserror::Error;

#[cfg(test)]
mod test_utils {
    use crate::projects::slots::SlotAttributes;
    use crate::settings::LoopMode;
    use crate::settings::SlotType;
    use crate::settings::TimeStretchMode;
    use crate::settings::TrigQuantizationMode;
    use crate::OtToolsIoError;
    use std::path::PathBuf;

    pub(crate) fn new_slot_attr_full_args() -> Result<SlotAttributes, OtToolsIoError> {
        SlotAttributes::new(
            SlotType::Static,
            100,
            Some(PathBuf::from("../AUDIO/location.wav")),
            Some(TimeStretchMode::default()),
            Some(LoopMode::default()),
            Some(TrigQuantizationMode::default()),
            Some(48),
            Some(3360),
        )
    }
    pub(crate) fn new_slot_attr_minimal_args() -> Result<SlotAttributes, OtToolsIoError> {
        SlotAttributes::new(SlotType::Static, 100, None, None, None, None, None, None)
    }
}

#[allow(clippy::enum_variant_names)]
#[derive(Debug, Error)]
pub enum ProjectSlotsError {
    #[error("invalid slot_id ({value}), must be in range 1 <= x <= 128")]
    SlotIdOutOfBounds { value: u8 },
    #[error("invalid tempo ({value}), must be in range 720 <= x <= 7200")]
    TempoOutOfBounds { value: u16 },
    #[error("invalid gain ({value}), must be in range 24 <= x <= 120")]
    GainOutOfBounds { value: u8 },
    #[error("failed to parse sample settings data")]
    SettingsRead,
    #[error("failed to canonicalize slot path ")]
    SlotPathCanon(#[from] std::io::Error),
}

/// Default tempo for the new slots.
pub const DEFAULT_TEMPO: u16 = 2880;

/// Default gain for new slots.
pub const DEFAULT_GAIN: u8 = 48;

/// A sample slot's global playback settings -- trig quantization, bpm,
/// timestrech mode ... anything applied to the sample globally .
/// The Octatrack only stores data when an audio file has been assigned to a sample slot.
///
/// # Naming
/// The Octatrack manual specifically refers to
/// > SAVE SAMPLE SETTINGS will save the trim, slice and **attribute** settings in a
/// > separate file and link it to the sample currently being edited.
/// > -- page 87
///
/// So ... these are the SLOT ATTRIBUTES which are saved to a SETTINGS FILE.
///
/// # Non-`Copy`
///
/// This type is non-`Copy` becaue of the `path` field ([`PathBuf`][std::path::PathBuf]).
///
/// It is **super** frustrating in downstream applications, but I can't really do much about it
/// without
/// - abandonding the ability to transcode into YAML and JSON
/// - doing a hefty work-around where we decode into array data for the rust type
///
/// That last option is quite a lot of work as it is basically re-implementing `OsString` using
/// fixed array sizes, then writing abstractions for all path methods that we might want to use
/// on this "path that is really some fixed arrays, but we want everyone to think is a path".
///
/// # No-`Default` trait
///
/// It doesn't make sense to have default field values for `slot_type`, `slot_id` or `path`.
// todo: See note on SampleSettingsFile ref `SlotModes` / `PlaybackSpeed` structs.
#[derive(
    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, AsMutDerive, AsRefDerive,
)]
pub struct SlotAttributes {
    /// Type of sample: STATIC or FLEX
    pub slot_type: SlotType,

    /// String ID Number of the slot the sample is assigned to e.g. 001, 002, 003...
    /// Maximum of 128 entries for STATIC sample slots, but can be up to 136 for flex
    /// slots as there are 8 recorders + 128 flex slots.
    pub slot_id: u8,

    /// Relative path to the file on the card from the project directory.
    ///
    /// Recording buffer flex slots by default have an empty path attribute,
    /// which basically means 'no path'. In idiomatic rust that's an option.
    pub path: Option<PathBuf>,

    /// Current `TimestrechModes` setting for the specific slot. Example: `TSMODE=2`
    /// See [TimeStretchMode].
    pub timestrech_mode: TimeStretchMode,

    /// Current `LoopMode` setting for the specific slot.
    /// See [LoopMode].
    pub loop_mode: LoopMode,

    /// Current `TrigQuantizationModes` setting for this specific slot.
    /// This is not used for recording buffer 'flex' tracks.
    /// See [TrigQuantizationMode].
    pub trig_quantization_mode: TrigQuantizationMode,

    /// Sample gain. 48 is default as per sample attributes file. maximum 96, minimum 0.
    pub gain: u8,

    /// BPM of the sample in this slot. The stored representation is the 'real' bpm (float to 2
    /// decimal places) multiplied by 24.
    /// Default value is 2880 (120 BPM).
    /// Max value is 7200 (300 BPM).
    /// Min value is 720 (30 BPM).
    pub bpm: u16,
}

#[allow(clippy::too_many_arguments)] // not my fault there's a bunch of inputs for this...
impl SlotAttributes {
    pub fn new(
        slot_type: SlotType,
        slot_id: u8,
        path: Option<PathBuf>,
        timestretch_mode: Option<TimeStretchMode>,
        loop_mode: Option<LoopMode>,
        trig_quantization_mode: Option<TrigQuantizationMode>,
        gain: Option<u8>,
        bpm: Option<u16>,
    ) -> Result<Self, OtToolsIoError> {
        // cannot be zero, flex slots go up to 128 + 8 (136), static up to 128
        match slot_type {
            SlotType::Static => {
                if !(1..=128).contains(&slot_id) {
                    return Err(ProjectSlotsError::SlotIdOutOfBounds { value: slot_id }.into());
                }
            }
            SlotType::Flex => {
                if !(1..=128).contains(&slot_id) {
                    return Err(ProjectSlotsError::SlotIdOutOfBounds { value: slot_id }.into());
                }
            }
        }

        // human range multiplied by 24
        if let Some(tempo) = bpm {
            if !(720..=7200).contains(&tempo) {
                return Err(ProjectSlotsError::TempoOutOfBounds { value: tempo }.into());
            }
        }

        // -24.0 to +24.0 with 0.2 step
        if let Some(amp) = gain {
            if !(24..=120).contains(&amp) {
                return Err(ProjectSlotsError::GainOutOfBounds { value: amp }.into());
            }
        }

        Ok(Self {
            slot_type,
            slot_id,
            path,
            timestrech_mode: timestretch_mode.unwrap_or_default(),
            loop_mode: loop_mode.unwrap_or_default(),
            trig_quantization_mode: trig_quantization_mode.unwrap_or_default(),
            gain: gain.unwrap_or(DEFAULT_GAIN),
            bpm: bpm.unwrap_or(DEFAULT_TEMPO),
        })
    }

    /// "I don't care about optional arguments, just let me create a [`SlotAttributes`]"
    pub fn new_no_opts(
        slot_type: SlotType,
        slot_id: u8,
        path: Option<PathBuf>,
    ) -> Result<Self, OtToolsIoError> {
        SlotAttributes::new(slot_type, slot_id, path, None, None, None, None, None)
    }
}

#[cfg(test)]
mod new_slot_attributes {
    use crate::projects::slots::test_utils;
    use crate::projects::slots::SlotAttributes;
    use crate::settings::SlotType;
    use crate::OtToolsIoError;
    #[test]
    fn valid_all_args() -> Result<(), OtToolsIoError> {
        test_utils::new_slot_attr_full_args()?;
        Ok(())
    }

    #[test]
    fn valid_no_args() -> Result<(), OtToolsIoError> {
        test_utils::new_slot_attr_minimal_args()?;
        Ok(())
    }

    #[test]
    fn invalid_slot_id_too_low_static() -> Result<(), OtToolsIoError> {
        let r = SlotAttributes::new(SlotType::Static, 0, None, None, None, None, None, None);
        assert!(r.is_err());
        Ok(())
    }

    #[test]
    fn invalid_slot_id_too_high_static() -> Result<(), OtToolsIoError> {
        let r = SlotAttributes::new(SlotType::Static, 129, None, None, None, None, None, None);
        assert!(r.is_err());
        Ok(())
    }

    #[test]
    fn invalid_slot_id_too_high_flex() -> Result<(), OtToolsIoError> {
        let r = SlotAttributes::new(SlotType::Flex, 137, None, None, None, None, None, None);
        assert!(r.is_err());
        Ok(())
    }

    #[test]
    fn invalid_gain_too_high() -> Result<(), OtToolsIoError> {
        let r = SlotAttributes::new(
            SlotType::Static,
            100,
            None,
            None,
            None,
            None,
            Some(200),
            None,
        );
        assert!(r.is_err());
        Ok(())
    }

    #[test]
    fn invalid_gain_too_low() -> Result<(), OtToolsIoError> {
        let r = SlotAttributes::new(SlotType::Static, 100, None, None, None, None, Some(1), None);
        assert!(r.is_err());
        Ok(())
    }

    #[test]
    fn invalid_tempo_too_high() -> Result<(), OtToolsIoError> {
        let r = SlotAttributes::new(
            SlotType::Static,
            100,
            None,
            None,
            None,
            None,
            None,
            Some(8000),
        );
        assert!(r.is_err());
        Ok(())
    }

    #[test]
    fn invalid_tempo_too_low() -> Result<(), OtToolsIoError> {
        let r = SlotAttributes::new(
            SlotType::Static,
            100,
            None,
            None,
            None,
            None,
            None,
            Some(20),
        );
        assert!(r.is_err());
        Ok(())
    }
}

/// Helper function to create default recorder flex slots, no public
fn recording_buffer_defaults(i: usize) -> Option<SlotAttributes> {
    Some(SlotAttributes {
        slot_type: SlotType::Flex,
        // WARN: i is a 0-indexed iterable, slot IDs need to be 1-indexed!
        slot_id: i as u8 + 129,
        path: None,
        timestrech_mode: TimeStretchMode::default(),
        loop_mode: LoopMode::default(),
        trig_quantization_mode: TrigQuantizationMode::default(),
        gain: 72, // recording buffers are set to +12.0 dB, other slots are created with 0.0dB
        bpm: DEFAULT_TEMPO,
    })
}

impl Default for Slots<Option<SlotAttributes>> {
    fn default() -> Self {
        Self {
            static_slots: PlaybackSlots::<Option<SlotAttributes>>::new(from_fn(|_| None)),
            flex_slots: PlaybackSlots::<Option<SlotAttributes>>::new(from_fn(|_| None)),
            recording_buffers: RecordingBufferSlots::<Option<SlotAttributes>>::new(from_fn(
                recording_buffer_defaults,
            )),
        }
    }
}