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//! [](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}