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
293fn u8_bytes_to_u16(bytes: &[u8; 2]) -> u16 {
294 ((bytes[0] as u16) << 8) | bytes[1] as u16
295}
296
297#[doc(hidden)]
298/// Read bytes from a file at `path`.
299// ```rust
300// let fpath = std::path::PathBuf::from("test-data")
301// .join("blank-project")
302// .join("bank01.work");
303// let r = ot_tools_io::read_bin_file(&fpath);
304// assert!(r.is_ok());
305// assert_eq!(r.unwrap().len(), 636113);
306//```
307fn read_bin_file(path: &Path) -> Result<Vec<u8>, OtToolsIoError> {
308 let mut infile = File::open(path).map_err(|e| OtToolsIoError::FileOs {
309 path: path.to_path_buf(),
310 source: e,
311 })?;
312 let mut bytes: Vec<u8> = vec![];
313 let _: usize = infile.read_to_end(&mut bytes)?;
314 Ok(bytes)
315}
316
317#[cfg(test)]
318mod read_bin_file {
319 use crate::test_utils::*;
320 use crate::{read_bin_file, OtToolsIoError};
321
322 #[test]
323 fn ok() -> Result<(), OtToolsIoError> {
324 let path = get_blank_proj_dirpath().join("bank01.work");
325 read_bin_file(&path)?;
326 Ok(())
327 }
328
329 #[test]
330 fn err_file_io() -> Result<(), OtToolsIoError> {
331 let path = get_blank_proj_dirpath().join("NOTNTONTONTNTOTNONT");
332
333 #[cfg(target_os = "windows")]
334 assert_eq!(
335 read_bin_file(&path).unwrap_err().to_string(),
336 format!["File OS Error: The system cannot find the file specified. (os error 2) Path={} (std::io::Error)", path.display()].to_string(),
337 "should throw a OtToolsIoError::FileOS error when file does not exist"
338 );
339
340 #[cfg(target_os = "linux")]
341 assert_eq!(
342 read_bin_file(&path).unwrap_err().to_string(),
343 format![
344 "File OS Error: No such file or directory (os error 2) Path={} (std::io::Error)",
345 path.display()
346 ]
347 .to_string(),
348 "should throw a OtToolsIoError::FileOS error when file does not exist"
349 );
350
351 Ok(())
352 }
353}
354
355#[doc(hidden)]
356/// Write bytes to a file at `path`.
357// ```rust
358// use std::env::temp_dir;
359// use std::array::from_fn;
360//
361// let arr: [u8; 27] = from_fn(|_| 0);
362//
363// let fpath = temp_dir()
364// .join("ot-tools-io")
365// .join("doctest")
366// .join("write_bin_file.example");
367//
368// # use std::fs::create_dir_all;
369// # create_dir_all(&fpath.parent().unwrap()).unwrap();
370// let r = ot_tools_io::write_bin_file(&arr, &fpath);
371// assert!(r.is_ok());
372// assert!(fpath.exists());
373// ```
374fn write_bin_file(bytes: &[u8], path: &Path) -> Result<(), OtToolsIoError> {
375 let mut file: File = File::create(path).map_err(|e| OtToolsIoError::FileOs {
376 path: path.to_path_buf(),
377 source: e,
378 })?;
379 file.write_all(bytes)?;
380 Ok(())
381}
382
383#[cfg(test)]
384mod write_bin_file {
385 use crate::{write_bin_file, OtToolsIoError};
386 use std::env::temp_dir;
387 use std::fs::{create_dir_all, remove_file};
388
389 #[test]
390 fn ok() -> Result<(), OtToolsIoError> {
391 let path = temp_dir()
392 .join("ot-tools-io")
393 .join("write_bin_file")
394 .join("ok.bin");
395 create_dir_all(path.parent().unwrap())?;
396 if path.exists() {
397 remove_file(&path)?;
398 };
399 write_bin_file(&[1, 2, 3, 4], &path)?;
400 Ok(())
401 }
402
403 // should fail: attempting to write a file to an existing directory
404 #[test]
405 fn err_file_io() -> Result<(), OtToolsIoError> {
406 let path = temp_dir().join("ot-tools-io").join("write_bin_file");
407 create_dir_all(&path)?;
408
409 #[cfg(target_os = "windows")]
410 assert_eq!(
411 write_bin_file(&[1, 2, 3, 4], &path)
412 .unwrap_err()
413 .to_string(),
414 format![
415 "File OS Error: Access is denied. (os error 5) Path={} (std::io::Error)",
416 path.display()
417 ]
418 .to_string(),
419 "should throw a OtToolsIoError::FileOS error when a file cannot be created"
420 );
421
422 #[cfg(target_os = "linux")]
423 assert_eq!(
424 write_bin_file(&[1, 2, 3, 4], &path)
425 .unwrap_err()
426 .to_string(),
427 format![
428 "File OS Error: Is a directory (os error 21) Path={} (std::io::Error)",
429 path.display()
430 ]
431 .to_string(),
432 "should throw a OtToolsIoError::FileOS error when a file cannot be created"
433 );
434 Ok(())
435 }
436}
437
438#[doc(hidden)]
439/// Read a file at `path` as a string.
440// ```
441// let fpath = std::path::PathBuf::from("test-data")
442// .join("blank-project")
443// .join("bank01.work");
444// let r = ot_tools_io::read_bin_file(&fpath);
445// assert!(r.is_ok());
446// assert_eq!(r.unwrap().len(), 636113);
447// ```
448fn read_str_file(path: &Path) -> Result<String, OtToolsIoError> {
449 let mut file = File::open(path).map_err(|e| OtToolsIoError::FileOs {
450 path: path.to_path_buf(),
451 source: e,
452 })?;
453 let mut string = String::new();
454 let _ = file.read_to_string(&mut string)?;
455 Ok(string)
456}
457
458#[cfg(test)]
459mod read_str_file {
460 use crate::test_utils::*;
461 use crate::{read_str_file, OtToolsIoError};
462
463 #[test]
464 fn ok() -> Result<(), OtToolsIoError> {
465 let path = get_blank_proj_dirpath().join("project.work");
466 read_str_file(&path)?;
467 Ok(())
468 }
469
470 #[test]
471 fn err_file_io() -> Result<(), OtToolsIoError> {
472 let path = get_blank_proj_dirpath().join("NOTNTONTONTNTOTNONT");
473
474 #[cfg(target_os = "windows")]
475 assert_eq!(
476 read_str_file(&path).unwrap_err().to_string(),
477 format!["File OS Error: The system cannot find the file specified. (os error 2) Path={} (std::io::Error)", path.display()].to_string(),
478 "should throw a OtToolsIoError::FileOS error when file does not exist"
479 );
480
481 #[cfg(target_os = "linux")]
482 assert_eq!(
483 read_str_file(&path).unwrap_err().to_string(),
484 format![
485 "File OS Error: No such file or directory (os error 2) Path={} (std::io::Error)",
486 path.display()
487 ]
488 .to_string(),
489 "should throw a OtToolsIoError::FileOS error when file does not exist"
490 );
491
492 Ok(())
493 }
494}
495
496#[doc(hidden)]
497/// Write a string to a file at `path`.
498// ```rust
499// use std::env::temp_dir;
500//
501// let data = "abcd".to_string();
502//
503// let fpath = temp_dir()
504// .join("ot-tools-io")
505// .join("doctest")
506// .join("write_str_file.example");
507//
508// # use std::fs::create_dir_all;
509// # create_dir_all(&fpath.parent().unwrap()).unwrap();
510// let r = ot_tools_io::write_str_file(&data, &fpath);
511// assert!(r.is_ok());
512// assert!(fpath.exists());
513// ```
514fn write_str_file(string: &str, path: &Path) -> Result<(), OtToolsIoError> {
515 let mut file: File = File::create(path).map_err(|e| OtToolsIoError::FileOs {
516 path: path.to_path_buf(),
517 source: e,
518 })?;
519 write!(file, "{string}")?;
520 Ok(())
521}
522
523#[cfg(test)]
524mod write_str_file {
525 use crate::{write_str_file, OtToolsIoError};
526 use std::env::temp_dir;
527 use std::fs::{create_dir_all, remove_file};
528
529 #[test]
530 fn ok() -> Result<(), OtToolsIoError> {
531 let path = temp_dir()
532 .join("ot-tools-io")
533 .join("write_str_file")
534 .join("ok.txt");
535 create_dir_all(path.parent().unwrap())?;
536 if path.exists() {
537 remove_file(&path)?;
538 };
539 write_str_file("SOMETHING", &path)?;
540 Ok(())
541 }
542
543 // should fail: attempting to write a file to an existing directory
544 #[test]
545 fn err_file_io() -> Result<(), OtToolsIoError> {
546 let path = temp_dir().join("ot-tools-io").join("write_str_file");
547 create_dir_all(&path)?;
548
549 #[cfg(target_os = "windows")]
550 assert_eq!(
551 write_str_file("SOMETHING", &path).unwrap_err().to_string(),
552 format![
553 "File OS Error: Access is denied. (os error 5) Path={} (std::io::Error)",
554 path.display()
555 ]
556 .to_string(),
557 "should throw a OtToolsIoError::FileOS error when a file cannot be created"
558 );
559
560 #[cfg(target_os = "linux")]
561 assert_eq!(
562 write_str_file("SOMETHING", &path).unwrap_err().to_string(),
563 format![
564 "File OS Error: Is a directory (os error 21) Path={} (std::io::Error)",
565 path.display()
566 ]
567 .to_string(),
568 "should throw a OtToolsIoError::FileOS error when a file cannot be created"
569 );
570 Ok(())
571 }
572}
573
574fn loop_point_is_disabled(loop_point: u32) -> bool {
575 loop_point == slices::SLICE_LOOP_POINT_DISABLED
576}
577
578fn loop_point_is_default(loop_point: u32) -> bool {
579 loop_point == 0
580}
581
582fn loop_point_is_in_trim_range(loop_point: u32, trim_start: u32, trim_end: u32) -> bool {
583 loop_point >= trim_start && loop_point < trim_end
584}
585
586fn check_loop_point(loop_point: u32, trim_start: u32, trim_end: u32) -> bool {
587 loop_point_is_in_trim_range(loop_point, trim_start, trim_end)
588 || loop_point_is_disabled(loop_point)
589 || loop_point_is_default(loop_point)
590}