ot_tools_io/
lib.rs

1/*
2SPDX-License-Identifier: GPL-3.0-or-later
3Copyright © 2024 Mike Robeson [dijksterhuis]
4*/
5
6//! Library crate for reading/writing data files for the [Elektron Octatrack][0].
7//!
8//! ```rust
9//! // reading, mutating and writing a bank file
10//!
11//! use std::path::PathBuf;
12//! use ot_tools_io::{OctatrackFileIO, BankFile};
13//!
14//! let path = PathBuf::from("test-data")
15//!     .join("blank-project")
16//!     .join("bank01.work");
17//!
18//! // read an editable version of the bank file
19//! let mut bank = BankFile::from_data_file(&path).unwrap();
20//!
21//! // change active scenes on the working copy of Part 4
22//! bank.parts.unsaved[3].active_scenes.scene_a = 2;
23//! bank.parts.unsaved[3].active_scenes.scene_b = 6;
24//!
25//! // write to a new bank file
26//! let outfpath = std::env::temp_dir()
27//!     .join("ot-tools-io")
28//!     .join("doctest")
29//!     .join("main_example_1");
30//!
31//! # // when running in cicd env the /tmp/ot-tools-io directory doesn't exist yet
32//! # let _ = std::fs::create_dir_all(outfpath.parent().unwrap());
33//! bank.to_data_file(&outfpath).unwrap();
34//! ```
35//!
36//! ## Brief Overview
37//!
38//! Important types and traits are re-exported or defined in the root of the crate's namespace for
39//! your convenience -- everything you need to read and write Octatrack data files should be
40//! available with `use ot_tools_io::*`. Less commonly used types and traits are public within their
41//! specific modules and will require importing.
42//! ```rust
43//! // basic / common imports
44//! use ot_tools_io::{SampleSettingsFile, OctatrackFileIO, HasHeaderField};
45//!
46//! // lower level / less common imports
47//! use ot_tools_io::samples::{SAMPLES_HEADER, SAMPLES_FILE_VERSION, DEFAULT_GAIN, DEFAULT_TEMPO};
48//! use ot_tools_io::slices::{Slice, Slices, SLICE_LOOP_POINT_DISABLED};
49//! use ot_tools_io::parts::{Part, AudioTrackMachineParamsSetupPickup};
50//! ```
51//!
52//! #### The `OctatrackFileIO` Trait
53//! You'll usually want to import the [`OctatrackFileIO`] trait if you're reading or writing files.
54//! It adds the associated functions & methods for file I/O, including for file I/O for YAML and
55//! JSON files.
56//! ```rust
57//! // write a sample settings file to yaml and json
58//!
59//! use std::path::PathBuf;
60//! use ot_tools_io::{SampleSettingsFile, OctatrackFileIO, HasHeaderField};
61//!
62//! let path = PathBuf::from("test-data")
63//!     .join("samples")
64//!     .join("sample.ot");
65//!
66//! let otfile = SampleSettingsFile::from_data_file(&path).unwrap();
67//!
68//! let outdir = std::env::temp_dir()
69//!     .join("ot-tools-io")
70//!     .join("doctest")
71//!     .join("write_yaml_and_json");
72//!
73//! # // when running in cicd env the /tmp/ot-tools-io directory doesn't exist yet
74//! # let _ = std::fs::create_dir_all(&outdir);
75//! &otfile.to_yaml_file(&outdir.join("sample.yaml")).unwrap();
76//! &otfile.to_json_file(&outdir.join("sample.json")).unwrap();
77//! ```
78//!
79//! #### The `HasSomeField` Traits
80//! The `Has*Field` traits add methods to a type to perform integrity checks: checksum calculation
81//! and validation, header validation and file patch version validation.
82//! ```rust
83//! // check the header of sample settings file
84//!
85//! use std::path::PathBuf;
86//! use ot_tools_io::{SampleSettingsFile, OctatrackFileIO, HasHeaderField};
87//!
88//! let path = PathBuf::from("test-data")
89//!     .join("samples")
90//!     .join("sample.ot");
91//!
92//! let otfile = SampleSettingsFile::from_data_file(&path).unwrap();
93//! assert!(otfile.check_header().unwrap())
94//! ```
95//!
96//! #### The `OtToolsIoError` Type
97//! The `OtToolsIoError` type should be used to catch any errors in normal use as it implements
98//! `From` for all other internal errors
99//! ```rust
100//! // handling errors
101//!
102//! use ot_tools_io::OtToolsIoError;
103//! use ot_tools_io::settings::MidiChannel;
104//!
105//! // always errors
106//! fn try_from_err() -> Result<MidiChannel, OtToolsIoError> {
107//!     Ok(MidiChannel::try_from(100_i8)?)
108//! }
109//!
110//! assert!(try_from_err().is_err());
111//! assert_eq!(
112//!     try_from_err().unwrap_err().to_string(),
113//!     "invalid setting value: Invalid MIDI Channel value".to_string(),
114//! );
115//! ```
116//!
117//! #### `SomeFile` Types
118//! Types directly related to some file used by the Octatrack are named as `SomeFile`, where
119//! `Some` is the relevant file base name (`*.ot` files obviously don't have a basename we can
120//! use).
121//!
122//! Only these `*File` types can be read from / written to the filesystem using this crate's
123//! [`OctatrackFileIO`] trait methods / functions.
124//!
125//! | Type    | Filename Pattern | Description |
126//! | ------------ | -------------------------- | ------------|
127//! | [`ArrangementFile`] | `arr??.*` | data for arrangements |
128//! | [`BankFile`] | `bank??.*` | data for parts and patterns |
129//! | [`MarkersFile`] | `markers.*` | start trim/end trim/slices/loop points for sample slots |
130//! | [`ProjectFile`] | `project.*` | project level settings; state; sample slots |
131//! | [`SampleSettingsFile`] | `*.ot` | saved sample settings data, loops slices etc. |
132//!
133//! Read the relevant modules in this library for more detailed information on
134//! the data contained in each file.
135//!
136//! ####
137//!
138//! ## Additional Details
139//!
140//! #### Octatrack File Relationships
141//!
142//! - Changing the sample loaded into a sample slot updates both the [`ProjectFile`] file (trig
143//!   quantization settings, file path etc) and the [`MarkersFile`] file (trim settings, slices,
144//!   loop points).
145//!
146//! - Slot data from [`ProjectFile`]s and [`MarkersFile`]s is written to an [`SampleSettingsFile`]
147//!   file when saving sample attributes data from the Octatrack's audio editing menu.
148//!
149//! - Loading a sample into a project sample slot ([`ProjectFile`]s and [`MarkersFile`]s) reads
150//!   any data in an [`SampleSettingsFile`] files and configures the sample slot accordingly.
151//!
152//! - A [`BankFile`]'s patterns and parts store zero-indexed sample slot IDs as flex
153//!   and static slot references (machine data in a part and track p-lock trigs in a pattern). These
154//!   references point to the relevant slot in **_both_** the [`MarkersFile`] and [`ProjectFile`]
155//!   for a project.
156//!
157//! - [`ArrangementFile`]s store a `u8` which references a [`BankFile`]'s pattern, indicating
158//!   the pattern should be played when the specific row in an arrangement is triggered.
159//!   The field is zero-indexed, with the full range used for pattern references 0 (A01) ->
160//!   256 (P16).
161//!
162//!
163//! #### Octatrack Device File Modifications
164//!
165//! - `*.work` files are created when creating a new project
166//!   `PROJECT MENU -> CHANGE PROJECT -> CREATE NEW`.
167//! - `*.work` files are updated by using the `PROJECT MENU -> SYNC TO CARD` operation.
168//! - `*.work` files are updated when the user performs a `PROJECT MENU -> SAVE PROJECT` operation.
169//! - `*.strd` files are created/updated when the user performs a `PROJECT MENU -> SAVE PROJECT`
170//!   operation.
171//! - `*.strd` files are not changed by the `PROJECT -> SYNC TO CARD` operation.
172//! - `arr??.strd` files can also be saved via the `ARRANGER MENU -> SAVE ARRANGEMENT` operation.
173//!
174//! #### Notable 'gotcha's
175//!
176//! ##### Sample Slot IDs
177//!
178//! Project Sample Slots store their slots with a **_one-indexed_** `slot_id` field.
179//!
180//! **_All other references to sample slots are zero-indexed_** (i.e. [`BankFile`] and
181//! [`MarkersFile`] files).
182//!
183//! I'm using lots of italics and bold formatting here because it is super annoying but there's not
184//! much i can do about it. Remember you will need to convert from one to zero indexed whenever you
185//! deal with Sample Slots.
186//!
187//! ##### Default Loop Point Values Differ Between Types
188//!
189//! A 'Disabled' loop point in either a [`MarkersFile`] or a [`SampleSettingsFile`] is a
190//! `0xFFFFFFFF` value, but the default loop point when creating new [`MarkersFile`] is always
191//! `0_u32`. The 'Disabled' value setting is only set when a sample is loaded into a sample slot,
192//! and a [`SampleSettingsFile`] is generated from that sample slot data.
193//!
194//! ####
195// For Andrey.
196//! ## Slava Ukraini
197//!
198//! I've worked with Ukrainian developers. What is happening to their country is abhorrent.
199//!
200//! [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://stand-with-ukraine.pp.ua)
201//! ##
202//!
203//! [0]: https://www.elektron.se/explore/octatrack-mkii
204//!
205
206pub mod arrangements;
207pub mod banks;
208pub mod errors;
209pub mod markers;
210pub mod parts;
211pub mod patterns;
212pub mod projects;
213pub mod samples;
214pub mod settings;
215pub mod slices;
216#[cfg(test)]
217#[allow(dead_code)]
218mod test_utils;
219mod traits;
220
221pub use crate::arrangements::ArrangementFile;
222pub use crate::banks::BankFile;
223pub use crate::markers::MarkersFile;
224pub use crate::projects::ProjectFile;
225pub use crate::samples::SampleSettingsFile;
226
227pub use crate::traits::{
228    CheckFileIntegrity, Defaults, HasChecksumField, HasFileVersionField, HasHeaderField, IsDefault,
229    OctatrackFileIO,
230};
231
232use crate::markers::MarkersError;
233use crate::projects::{ProjectError, ProjectParseError};
234use crate::samples::SampleSettingsError;
235use crate::settings::InvalidValueError;
236use crate::slices::SliceError;
237use std::fs::File;
238use std::io::{Read, Write};
239use std::path::{Path, PathBuf};
240use thiserror::Error;
241
242/// Global error variant handling. All internally used error types and variants can cast to this
243/// type.
244#[derive(Debug, Error)]
245pub enum OtToolsIoError {
246    /// File could not be found / opened etc.
247    #[error("File OS Error: {source} Path={path} (std::io::Error)")]
248    FileOs {
249        #[source]
250        source: std::io::Error,
251        path: PathBuf,
252    },
253    /// Some other FIle I/O error
254    #[error("File I/O Error: {0} (std::io::Error)")]
255    FileIo(#[from] std::io::Error),
256    /// Bincode could not de/serialize the data
257    #[error("error during binary data decoding / encoding: {0} (bincode::Error)")]
258    Bincode(#[from] bincode::Error),
259    /// Can't read project string data
260    #[error("error reading raw project data: {0} (std::str::Utf8Error)")]
261    ProjectRead(#[from] std::str::Utf8Error),
262    /// Can't parse project string data
263    #[error("error parsing raw project data: {0} (ot_tools_io::projects::ProjectParseError)")]
264    ProjectParse(#[from] ProjectParseError),
265    /// Can't parse yaml data
266    #[error("error processing yaml: {0} (serde_yml::Error)")]
267    YamlParse(#[from] serde_yml::Error),
268    /// Can't parse json data
269    #[error("error processing json: {0} (serde_json::Error)")]
270    JsonParse(#[from] serde_json::Error),
271    /// [projects] specific errors
272    #[error(
273        "project files cannot be checked for integrity: {0} (ot_tools_io::projects::ProjectError)"
274    )]
275    ProjectFile(#[from] ProjectError),
276    /// [samples] specific errors
277    #[error("sample settings error: {0} (ot_tools_io::samples::SampleSettingsError)")]
278    SampleSettings(#[from] SampleSettingsError),
279    /// [markers] specific errors
280    #[error("markers error: {0} (ot_tools_io::markers::MarkersErrors)")]
281    Markers(#[from] MarkersError),
282    /// [slices] specific errors
283    #[error("slices error: {0} (ot_tools_io::slices::SlicesErrors)")]
284    Slice(#[from] SliceError),
285    /// [settings] error handling
286    #[error("invalid setting value: {0}")]
287    SettingValue(#[from] InvalidValueError),
288    /// Header for some file type is invalid
289    #[error("invalid header(s) for file")]
290    FileHeader,
291}
292
293/// The Elektron Octatrack OS project versions this library can be used with.
294/// The `ProjectFile.metadata.os_version` field must contain one of these
295/// string values, otherwise the project is not compatible.
296///
297/// See the [`ProjectFile::check_compatible_os_version`] method for usage
298/// information.
299pub const ALLOWED_OS_VERSIONS: [&str; 3] = ["1.40A", "1.40B", "1.40C"];
300
301fn u8_bytes_to_u16(bytes: &[u8; 2]) -> u16 {
302    ((bytes[0] as u16) << 8) | bytes[1] as u16
303}
304
305#[doc(hidden)]
306/// Read bytes from a file at `path`.
307// ```rust
308// let fpath = std::path::PathBuf::from("test-data")
309//     .join("blank-project")
310//     .join("bank01.work");
311// let r = ot_tools_io::read_bin_file(&fpath);
312// assert!(r.is_ok());
313// assert_eq!(r.unwrap().len(), 636113);
314//```
315fn read_bin_file(path: &Path) -> Result<Vec<u8>, OtToolsIoError> {
316    let mut infile = File::open(path).map_err(|e| OtToolsIoError::FileOs {
317        path: path.to_path_buf(),
318        source: e,
319    })?;
320    let mut bytes: Vec<u8> = vec![];
321    let _: usize = infile.read_to_end(&mut bytes)?;
322    Ok(bytes)
323}
324
325#[cfg(test)]
326mod read_bin_file {
327    use crate::test_utils::*;
328    use crate::{read_bin_file, OtToolsIoError};
329
330    #[test]
331    fn ok() -> Result<(), OtToolsIoError> {
332        let path = get_blank_proj_dirpath().join("bank01.work");
333        read_bin_file(&path)?;
334        Ok(())
335    }
336
337    #[test]
338    fn err_file_io() -> Result<(), OtToolsIoError> {
339        let path = get_blank_proj_dirpath().join("NOTNTONTONTNTOTNONT");
340
341        #[cfg(target_os = "windows")]
342        assert_eq!(
343            read_bin_file(&path).unwrap_err().to_string(),
344            format!["File OS Error: The system cannot find the file specified. (os error 2) Path={} (std::io::Error)", path.display()].to_string(),
345            "should throw a OtToolsIoError::FileOS error when file does not exist"
346        );
347
348        #[cfg(target_os = "linux")]
349        assert_eq!(
350            read_bin_file(&path).unwrap_err().to_string(),
351            format![
352                "File OS Error: No such file or directory (os error 2) Path={} (std::io::Error)",
353                path.display()
354            ]
355            .to_string(),
356            "should throw a OtToolsIoError::FileOS error when file does not exist"
357        );
358
359        Ok(())
360    }
361}
362
363#[doc(hidden)]
364/// Write bytes to a file at `path`.
365// ```rust
366// use std::env::temp_dir;
367// use std::array::from_fn;
368//
369// let arr: [u8; 27] = from_fn(|_| 0);
370//
371// let fpath = temp_dir()
372//    .join("ot-tools-io")
373//    .join("doctest")
374//    .join("write_bin_file.example");
375//
376// # use std::fs::create_dir_all;
377// # create_dir_all(&fpath.parent().unwrap()).unwrap();
378// let r = ot_tools_io::write_bin_file(&arr, &fpath);
379// assert!(r.is_ok());
380// assert!(fpath.exists());
381// ```
382fn write_bin_file(bytes: &[u8], path: &Path) -> Result<(), OtToolsIoError> {
383    let mut file: File = File::create(path).map_err(|e| OtToolsIoError::FileOs {
384        path: path.to_path_buf(),
385        source: e,
386    })?;
387    file.write_all(bytes)?;
388    Ok(())
389}
390
391#[cfg(test)]
392mod write_bin_file {
393    use crate::{write_bin_file, OtToolsIoError};
394    use std::env::temp_dir;
395    use std::fs::{create_dir_all, remove_file};
396
397    #[test]
398    fn ok() -> Result<(), OtToolsIoError> {
399        let path = temp_dir()
400            .join("ot-tools-io")
401            .join("write_bin_file")
402            .join("ok.bin");
403        create_dir_all(path.parent().unwrap())?;
404        if path.exists() {
405            remove_file(&path)?;
406        };
407        write_bin_file(&[1, 2, 3, 4], &path)?;
408        Ok(())
409    }
410
411    // should fail: attempting to write a file to an existing directory
412    #[test]
413    fn err_file_io() -> Result<(), OtToolsIoError> {
414        let path = temp_dir().join("ot-tools-io").join("write_bin_file");
415        create_dir_all(&path)?;
416
417        #[cfg(target_os = "windows")]
418        assert_eq!(
419            write_bin_file(&[1, 2, 3, 4], &path)
420                .unwrap_err()
421                .to_string(),
422            format![
423                "File OS Error: Access is denied. (os error 5) Path={} (std::io::Error)",
424                path.display()
425            ]
426            .to_string(),
427            "should throw a OtToolsIoError::FileOS error when a file cannot be created"
428        );
429
430        #[cfg(target_os = "linux")]
431        assert_eq!(
432            write_bin_file(&[1, 2, 3, 4], &path)
433                .unwrap_err()
434                .to_string(),
435            format![
436                "File OS Error: Is a directory (os error 21) Path={} (std::io::Error)",
437                path.display()
438            ]
439            .to_string(),
440            "should throw a OtToolsIoError::FileOS error when a file cannot be created"
441        );
442        Ok(())
443    }
444}
445
446#[doc(hidden)]
447/// Read a file at `path` as a string.
448// ```
449// let fpath = std::path::PathBuf::from("test-data")
450//     .join("blank-project")
451//     .join("bank01.work");
452// let r = ot_tools_io::read_bin_file(&fpath);
453// assert!(r.is_ok());
454// assert_eq!(r.unwrap().len(), 636113);
455// ```
456fn read_str_file(path: &Path) -> Result<String, OtToolsIoError> {
457    let mut file = File::open(path).map_err(|e| OtToolsIoError::FileOs {
458        path: path.to_path_buf(),
459        source: e,
460    })?;
461    let mut string = String::new();
462    let _ = file.read_to_string(&mut string)?;
463    Ok(string)
464}
465
466#[cfg(test)]
467mod read_str_file {
468    use crate::test_utils::*;
469    use crate::{read_str_file, OtToolsIoError};
470
471    #[test]
472    fn ok() -> Result<(), OtToolsIoError> {
473        let path = get_blank_proj_dirpath().join("project.work");
474        read_str_file(&path)?;
475        Ok(())
476    }
477
478    #[test]
479    fn err_file_io() -> Result<(), OtToolsIoError> {
480        let path = get_blank_proj_dirpath().join("NOTNTONTONTNTOTNONT");
481
482        #[cfg(target_os = "windows")]
483        assert_eq!(
484            read_str_file(&path).unwrap_err().to_string(),
485            format!["File OS Error: The system cannot find the file specified. (os error 2) Path={} (std::io::Error)", path.display()].to_string(),
486            "should throw a OtToolsIoError::FileOS error when file does not exist"
487        );
488
489        #[cfg(target_os = "linux")]
490        assert_eq!(
491            read_str_file(&path).unwrap_err().to_string(),
492            format![
493                "File OS Error: No such file or directory (os error 2) Path={} (std::io::Error)",
494                path.display()
495            ]
496            .to_string(),
497            "should throw a OtToolsIoError::FileOS error when file does not exist"
498        );
499
500        Ok(())
501    }
502}
503
504#[doc(hidden)]
505/// Write a string to a file at `path`.
506// ```rust
507// use std::env::temp_dir;
508//
509// let data = "abcd".to_string();
510//
511// let fpath = temp_dir()
512//    .join("ot-tools-io")
513//    .join("doctest")
514//    .join("write_str_file.example");
515//
516// # use std::fs::create_dir_all;
517// # create_dir_all(&fpath.parent().unwrap()).unwrap();
518// let r = ot_tools_io::write_str_file(&data, &fpath);
519// assert!(r.is_ok());
520// assert!(fpath.exists());
521// ```
522fn write_str_file(string: &str, path: &Path) -> Result<(), OtToolsIoError> {
523    let mut file: File = File::create(path).map_err(|e| OtToolsIoError::FileOs {
524        path: path.to_path_buf(),
525        source: e,
526    })?;
527    write!(file, "{string}")?;
528    Ok(())
529}
530
531#[cfg(test)]
532mod write_str_file {
533    use crate::{write_str_file, OtToolsIoError};
534    use std::env::temp_dir;
535    use std::fs::{create_dir_all, remove_file};
536
537    #[test]
538    fn ok() -> Result<(), OtToolsIoError> {
539        let path = temp_dir()
540            .join("ot-tools-io")
541            .join("write_str_file")
542            .join("ok.txt");
543        create_dir_all(path.parent().unwrap())?;
544        if path.exists() {
545            remove_file(&path)?;
546        };
547        write_str_file("SOMETHING", &path)?;
548        Ok(())
549    }
550
551    // should fail: attempting to write a file to an existing directory
552    #[test]
553    fn err_file_io() -> Result<(), OtToolsIoError> {
554        let path = temp_dir().join("ot-tools-io").join("write_str_file");
555        create_dir_all(&path)?;
556
557        #[cfg(target_os = "windows")]
558        assert_eq!(
559            write_str_file("SOMETHING", &path).unwrap_err().to_string(),
560            format![
561                "File OS Error: Access is denied. (os error 5) Path={} (std::io::Error)",
562                path.display()
563            ]
564            .to_string(),
565            "should throw a OtToolsIoError::FileOS error when a file cannot be created"
566        );
567
568        #[cfg(target_os = "linux")]
569        assert_eq!(
570            write_str_file("SOMETHING", &path).unwrap_err().to_string(),
571            format![
572                "File OS Error: Is a directory (os error 21) Path={} (std::io::Error)",
573                path.display()
574            ]
575            .to_string(),
576            "should throw a OtToolsIoError::FileOS error when a file cannot be created"
577        );
578        Ok(())
579    }
580}
581
582fn loop_point_is_disabled(loop_point: u32) -> bool {
583    loop_point == slices::SLICE_LOOP_POINT_DISABLED
584}
585
586fn loop_point_is_default(loop_point: u32) -> bool {
587    loop_point == 0
588}
589
590fn loop_point_is_in_trim_range(loop_point: u32, trim_start: u32, trim_end: u32) -> bool {
591    loop_point >= trim_start && loop_point < trim_end
592}
593
594fn check_loop_point(loop_point: u32, trim_start: u32, trim_end: u32) -> bool {
595    loop_point_is_in_trim_range(loop_point, trim_start, trim_end)
596        || loop_point_is_disabled(loop_point)
597        || loop_point_is_default(loop_point)
598}