diff --git a/Cargo.lock b/Cargo.lock index b24081da4..ceba10b4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2196,6 +2196,20 @@ dependencies = [ "proptest", ] +[[package]] +name = "craft-scraft-format" +version = "0.1.0" +dependencies = [ + "craft-audio", + "craft-midi", + "craft-time", + "craft-zip", + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-settings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b86ae2fa8..9005875c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ craft-raw = { path = "crates/raw" } craft-sheet = { path = "crates/sheet" } craft-sketch = { path = "crates/sketch" } craft-settings = { path = "crates/settings" } +craft-scraft-format = { path = "crates/scraft-format" } craft-time = { path = "crates/time" } craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } diff --git a/README.md b/README.md index 9ecd57bde..83f20828c 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit is built on it in `craft-pdf`. - [`craft-timeline`](crates/timeline/README.md): editorial timeline interchange: exact media time and SMPTE timecode, import/export reports, media path and `file://` URL handling, Avid Log Exchange (ALE) and CMX 3600 EDL documents, format detection. From FilmCraft. - [`craft-time`](crates/time/README.md): exact rational ticks and SMPTE time across media, with a `soundcraft` compatibility module retaining SoundCraft's sample-index timebase, tempo maps, grids and session serialization semantics. +- [`craft-scraft-format`](crates/scraft-format/README.md): SoundCraft's typed `.scraft` session schema, versioned reader/writer, hostile-input validation, and `.scraftz` bundle reader/writer. Engine and UI mapping stays in SoundCraft. - [`craft-metadata`](crates/metadata/README.md): photo metadata, from LightCraft: EXIF (read and write, maker-note lens and focus point, every tag by name), IPTC-IIM, XMP packets (read, write, and a merge that keeps what another program wrote), GPX track logs, and the blocks that carry them in JPEG, PNG, diff --git a/crates/scraft-format/Cargo.toml b/crates/scraft-format/Cargo.toml new file mode 100644 index 000000000..9ec17cf9f --- /dev/null +++ b/crates/scraft-format/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "craft-scraft-format" +description = "SoundCraft's versioned native session document model and JSON schema" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-audio = { workspace = true, features = ["lossless-encode", "peaks"] } +craft-midi = { workspace = true, features = ["serde"] } +craft-time.workspace = true +craft-zip.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +craft-audio = { workspace = true, features = ["decode", "serde"] } +proptest.workspace = true diff --git a/crates/scraft-format/README.md b/crates/scraft-format/README.md new file mode 100644 index 000000000..c2df56206 --- /dev/null +++ b/crates/scraft-format/README.md @@ -0,0 +1,15 @@ +# craft-scraft-format + +The native SoundCraft session document, `.scraft` JSON schema and typed reader/writer. `Session` +and its nested types are the wire representation; the library checks `format_version` before +parsing, deserializes the document directly (without building a `serde_json::Value` tree), then +sanitizes and validates the resulting session. `Session::to_json` writes the same pretty JSON +representation. + +The crate retains SoundCraft's serialized enum names, defaults, compatibility behavior for older +fields, 256 MiB session byte limit, character limits, and duplicate/exhausted ID checks. Its time +fields use `craft_time::soundcraft`, the exact sample-index time API retained by `craft-time`. +MIDI sequence values use `craft-midi`'s serde representation. + +The application still maps sessions to UI state, engine state, media paths and audio pools. The +session schema and its format-specific parsing, writing, sanitization and validation live here. diff --git a/crates/scraft-format/src/automation.rs b/crates/scraft-format/src/automation.rs new file mode 100644 index 000000000..6d597682e --- /dev/null +++ b/crates/scraft-format/src/automation.rs @@ -0,0 +1,394 @@ +//! Automation lanes and modes. + +use crate::soundcraft_time::Samples; + +/// Automation modes (per track). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum AutomationMode { + Off, + #[default] + Read, + Touch, + Latch, + TouchLatch, + Write, + Trim, +} + +impl AutomationMode { + pub const ALL: [AutomationMode; 7] = [ + AutomationMode::Off, + AutomationMode::Read, + AutomationMode::Touch, + AutomationMode::Latch, + AutomationMode::TouchLatch, + AutomationMode::Write, + AutomationMode::Trim, + ]; + pub fn label(self) -> &'static str { + match self { + AutomationMode::Off => "off", + AutomationMode::Read => "read", + AutomationMode::Touch => "touch", + AutomationMode::Latch => "latch", + AutomationMode::TouchLatch => "touch/latch", + AutomationMode::Write => "write", + AutomationMode::Trim => "trim", + } + } + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + AutomationMode::ALL.into_iter().find(|m| m.label() == s || m.label().replace('/', "_") == s || format!("{m:?}").to_ascii_lowercase() == s) + } + pub fn reads(self) -> bool { + !matches!(self, AutomationMode::Off) + } +} + +/// Which parameter an automation lane controls. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum AutoParam { + /// Fader level, dB. + Volume, + /// Pan, -1..1 (index = which panner of a stereo track: 0 left, 1 right). + Pan(u8), + /// Mute (0/1). + Mute, + /// Send level, dB. + SendLevel(u8), + SendPan(u8), + SendMute(u8), + /// Insert slot + parameter id. + Plugin { + slot: u8, + param: String, + }, +} + +impl AutoParam { + pub fn label(&self) -> String { + match self { + AutoParam::Volume => "volume".into(), + AutoParam::Pan(0) => "pan".into(), + AutoParam::Pan(i) => format!("pan {}", u16::from(*i) + 1), + AutoParam::Mute => "mute".into(), + AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)), + AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)), + AutoParam::SendMute(i) => format!("send {} mute", slot_letter(*i)), + AutoParam::Plugin { slot, param } => format!("insert {} {param}", slot_letter(*slot)), + } + } + /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`. + pub fn parse(s: &str) -> Option { + let orig = s.trim(); + let s = orig.to_ascii_lowercase(); + match s.as_str() { + "volume" | "vol" => return Some(AutoParam::Volume), + "pan" | "pan1" => return Some(AutoParam::Pan(0)), + "pan2" => return Some(AutoParam::Pan(1)), + "mute" => return Some(AutoParam::Mute), + _ => {} + } + if let Some(rest) = s.strip_prefix("send_") { + let mut it = rest.splitn(2, '_'); + let slot = letter_slot(it.next()?)?; + return match it.next()? { + "level" => Some(AutoParam::SendLevel(slot)), + "pan" => Some(AutoParam::SendPan(slot)), + "mute" => Some(AutoParam::SendMute(slot)), + _ => None, + }; + } + if let Some(rest) = s.strip_prefix("plugin:") { + let (slot, param) = rest.split_once(':')?; + // ASCII lowercasing keeps byte offsets, so take the param from the original. + let param = orig.get(orig.len() - param.len()..)?; + return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() }); + } + None + } + /// Value range and default. + pub fn range(&self) -> (f32, f32, f32) { + match self { + AutoParam::Volume | AutoParam::SendLevel(_) => (-144.0, 12.0, 0.0), + AutoParam::Pan(_) | AutoParam::SendPan(_) => (-1.0, 1.0, 0.0), + AutoParam::Mute | AutoParam::SendMute(_) => (0.0, 1.0, 0.0), + AutoParam::Plugin { .. } => (f32::MIN, f32::MAX, 0.0), + } + } +} + +pub fn slot_letter(i: u8) -> char { + char::from(b'A'.saturating_add(i.min(25))) +} + +fn letter_slot(s: &str) -> Option { + let c = s.chars().next()?.to_ascii_uppercase(); + if s.len() == 1 && c.is_ascii_uppercase() { Some(c as u8 - b'A') } else { s.parse().ok() } +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AutomationPoint { + pub at: Samples, + pub value: f32, +} + +/// A breakpoint automation lane. Points are kept sorted by time. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AutomationLane { + pub param: AutoParam, + pub points: Vec, + #[serde(default)] + pub enabled: bool, +} + +impl AutomationLane { + pub fn new(param: AutoParam) -> Self { + AutomationLane { param, points: Vec::new(), enabled: true } + } + + /// Value at `at` (linear interpolation; `default` when empty). + pub fn value_at(&self, at: Samples, default: f32) -> f32 { + let pts = &self.points; + let Some(first) = pts.first() else { return default }; + if at <= first.at { + return first.value; + } + let idx = pts.partition_point(|p| p.at <= at); + match (pts.get(idx.wrapping_sub(1)), pts.get(idx)) { + (Some(a), Some(b)) if b.at > a.at => { + // Mute-like lanes step instead of ramping. + if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) { + return a.value; + } + // Widen so far-apart breakpoints cannot overflow i64. + let span = i128::from(b.at) - i128::from(a.at); + let off = i128::from(at) - i128::from(a.at); + // Interpolate in f64: the f32 difference overflows for + // opposite-sign values near f32::MAX. + let (av, bv) = (f64::from(a.value), f64::from(b.value)); + (av + (bv - av) * (off as f64 / span as f64)) as f32 + } + (Some(a), _) => a.value, + _ => default, + } + } + + /// Insert or replace a breakpoint. + pub fn set_point(&mut self, at: Samples, value: f32) { + let (lo, hi, _) = self.param.range(); + let value = if value.is_nan() { 0.0 } else { value.clamp(lo, hi) }; + match self.points.binary_search_by(|p| p.at.cmp(&at)) { + Ok(i) => { + if let Some(p) = self.points.get_mut(i) { + p.value = value; + } + } + Err(i) => self.points.insert(i, AutomationPoint { at, value }), + } + } + + /// Remove points in `[start, end)`. + pub fn clear_range(&mut self, start: Samples, end: Samples) -> usize { + let before = self.points.len(); + self.points.retain(|p| p.at < start || p.at >= end); + before - self.points.len() + } + + /// Write a constant value over a range, keeping the surrounding curve intact with anchor points. + pub fn write_range(&mut self, start: Samples, end: Samples, value: f32, default: f32) { + if end <= start { + return; + } + let before = self.value_at(start.saturating_sub(1), default); + let after = self.value_at(end, default); + self.clear_range(start, end.saturating_add(1)); + if start > 0 { + self.set_point(start - 1, before); + } + self.set_point(start, value); + self.set_point(end, value); + let anchor = end.saturating_add(1); + // A real breakpoint already at `end + 1` keeps its own value. + if self.points.binary_search_by(|p| p.at.cmp(&anchor)).is_err() { + self.set_point(anchor, after); + } + } + + /// Thin: remove points whose removal changes the curve by less than `tolerance`. + pub fn thin(&mut self, tolerance: f32) -> usize { + // Linear thinning would move the transitions of step lanes. + if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) || self.points.len() < 3 || tolerance.is_nan() { + return 0; + } + let tolerance = f64::from(tolerance); + let mut keep = vec![true; self.points.len()]; + let mut last = 0usize; + // Slopes from the last kept point that keep every point removed since then within `tolerance`. + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for i in 1..self.points.len() - 1 { + let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue }; + let dt = b.at.saturating_sub(a.at) as f64; + let rise = f64::from(b.value) - f64::from(a.value); + lo = lo.max((rise - tolerance) / dt); + hi = hi.min((rise + tolerance) / dt); + let slope = (f64::from(c.value) - f64::from(a.value)) / c.at.saturating_sub(a.at) as f64; + if (lo..=hi).contains(&slope) { + if let Some(k) = keep.get_mut(i) { + *k = false; + } + } else { + last = i; + (lo, hi) = (f64::NEG_INFINITY, f64::INFINITY); + } + } + let before = self.points.len(); + let mut it = keep.iter(); + self.points.retain(|_| it.next().copied().unwrap_or(true)); + before - self.points.len() + } + + /// Shift points at or after `from` by `delta` (Shuffle edits, Insert Time). + pub fn shift_from(&mut self, from: Samples, delta: Samples) { + for p in &mut self.points { + if p.at >= from { + p.at = p.at.saturating_add(delta).max(0); + } + } + self.points.sort_by_key(|p| p.at); + self.points.dedup_by_key(|p| p.at); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_keeps_plugin_param_case() { + let p = AutoParam::Plugin { slot: 0, param: "Cutoff".into() }; + assert_eq!(AutoParam::parse("plugin:a:Cutoff"), Some(p)); + assert_eq!(AutoParam::parse("Plugin:B:Mix"), Some(AutoParam::Plugin { slot: 1, param: "Mix".into() })); + assert_eq!(AutoParam::parse("Volume"), Some(AutoParam::Volume)); + assert_eq!(AutoParam::parse("SEND_B_LEVEL"), Some(AutoParam::SendLevel(1))); + } + + #[test] + fn value_at_far_apart_points_does_not_overflow() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(-1, 0.0); + l.set_point(i64::MAX, -10.0); + let v = l.value_at(5, 0.0); + assert!(v.is_finite() && (-10.0..=0.0).contains(&v)); + let w = l.value_at(i64::MAX - 1, 0.0); + assert!(w.is_finite() && (-10.0..=0.0).contains(&w)); + } + + #[test] + fn value_at_opposite_sign_extremes_stays_finite() { + let mut l = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "Cutoff".into() }); + l.set_point(0, -3.0e38); + l.set_point(1000, 3.0e38); + let v = l.value_at(500, 0.0); + assert!(v.is_finite() && v.abs() < 1.0e32, "mid value {v}"); + let w = l.value_at(1, 0.0); + assert!(w.is_finite() && w < -2.9e38, "early value {w}"); + } + + #[test] + fn pan_label_max_does_not_overflow() { + assert_eq!(AutoParam::Pan(255).label(), "pan 256"); + } + + #[test] + fn interpolation_and_steps() { + let mut l = AutomationLane::new(AutoParam::Volume); + assert_eq!(l.value_at(10, -3.0), -3.0); + l.set_point(0, 0.0); + l.set_point(100, -10.0); + assert!((l.value_at(50, 0.0) + 5.0).abs() < 1e-6); + assert_eq!(l.value_at(1000, 0.0), -10.0); + let mut m = AutomationLane::new(AutoParam::Mute); + m.set_point(0, 0.0); + m.set_point(100, 1.0); + assert_eq!(m.value_at(99, 0.0), 0.0); + assert_eq!(m.value_at(100, 0.0), 1.0); + } + + #[test] + fn write_range_keeps_point_right_after_end() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(11, -100.0); + l.write_range(2, 10, -20.0, 0.0); + assert_eq!(l.value_at(11, 0.0), -100.0); + } + + #[test] + fn write_range_preserves_outside() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(1000, 0.0); + l.write_range(200, 400, -20.0, 0.0); + assert_eq!(l.value_at(100, 0.0), 0.0); + assert_eq!(l.value_at(300, 0.0), -20.0); + assert_eq!(l.value_at(600, 0.0), 0.0); + } + + #[test] + fn thin_removes_collinear() { + let mut l = AutomationLane::new(AutoParam::Volume); + for i in 0..=10 { + l.set_point(i * 10, -(i as f32)); + } + assert_eq!(l.thin(0.01), 9); + assert_eq!(l.points.len(), 2); + } + + #[test] + fn thin_keeps_removed_points_within_tolerance() { + for direction in [1.0, -1.0] { + let mut lane = AutomationLane::new(AutoParam::Volume); + for (at, value) in [(0, 0.0), (100, 0.0), (200, 0.0), (300, 0.12), (400, 0.26)] { + lane.set_point(at, value * direction); + } + let original = lane.points.clone(); + let tolerance = 0.1; + assert!(lane.thin(tolerance) > 0); + for point in original { + let error = (lane.value_at(point.at, 0.0) - point.value).abs(); + assert!(error <= tolerance, "at {}: error {error} exceeds {tolerance}", point.at); + } + } + } + + #[test] + fn thin_keeps_mute_changes_in_place() { + for param in [AutoParam::Mute, AutoParam::SendMute(0)] { + let mut lane = AutomationLane::new(param); + for (at, value) in [(0, 0.0), (9000, 1.0), (10000, 1.0)] { + lane.set_point(at, value); + } + lane.thin(0.1); + assert_eq!(lane.value_at(8999, 0.0), 0.0); + assert_eq!(lane.value_at(9000, 0.0), 1.0); + } + } + + #[test] + fn param_ids() { + assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1))); + assert_eq!(AutoParam::parse("plugin:a:threshold"), Some(AutoParam::Plugin { slot: 0, param: "threshold".into() })); + assert_eq!(AutoParam::parse("nope"), None); + assert_eq!(AutomationMode::from_id("touch/latch"), Some(AutomationMode::TouchLatch)); + } + + #[test] + fn set_point_clamps_nan() { + let mut l = AutomationLane::new(AutoParam::Pan(0)); + l.set_point(0, f32::NAN); + l.set_point(1, 5.0); + assert_eq!(l.points[0].value, 0.0); + assert_eq!(l.points[1].value, 1.0); + } +} diff --git a/crates/scraft-format/src/b64.rs b/crates/scraft-format/src/b64.rs new file mode 100644 index 000000000..100a9a08f --- /dev/null +++ b/crates/scraft-format/src/b64.rs @@ -0,0 +1,114 @@ +//! Standard base64 (RFC 4648, `+/` alphabet, `=` padding), used to keep opaque plugin state +//! blobs in the JSON session file. Decoding treats its input as hostile: it rejects bad +//! characters and bad padding, ignores ASCII whitespace, and caps the output size. + +/// Largest blob [`decode`] will produce (64 MiB): bigger session fields are rejected. +pub const MAX_DECODED_BYTES: usize = 64 << 20; + +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encodes bytes as padded standard base64. +pub fn encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3).saturating_mul(4)); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + let sym = |shift: u32| char::from(ALPHABET.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'A')); + out.push(sym(18)); + out.push(sym(12)); + out.push(if chunk.len() > 1 { sym(6) } else { '=' }); + out.push(if chunk.len() > 2 { sym(0) } else { '=' }); + } + out +} + +fn value(c: u8) -> Option { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + }; + Some(u32::from(v)) +} + +/// Decodes padded (or unpadded) standard base64. `None` for malformed input or a result larger +/// than [`MAX_DECODED_BYTES`]. +pub fn decode(text: &str) -> Option> { + if text.bytes().filter(|c| !c.is_ascii_whitespace()).count() > MAX_DECODED_BYTES.div_ceil(3).saturating_mul(4) { + return None; + } + let syms: Vec = text.bytes().filter(|c| !c.is_ascii_whitespace()).collect(); + let body = syms.strip_suffix(b"==").or_else(|| syms.strip_suffix(b"=")).unwrap_or(&syms); + if body.contains(&b'=') || (syms.len() != body.len() && !syms.len().is_multiple_of(4)) || body.len() % 4 == 1 { + return None; + } + let decoded_len = body.len() / 4 * 3 + body.len() % 4 * 3 / 4; + if decoded_len > MAX_DECODED_BYTES { + return None; + } + let mut out = Vec::with_capacity(decoded_len); + for chunk in body.chunks(4) { + let mut n = 0u32; + for (i, &c) in chunk.iter().enumerate() { + n |= value(c)? << (18 - 6 * i as u32); + } + let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8]; + out.extend_from_slice(bytes.get(..chunk.len() - 1)?); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc4648_vectors_round_trip() { + for (plain, enc) in + [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] + { + assert_eq!(encode(plain.as_bytes()), enc); + assert_eq!(decode(enc).unwrap(), plain.as_bytes()); + } + let all: Vec = (0..=255).collect(); + assert_eq!(decode(&encode(&all)).unwrap(), all); + assert_eq!(decode("Zm9v\nYmE=").unwrap(), b"fooba", "whitespace is ignored"); + assert_eq!(decode("Zm9vYmE").unwrap(), b"fooba", "padding is optional"); + } + + #[test] + fn hostile_input_is_rejected() { + for bad in ["Z", "Zm9=v", "Zm9v!", "====", "Zg=", "é", "Zg===", "Z==="] { + assert!(decode(bad).is_none(), "{bad}"); + } + } + + #[test] + fn size_cap_counts_the_partial_quartet() { + fn encoded_zero_bytes(len: usize) -> String { + let groups = len / 3; + let mut encoded = "A".repeat(groups * 4); + match len % 3 { + 1 => encoded.push_str("AA=="), + 2 => encoded.push_str("AAA="), + _ => {} + } + encoded + } + + // Build valid base64 directly instead of encoding a 64 MiB allocation on every case. + // This keeps the same decoded-size boundary checks while making the workspace gate much + // faster and using less temporary memory. + let exact = encoded_zero_bytes(MAX_DECODED_BYTES); + assert_eq!(decode(&exact).map(|bytes| bytes.len()), Some(MAX_DECODED_BYTES), "padded boundary"); + assert_eq!(decode(exact.trim_end_matches('=')).map(|bytes| bytes.len()), Some(MAX_DECODED_BYTES), "unpadded boundary"); + + let too_large = encoded_zero_bytes(MAX_DECODED_BYTES + 1); + assert!(decode(&too_large).is_none(), "padded over boundary"); + let spaced = format!("{}\n {}", &too_large[..8], &too_large[8..]); + assert!(decode(&spaced).is_none(), "whitespace over boundary"); + } +} diff --git a/crates/scraft-format/src/bundle.rs b/crates/scraft-format/src/bundle.rs new file mode 100644 index 000000000..6b4885003 --- /dev/null +++ b/crates/scraft-format/src/bundle.rs @@ -0,0 +1,248 @@ +//! The optional `.scraftz` ZIP container for a `.scraft` session and its embedded audio files. + +use crate::{FileFormat, MAX_SESSION_BYTES, SESSION_EXTENSION, Session}; +use craft_audio::{BitDepth, EncodeFormat, EncodeOptions, encode}; +use craft_zip::{Archive, Compression, Limits, Writer}; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; +use std::path::Path; + +/// Extension of a ZIP bundle containing a `.scraft` session and its audio files. +pub const BUNDLE_EXTENSION: &str = "scraftz"; + +/// The limits applied to a bundle in both directions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BundleLimits { + /// Maximum ZIP entries, including directory entries. + pub entries: usize, + /// Maximum combined uncompressed size of entries. + pub bytes: u64, + /// Maximum size of the contained session JSON. + pub session_bytes: u64, +} + +/// SoundCraft's current bundle defaults: 10,000 entries, 1 GiB expanded and 256 MiB per session. +pub const DEFAULT_BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 1 << 30, session_bytes: MAX_SESSION_BYTES }; + +/// Errors while reading or writing a session bundle. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum BundleError { + /// The archive is malformed, exceeds a configured limit, or does not contain a session. + #[error("bundle: {0}")] + Invalid(String), + /// The session JSON cannot be serialized. + #[error("{0}")] + Session(String), + /// The embedded audio cannot be encoded as WAV. + #[error("{0}")] + Audio(String), +} + +/// Write a session and its decoded audio as a deterministic `.scraftz` ZIP bundle. +/// +/// Audio entries use the same float WAV representation as SoundCraft's desktop bundle writer. +/// The returned session has each included source's path rewritten to `Audio Files/.wav`, +/// its format set to `Wav`, and `unsaved` cleared. +pub fn write_bundle(session: &Session, limits: BundleLimits) -> Result<(Vec, Session), BundleError> { + let mut fresh = session.clone(); + let mut zip = Writer::new(); + let mut names = HashSet::new(); + let mut count = 0usize; + let mut total = 0u64; + + for source in &mut fresh.sources { + let Some(audio) = fresh.pool.get(source.id) else { continue }; + let bytes = encode(&audio.buffer, &EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(BitDepth::Float32)) + .map_err(|e| BundleError::Audio(e.to_string()))?; + let base = sanitize_name(&source.name); + let mut target = format!("Audio Files/{base}.wav"); + let mut suffix = 1usize; + while names.contains(&target) && suffix <= 9_999 { + target = format!("Audio Files/{base}_{suffix:02}.wav"); + suffix += 1; + } + account(&mut count, &mut total, bytes.len(), limits)?; + zip.add(&target, &bytes, Compression::Stored).map_err(|e| BundleError::Invalid(e.to_string()))?; + names.insert(target.clone()); + source.path = target; + source.unsaved = false; + source.format = FileFormat::Wav; + } + + let text = crate::write_session(&fresh, limits.session_bytes)?; + account(&mut count, &mut total, text.len(), limits)?; + let session_name = format!("{}.{}", sanitize_name(&fresh.name), SESSION_EXTENSION); + zip.add(&session_name, text.as_bytes(), Compression::Stored).map_err(|e| BundleError::Invalid(e.to_string()))?; + let bytes = zip.finish().map_err(|e| BundleError::Invalid(e.to_string()))?; + Ok((bytes, fresh)) +} + +/// Read the first root-level `.scraft` and retain other files by their archive path. +/// +/// Duplicate entry names, non-text names and entries whose file data overlaps another entry are +/// refused before payloads are copied. The session byte cap is checked against the ZIP's declared +/// size before decompressing its JSON. +pub fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, HashMap>), BundleError> { + let max_entries = u64::try_from(limits.entries).unwrap_or(u64::MAX); + let mut archive = Archive::open(bytes, Limits { max_entries, max_total_bytes: limits.bytes }).map_err(zip_error)?; + if archive.has_duplicate_names() { + return Err(BundleError::Invalid("files share a name".into())); + } + if archive.has_overlapping_files().map_err(zip_error)? { + return Err(BundleError::Invalid("files share data".into())); + } + let len = archive.len(); + let mut session = None; + let mut files = HashMap::new(); + for index in 0..len { + let is_dir = archive.entry_is_dir(index).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid("missing entry".into()))?; + if is_dir { + continue; + } + let name = archive.entry_name(index).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid("missing entry".into()))?; + let is_session = !name.contains('/') && Path::new(&name).extension().and_then(OsStr::to_str) == Some(SESSION_EXTENSION); + let size = archive.declared_size(&name).ok_or_else(|| BundleError::Invalid(format!("{name}: missing entry")))?; + if is_session && session.is_none() { + check_session_size(&name, size, limits.session_bytes, false).map_err(bundle_context)?; + } + let data = archive.read(&name, limits.bytes).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid(format!("{name}: missing entry")))?; + if is_session && session.is_none() { + session = Some(String::from_utf8(data).map_err(|_| BundleError::Invalid(format!("{name} is not text")))?); + } else { + files.insert(name, data); + } + } + let session = session.ok_or_else(|| BundleError::Invalid("no .scraft session inside".into()))?; + Ok((session, files)) +} + +/// Refuse a session that cannot be opened again under the same byte cap. +pub fn check_session_size(name: &str, len: u64, limit: u64, saving: bool) -> Result<(), BundleError> { + if len <= limit { + return Ok(()); + } + let most = if limit >= 1 << 20 { format!("{} MiB", limit >> 20) } else { format!("{limit} bytes") }; + let message = if saving { + format!("{name} is too large to save: over {most}, more than SoundCraft opens") + } else { + format!("{name} is too large to open (over {most})") + }; + Err(BundleError::Session(message)) +} + +fn bundle_context(error: BundleError) -> BundleError { + match error { + BundleError::Session(message) => BundleError::Invalid(message), + other => other, + } +} + +fn account(count: &mut usize, total: &mut u64, len: usize, limits: BundleLimits) -> Result<(), BundleError> { + *count = count.saturating_add(1); + *total = total.saturating_add(u64::try_from(len).unwrap_or(u64::MAX)); + if *count > limits.entries || *total > limits.bytes { + return Err(BundleError::Invalid(format!( + "too large to save (a bundle holds at most {} files and {} MiB, or it can't be opened again)", + limits.entries, + limits.bytes >> 20 + ))); + } + Ok(()) +} + +fn zip_error(error: craft_zip::Error) -> BundleError { + match error { + craft_zip::Error::NotZip(message) => BundleError::Invalid(message), + craft_zip::Error::TooManyEntries { .. } => BundleError::Invalid("too many files".into()), + craft_zip::Error::EntryTooLarge { .. } | craft_zip::Error::BudgetSpent { .. } => BundleError::Invalid("too large".into()), + craft_zip::Error::Entry { name, msg } => BundleError::Invalid(format!("{name}: {msg}")), + other => BundleError::Invalid(other.to_string()), + } +} + +fn sanitize_name(name: &str) -> String { + let text: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); + let text = text.trim().trim_start_matches('.').to_string(); + if text.is_empty() { "untitled".into() } else { text.chars().take(100).collect() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + + #[test] + fn bare_session_round_trips_in_a_bundle() { + let session = Session::default(); + let (bytes, fresh) = write_bundle(&session, DEFAULT_BUNDLE_LIMITS).unwrap(); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert_eq!(fresh, session); + assert_eq!(write_bundle(&session, DEFAULT_BUNDLE_LIMITS).unwrap().0, bytes, "same input writes the same ZIP bytes"); + let (text, files) = read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).unwrap(); + assert!(files.is_empty()); + assert_eq!(Session::from_json(&text).unwrap(), session); + } + + #[test] + fn audio_is_stored_as_float_wav_and_the_session_path_is_rewritten() { + use crate::{FileFormat, Source, SourceAudio, SourceId}; + use craft_audio::AudioBuffer; + use std::sync::Arc; + + let id = SourceId(1); + let samples = [-2.0, -0.25, 0.5, 1.5]; + let mut audio = AudioBuffer::new(48_000, 1, samples.len()); + audio.channels[0].copy_from_slice(&samples); + let mut session = Session::default(); + session.sources.push(Source { + id, + name: "Lead / take?".into(), + path: "elsewhere.wav".into(), + channels: 1, + frames: samples.len() as u64, + sample_rate: 48_000, + format: FileFormat::Other, + time_reference: 0, + unsaved: true, + }); + session.pool.insert(id, Arc::new(SourceAudio::new(audio))); + + let (bytes, fresh) = write_bundle(&session, DEFAULT_BUNDLE_LIMITS).unwrap(); + let source = fresh.sources.first().unwrap(); + assert_eq!(source.path, "Audio Files/Lead _ take_.wav"); + assert_eq!(source.format, FileFormat::Wav); + assert!(!source.unsaved); + + let (text, files) = read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).unwrap(); + let parsed = Session::from_json(&text).unwrap(); + assert_eq!(parsed.sources[0].path, source.path); + let wav = files.get(&source.path).unwrap(); + let (_, decoded) = craft_audio::decode(wav, Some("wav")).unwrap(); + assert_eq!(decoded.channels[0], samples); + } + + #[test] + fn session_size_is_checked_in_both_directions() { + let too_big = check_session_size("a session", 11, 10, true).unwrap_err(); + assert_eq!(too_big.to_string(), "a session is too large to save: over 10 bytes, more than SoundCraft opens"); + let too_big = check_session_size("a session", 11, 10, false).unwrap_err(); + assert_eq!(too_big.to_string(), "a session is too large to open (over 10 bytes)"); + } + + #[test] + fn a_bundle_without_a_session_is_refused() { + let mut zip = Writer::new(); + zip.add("Audio Files/a.wav", b"audio", Compression::Stored).unwrap(); + let bytes = zip.finish().unwrap(); + assert_eq!(read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).unwrap_err().to_string(), "bundle: no .scraft session inside"); + } + + #[test] + fn every_truncation_of_a_bundle_is_an_error_not_a_panic() { + let (bytes, _) = write_bundle(&Session::default(), DEFAULT_BUNDLE_LIMITS).unwrap(); + for cut in 0..bytes.len() { + assert!(read_bundle(&bytes[..cut], DEFAULT_BUNDLE_LIMITS).is_err(), "accepted truncation at {cut}"); + } + assert!(read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).is_ok()); + } +} diff --git a/crates/scraft-format/src/clip.rs b/crates/scraft-format/src/clip.rs new file mode 100644 index 000000000..28e60b6d9 --- /dev/null +++ b/crates/scraft-format/src/clip.rs @@ -0,0 +1,544 @@ +//! Clips: windows onto audio sources or MIDI sequences, placed on a playlist. + +use crate::soundcraft_midi::Sequence; +use crate::soundcraft_time::{MAX_POSITION, Range, Samples}; +use crate::{ClipId, MAX_NAME_CHARS, SourceId, truncate_chars}; + +/// Fade curve shapes (Pro Tools offers standard, equal-power, S-curve and custom presets). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum FadeShape { + Linear, + #[default] + EqualPower, + SCurve, + Exponential, + Logarithmic, +} + +impl FadeShape { + pub const ALL: [FadeShape; 5] = [FadeShape::Linear, FadeShape::EqualPower, FadeShape::SCurve, FadeShape::Exponential, FadeShape::Logarithmic]; + + /// Fade-in gain at `t` ∈ [0,1] (fade-outs use `gain(1 - t)`). + pub fn gain(self, t: f32) -> f32 { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + match self { + FadeShape::Linear => t, + FadeShape::EqualPower => (t * std::f32::consts::FRAC_PI_2).sin(), + FadeShape::SCurve => 0.5 - 0.5 * (t * std::f32::consts::PI).cos(), + FadeShape::Exponential => t * t * t, + FadeShape::Logarithmic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), + } + } + + pub fn label(self) -> &'static str { + match self { + FadeShape::Linear => "Linear", + FadeShape::EqualPower => "Equal Power", + FadeShape::SCurve => "S-Curve", + FadeShape::Exponential => "Exponential", + FadeShape::Logarithmic => "Logarithmic", + } + } + + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase().replace(['-', '_', ' '], ""); + FadeShape::ALL.into_iter().find(|f| f.label().to_ascii_lowercase().replace(['-', ' '], "") == s) + } +} + +/// A fade at a clip boundary, `len` samples long. +#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub struct Fade { + pub len: Samples, + pub shape: FadeShape, + /// Continuous bend (-1..1); zero preserves the original shape exactly. + #[serde(default)] + pub curvature: f32, +} + +impl Fade { + /// The envelope used by both rendering and audio, with fixed zero/unity endpoints. + pub fn gain(self, t: f32) -> f32 { + let base = self.shape.gain(t); + let curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + if curvature == 0.0 || base <= 0.0 || base >= 1.0 { + return base; + } + let weighted = base * (curvature * 4.0).exp2(); + weighted / (weighted + 1.0 - base) + } + + /// Invert the bend at the midpoint so a vertical drag follows the pointer. + pub fn curvature_for_midpoint_gain(self, gain: f32) -> f32 { + let gain = if gain.is_finite() { gain.clamp(0.001, 0.999) } else { self.gain(0.5) }; + let base = self.shape.gain(0.5); + (((gain / (1.0 - gain)).log2() - (base / (1.0 - base)).log2()) / 4.0).clamp(-1.0, 1.0) + } + + pub fn sanitize(&mut self) { + self.curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + } +} + +/// What a clip plays. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ClipContent { + /// Audio from `source`, starting `offset` samples into the file. + Audio { source: SourceId, offset: Samples }, + /// MIDI notes, with ticks relative to the clip start. + Midi { sequence: Sequence }, + /// Picture from the movie `source` ([`crate::VideoSource`], `Session::videos`), starting + /// `offset` samples into the movie. Video clips carry no audio. + Video { source: SourceId, offset: Samples }, +} + +/// A clip on a playlist. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Clip { + pub id: ClipId, + pub name: String, + /// Timeline start (samples). + pub start: Samples, + /// Length (samples, > 0). + pub length: Samples, + pub content: ClipContent, + /// Static clip gain in dB. + #[serde(default)] + pub gain_db: f32, + /// Clip gain envelope breakpoints: (offset from clip start, dB). Empty = flat `gain_db`. + #[serde(default)] + pub gain_env: Vec<(Samples, f32)>, + #[serde(default)] + pub fade_in: Fade, + #[serde(default)] + pub fade_out: Fade, + #[serde(default)] + pub muted: bool, + #[serde(default)] + pub edit_locked: bool, + #[serde(default)] + pub time_locked: bool, + #[serde(default)] + pub rating: u8, + /// Sync point offset from clip start. + #[serde(default)] + pub sync_point: Samples, + /// Optional colour override (RGB). + #[serde(default)] + pub color: Option<[u8; 3]>, + /// The key in [`crate::Session::auto_names`] of the names that editing gives the clips it + /// separates from this one (Clip List › Auto Rename). `None` keeps the clip's name. + #[serde(default)] + pub auto_name: Option, + /// Elastic / Vari-speed ratio (1.0 = none) — applied by rendered processing. + #[serde(default = "one")] + pub stretch: f64, + /// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews). + #[serde(default)] + pub group: Option, + /// Marks the clips made by `clip.loop` (the origin and its copies share one id), so + /// `clip.unloop` still recognises the loop after the source's content is edited. + #[serde(default)] + pub loop_id: Option, +} + +fn one() -> f64 { + 1.0 +} + +impl Clip { + pub fn audio(id: ClipId, name: impl Into, source: SourceId, offset: Samples, start: Samples, length: Samples) -> Clip { + Clip { + id, + name: name.into(), + start, + length: length.max(1), + content: ClipContent::Audio { source, offset }, + gain_db: 0.0, + gain_env: Vec::new(), + fade_in: Fade::default(), + fade_out: Fade::default(), + muted: false, + edit_locked: false, + time_locked: false, + rating: 0, + sync_point: 0, + color: None, + auto_name: None, + stretch: 1.0, + group: None, + loop_id: None, + } + } + + pub fn midi(id: ClipId, name: impl Into, start: Samples, length: Samples, sequence: Sequence) -> Clip { + let mut c = Clip::audio(id, name, SourceId(0), 0, start, length); + c.content = ClipContent::Midi { sequence }; + c + } + + /// A clip showing the movie `source` from `offset` samples into it. + pub fn video(id: ClipId, name: impl Into, source: SourceId, offset: Samples, start: Samples, length: Samples) -> Clip { + let mut c = Clip::audio(id, name, SourceId(0), 0, start, length); + c.content = ClipContent::Video { source, offset }; + c + } + + pub fn end(&self) -> Samples { + self.start.saturating_add(self.length) + } + + pub fn range(&self) -> Range { + Range { start: self.start, end: self.end() } + } + + pub fn is_audio(&self) -> bool { + matches!(self.content, ClipContent::Audio { .. }) + } + + pub fn is_video(&self) -> bool { + matches!(self.content, ClipContent::Video { .. }) + } + + /// The audio source this clip plays (None for MIDI and video clips). + pub fn source(&self) -> Option { + match self.content { + ClipContent::Audio { source, .. } => Some(source), + ClipContent::Midi { .. } | ClipContent::Video { .. } => None, + } + } + + /// The movie this clip shows (video clips only). + pub fn video_source(&self) -> Option { + match self.content { + ClipContent::Video { source, .. } => Some(source), + ClipContent::Audio { .. } | ClipContent::Midi { .. } => None, + } + } + + /// Offset into the audio file or movie (0 for MIDI). + pub fn source_offset(&self) -> Samples { + match self.content { + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => offset, + ClipContent::Midi { .. } => 0, + } + } + + /// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades. + pub fn gain_at(&self, rel: Samples) -> f32 { + self.gain_at_with_fades(rel, self.fade_in, self.fade_out) + } + + /// Evaluate temporary fades while retaining clip gain and its envelope. + /// UI previews can use this without modifying the clip or rebuilding media. + pub fn gain_at_with_fades(&self, rel: Samples, fade_in: Fade, fade_out: Fade) -> f32 { + let mut db = self.gain_db; + if !self.gain_env.is_empty() { + db += env_value(&self.gain_env, rel); + } + let mut g = db_to_gain(db); + if fade_in.len > 0 && rel < fade_in.len { + g *= fade_in.gain(rel as f32 / fade_in.len as f32); + } + let from_end = self.length.saturating_sub(rel); + if fade_out.len > 0 && from_end <= fade_out.len { + g *= fade_out.gain(from_end as f32 / fade_out.len as f32); + } + g + } + + /// Gain envelope value in dB at `rel` samples from the clip start (0 without an envelope). + pub fn env_db_at(&self, rel: Samples) -> f32 { + env_value(&self.gain_env, rel) + } + + /// Move the start edge to `new_start` keeping material anchored (trim start). + pub fn trim_start_to(&mut self, new_start: Samples) { + // #907: these are public APIs, so they have to survive arguments a command would never + // pass (commands clamp to `MAX_POSITION` first). `Samples` is `i64`, so an extreme + // `new_start` against an extreme `start` wrapped in release and panicked in debug. + // Never so far back that the length would pass `MAX_POSITION`: trimming the start keeps the end. + let end = self.end(); + let new_start = new_start.max(end.saturating_sub(MAX_POSITION)).clamp(-MAX_POSITION, MAX_POSITION).min(end.saturating_sub(1)); + let delta = new_start.saturating_sub(self.start); + // The mixer plays a stretched audio clip from `offset + rel / stretch`. + let stretch = if self.stretch.is_finite() && self.stretch > 0.0 { self.stretch } else { 1.0 }; + match &mut self.content { + ClipContent::Audio { offset, .. } if (stretch - 1.0).abs() >= 1e-9 => { + *offset = offset.saturating_add((delta as f64 / stretch).round() as Samples); + } + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => { + *offset = offset.saturating_add(delta); + } + ClipContent::Midi { .. } => {} + } + // MIDI note ticks are re-anchored by the engine, which knows the tempo map. + self.length = self.length.saturating_sub(delta).clamp(1, MAX_POSITION); + self.start = new_start; + if self.sync_point != 0 { + self.sync_point = self.sync_point.saturating_sub(delta); + } + self.drop_stray_sync(); + self.clamp_fades(); + // Keep the gain at the cut when earlier breakpoints are dropped. + if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) { + self.gain_env.insert(0, (delta, env_value(&self.gain_env, delta))); + } + self.gain_env.retain(|&(t, _)| t >= delta); + self.gain_env.iter_mut().for_each(|(t, _)| *t = t.saturating_sub(delta)); + } + + /// Move the end edge to `new_end`. + pub fn trim_end_to(&mut self, new_end: Samples) { + // #907: saturating, like `trim_start_to`. + let new_end = new_end.clamp(-MAX_POSITION, MAX_POSITION).max(self.start.saturating_add(1)); + self.length = new_end.saturating_sub(self.start).clamp(1, MAX_POSITION); + self.drop_stray_sync(); + self.clamp_fades(); + } + + /// Keep both fades within the clip. + pub fn clamp_fades(&mut self) { + self.fade_in.len = self.fade_in.len.clamp(0, self.length); + self.fade_out.len = self.fade_out.len.clamp(0, self.length); + } + + /// A sync point (0 means none) that a trim left outside the clip no longer marks anything. + fn drop_stray_sync(&mut self) { + if !(0..self.length).contains(&self.sync_point) { + self.sync_point = 0; + } + } + + /// Repair a clip read from a file: positions and lengths within ±[`MAX_POSITION`], as commands + /// keep theirs, fades within the clip, a name that can be drawn, and a usable gain and stretch. + pub fn sanitize(&mut self) { + bound(&mut self.start); + self.length = self.length.clamp(1, MAX_POSITION); + bound(&mut self.sync_point); + self.gain_env.iter_mut().for_each(|(at, _)| bound(at)); + match &mut self.content { + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => bound(offset), + ClipContent::Midi { sequence } => bound_sequence(sequence), + } + self.clamp_fades(); + self.fade_in.sanitize(); + self.fade_out.sanitize(); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + if !self.gain_db.is_finite() { + self.gain_db = 0.0; + } + if !self.stretch.is_finite() || self.stretch <= 0.0 { + self.stretch = 1.0; + } + } +} + +pub(crate) fn bound(at: &mut Samples) { + *at = (*at).clamp(-MAX_POSITION, MAX_POSITION); +} + +pub(crate) fn bound_sequence(s: &mut Sequence) { + for n in &mut s.notes { + bound(&mut n.start); + n.length = n.length.clamp(1, MAX_POSITION); + } + s.ctrls.iter_mut().for_each(|c| bound(&mut c.tick)); +} + +fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { + let mut prev: Option<(Samples, f32)> = None; + for &(t, v) in env { + if t >= at { + return match prev { + Some((pt, pv)) if t > pt => { + let elapsed = i128::from(at).saturating_sub(i128::from(pt)); + let span = i128::from(t).saturating_sub(i128::from(pt)); + pv + (v - pv) * (elapsed as f32 / span as f32) + } + _ => v, + }; + } + prev = Some((t, v)); + } + prev.map_or(0.0, |(_, v)| v) +} + +pub(crate) fn db_to_gain(db: f32) -> f32 { + if db.is_nan() || db <= -144.0 { 0.0 } else { 10f32.powf(db.min(48.0) / 20.0) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fade_shapes_hit_endpoints() { + for s in FadeShape::ALL { + assert!(s.gain(0.0).abs() < 1e-6, "{s:?}"); + assert!((s.gain(1.0) - 1.0).abs() < 1e-6, "{s:?}"); + assert_eq!(s.gain(f32::NAN), 0.0); + assert_eq!(FadeShape::from_id(s.label()), Some(s)); + } + } + + #[test] + fn fade_curvature_is_bounded_monotonic_and_inverts_at_the_midpoint() { + for shape in FadeShape::ALL { + let original = Fade { len: 1000, shape, curvature: 0.0 }; + for i in 0..=128 { + let t = i as f32 / 128.0; + assert_eq!(original.gain(t), shape.gain(t), "legacy curves must be unchanged"); + } + for curvature in [-1.0, -0.5, 0.0, 0.5, 1.0] { + let fade = Fade { curvature, ..original }; + assert_eq!(fade.gain(0.0), 0.0); + assert_eq!(fade.gain(1.0), 1.0); + let mut previous = 0.0; + for i in 0..=128 { + let gain = fade.gain(i as f32 / 128.0); + assert!(gain.is_finite() && (0.0..=1.0).contains(&gain)); + assert!(gain >= previous, "{shape:?}, {curvature}"); + previous = gain; + } + assert!((fade.curvature_for_midpoint_gain(fade.gain(0.5)) - curvature).abs() < 1e-5); + } + assert!((Fade { curvature: 0.5, ..original }).gain(0.5) > original.gain(0.5)); + assert!((Fade { curvature: -0.5, ..original }).gain(0.5) < original.gain(0.5)); + } + } + + #[test] + fn fade_curvature_loads_legacy_data_and_sanitizes_hostile_values() { + let fade: Fade = serde_json::from_str(r#"{"len":100,"shape":"SCurve"}"#).unwrap(); + assert_eq!(fade.curvature, 0.0); + for curvature in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -10.0, 10.0] { + let mut fade = Fade { curvature, ..fade }; + assert!(fade.gain(0.5).is_finite()); + fade.sanitize(); + assert!(fade.curvature.is_finite() && (-1.0..=1.0).contains(&fade.curvature)); + let roundtrip: Fade = serde_json::from_str(&serde_json::to_string(&fade).unwrap()).unwrap(); + assert_eq!(roundtrip, fade); + } + } + + #[test] + fn temporary_fades_keep_clip_gain_and_envelope_without_mutating_the_clip() { + let mut clip = Clip::audio(ClipId(1), "preview", SourceId(1), 0, 0, 1000); + clip.gain_db = -6.0; + clip.gain_env = vec![(0, 0.0), (1000, 6.0)]; + clip.fade_in = Fade { len: 100, shape: FadeShape::SCurve, curvature: 0.25 }; + clip.fade_out = Fade { len: 100, shape: FadeShape::Logarithmic, curvature: -0.1 }; + let before = clip.clone(); + let fade_in = Fade { len: 1000, shape: FadeShape::Linear, curvature: 0.5 }; + let fade_out = Fade { len: 1000, shape: FadeShape::Linear, curvature: -0.5 }; + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + let mut committed = clip.clone(); + committed.fade_in = fade_in; + committed.fade_out = fade_out; + for rel in [0, 50, 500, 999, 1000] { + assert_eq!(clip.gain_at_with_fades(rel, fade_in, fade_out), committed.gain_at(rel)); + } + for rel in [i64::MIN, i64::MAX] { + assert!(clip.gain_at(rel).is_finite()); + assert!(clip.gain_at_with_fades(rel, fade_in, fade_out).is_finite()); + } + assert_eq!(clip, before); + clip.gain_env = vec![(i64::MIN, 0.0), (i64::MAX, 6.0)]; + assert!((clip.gain_at(500) - db_to_gain(-3.0)).abs() < 1e-6); + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + } + + #[test] + fn gain_with_fades() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000); + c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + assert!(c.gain_at(0).abs() < 1e-6); + assert!((c.gain_at(50) - 0.5).abs() < 1e-6); + assert!((c.gain_at(500) - 1.0).abs() < 1e-6); + assert!((c.gain_at(950) - 0.5).abs() < 1e-6); + } + + #[test] + fn gain_envelope_interpolates() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 1000); + c.gain_env = vec![(0, 0.0), (1000, -20.0)]; + assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4); + } + + #[test] + fn trim_start_keeps_gain_envelope_at_cut() { + for (env, cut, expected) in [ + (vec![(0, 0.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![(0, 0.0), (100, -20.0)], 500, vec![(0, -20.0)]), + (vec![(0, 0.0), (500, -10.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![], 500, vec![]), + (vec![(100, -3.0)], 0, vec![(100, -3.0)]), + (vec![(100, -3.0)], -100, vec![(200, -3.0)]), + ] { + let mut clip = Clip::audio(ClipId(1), "a", SourceId(1), 100, 0, 1000); + clip.gain_env = env; + let gain_at_cut = clip.gain_at(cut); + clip.trim_start_to(cut); + assert_eq!(clip.gain_env, expected); + if cut > 0 { + assert!((clip.gain_at(0) - gain_at_cut).abs() < 1e-6); + } + } + } + + #[test] + fn trims_keep_material_anchored() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000); + c.trim_start_to(300); + assert_eq!((c.start, c.length, c.source_offset()), (300, 800, 1200)); + c.trim_end_to(500); + assert_eq!(c.length, 200); + c.trim_end_to(-50); + assert_eq!(c.length, 1); + // Regression: trimming the start must not leave a fade-out longer than the clip. + let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 1000); + c.fade_out = Fade { len: 800, shape: FadeShape::Linear, curvature: 0.0 }; + c.trim_start_to(900); + assert!(c.fade_out.len <= c.length); + } + + /// #907: `trim_start_to` / `trim_end_to` are public APIs, so they have to survive arguments a + /// command would never pass. `Samples` is `i64`, so an extreme `new_start` against an extreme + /// `start` wrapped in release and panicked in debug. Every position stays within + /// `±MAX_POSITION` and every length within `1..=MAX_POSITION`. + #[test] + fn trims_saturate_on_extreme_arguments() { + for &new_start in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, MAX_POSITION / 2, 1000); + c.trim_start_to(new_start); + assert!((-(MAX_POSITION as i128)..=(MAX_POSITION as i128)).contains(&(c.start as i128)), "start {new_start}: {}", c.start); + assert!((1..=MAX_POSITION).contains(&c.length), "start {new_start}: length {}", c.length); + assert_eq!(c.end(), MAX_POSITION / 2 + 1000, "start {new_start} moved the end"); + } + for &new_end in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] { + let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 1000, -(MAX_POSITION / 2), 1000); + c.trim_end_to(new_end); + assert!((1..=MAX_POSITION).contains(&c.length), "end {new_end}: length {}", c.length); + assert!(c.end() - c.start == c.length, "end {new_end}: start {} end {}", c.start, c.end()); + } + } + + #[test] + fn trim_start_of_stretched_audio_advances_offset_by_source_time() { + // The mixer plays source `offset + rel / stretch`, so a split at timeline 1000 of a 2x clip + // must start the right half at source sample 500. + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 2000); + c.stretch = 2.0; + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 500); + let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 2000); + c.stretch = 0.5; + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 2000); + let mut c = Clip::audio(ClipId(3), "c", SourceId(1), 0, 0, 2000); + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 1000); + } +} diff --git a/crates/scraft-format/src/ids.rs b/crates/scraft-format/src/ids.rs new file mode 100644 index 000000000..441ced1ae --- /dev/null +++ b/crates/scraft-format/src/ids.rs @@ -0,0 +1,21 @@ +//! Typed ids. All ids come from one session-wide counter so they never collide across kinds. + +macro_rules! id_type { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, serde::Serialize, serde::Deserialize)] + #[serde(transparent)] + pub struct $name(pub u64); + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +id_type!(TrackId); +id_type!(ClipId); +id_type!(SourceId); +id_type!(BusId); +id_type!(GroupId); +id_type!(MarkerId); diff --git a/crates/scraft-format/src/lib.rs b/crates/scraft-format/src/lib.rs new file mode 100644 index 000000000..c54d7f934 --- /dev/null +++ b/crates/scraft-format/src/lib.rs @@ -0,0 +1,123 @@ +//! The SoundCraft session document. +//! +//! A [`Session`] is pure data: tracks with playlists of clips, the mixer (inserts, sends, routing, +//! automation), tempo/meter, memory locations and groups. Audio sample data lives in +//! [`SourcePool`] behind `Arc`s, so cloning a session for undo is cheap. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod automation; +pub mod b64; +pub mod bundle; +mod clip; +mod ids; +mod markers; +mod mixer; +mod session; +mod source; +mod track; + +pub use craft_midi as soundcraft_midi; +pub use craft_time::soundcraft as soundcraft_time; + +pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint}; +pub use bundle::{BUNDLE_EXTENSION, BundleError, BundleLimits, DEFAULT_BUNDLE_LIMITS, check_session_size, read_bundle, write_bundle}; +pub use clip::{Clip, ClipContent, Fade, FadeShape}; +pub use ids::{BusId, ClipId, GroupId, MarkerId, SourceId, TrackId}; +pub use markers::{Group, MarkerKind, MemoryLocation}; +pub use mixer::{ + Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db, +}; +pub use session::{ + AutoName, BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError, + Tool, TrackView, ZoomState, ZoomToggle, truncate_chars, +}; +pub use source::{FileFormat, Source, SourceAudio, SourcePool, VideoSource}; +pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind}; + +pub use soundcraft_time as time; + +/// Largest `.scraft` session opened or written by SoundCraft. +pub const MAX_SESSION_BYTES: u64 = 256 << 20; + +/// Read a bare `.scraft` file after enforcing the caller's byte limit. +pub fn read_session_bytes(name: &str, bytes: &[u8], limit: u64) -> Result { + check_session_size(name, u64::try_from(bytes.len()).unwrap_or(u64::MAX), limit, false)?; + read_session_data(name, bytes.to_vec(), limit) +} + +/// Turn already-read `.scraft` bytes into text without making another copy. +pub fn read_session_data(name: &str, bytes: Vec, limit: u64) -> Result { + check_session_size(name, u64::try_from(bytes.len()).unwrap_or(u64::MAX), limit, false)?; + String::from_utf8(bytes).map_err(|_| BundleError::Session(format!("{name}: not a SoundCraft session"))) +} + +/// The size of pretty JSON for `session`, measured without retaining the serialized document. +pub fn session_json_len(session: &Session) -> Result { + struct Count(u64); + impl std::io::Write for Count { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut count = Count(0); + serde_json::to_writer_pretty(&mut count, session).map_err(|error| BundleError::Session(error.to_string()))?; + Ok(count.0) +} + +/// Serialize a `.scraft` session, refusing output larger than the caller's read limit while +/// keeping the temporary allocation within that limit. +pub fn write_session(session: &Session, limit: u64) -> Result { + write_session_named(session, "the session", limit) +} + +/// [`write_session`] with the path or label used for the session-size error. +pub fn write_session_named(session: &Session, name: &str, limit: u64) -> Result { + struct Bounded(Vec, u64, bool); + impl std::io::Write for Bounded { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let remaining = self.1.saturating_sub(u64::try_from(self.0.len()).unwrap_or(u64::MAX)); + let count = usize::try_from(remaining).unwrap_or(usize::MAX).min(bytes.len()); + self.0.extend_from_slice(&bytes[..count]); + self.2 |= count < bytes.len(); + Ok(count) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let capacity = usize::try_from(limit).unwrap_or(usize::MAX); + let mut output = Bounded(Vec::with_capacity(capacity.min(1024 * 1024)), limit, false); + let serialized = serde_json::to_writer_pretty(&mut output, session); + if output.2 { + check_session_size(name, limit.saturating_add(1), limit, true)?; + } + serialized.map_err(|error| BundleError::Session(error.to_string()))?; + String::from_utf8(output.0).map_err(|error| BundleError::Session(error.to_string())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod session_io_tests { + use super::*; + + #[test] + fn bounded_writer_matches_canonical_session_json_and_refuses_one_byte_under() { + let session = Session::default(); + let canonical = session.to_json().unwrap(); + let len = u64::try_from(canonical.len()).unwrap(); + assert_eq!(session_json_len(&session).unwrap(), len); + assert_eq!(write_session(&session, len).unwrap(), canonical); + assert!(write_session(&session, len - 1).is_err()); + } + + #[test] + fn bare_session_reader_checks_size_and_utf8() { + assert!(read_session_bytes("song.scraft", b"{}", 1).is_err()); + assert!(read_session_bytes("song.scraft", &[0xff], 10).is_err()); + assert_eq!(read_session_bytes("song.scraft", b"{}", 2).unwrap(), "{}"); + } +} diff --git a/crates/scraft-format/src/markers.rs b/crates/scraft-format/src/markers.rs new file mode 100644 index 000000000..eaa517dd2 --- /dev/null +++ b/crates/scraft-format/src/markers.rs @@ -0,0 +1,58 @@ +//! Memory locations (markers and stored selections) and mix/edit groups. + +use crate::soundcraft_time::Samples; +use crate::{GroupId, MarkerId, TrackId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum MarkerKind { + /// A point marker on a ruler. + #[default] + Marker, + /// A stored selection range. + Selection, + /// A location that only recalls view settings. + None, +} + +/// A memory location (Window › Memory Locations). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MemoryLocation { + pub id: MarkerId, + /// User-visible number (1-based, unique). + pub number: u32, + pub name: String, + pub kind: MarkerKind, + pub start: Samples, + pub end: Samples, + #[serde(default)] + pub comments: String, + #[serde(default)] + pub color: Option<[u8; 3]>, + /// Marker ruler lane 1..5. + #[serde(default = "one")] + pub ruler: u8, + /// Track-lane marker on this track, if any. + #[serde(default)] + pub track: Option, +} + +fn one() -> u8 { + 1 +} + +/// A track group (Track › Group…). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Group { + pub id: GroupId, + pub name: String, + /// Group letter id shown in the Groups list ("a", "b", …). + pub letter: char, + pub members: Vec, + pub edit: bool, + pub mix: bool, + pub active: bool, + pub color: [u8; 3], + /// Shared attributes linked by the group (volume, mute, solo, rec, etc.). + #[serde(default)] + pub attributes: Vec, +} diff --git a/crates/scraft-format/src/mixer.rs b/crates/scraft-format/src/mixer.rs new file mode 100644 index 000000000..efdaf2d17 --- /dev/null +++ b/crates/scraft-format/src/mixer.rs @@ -0,0 +1,714 @@ +//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo. + +use crate::{AutomationMode, BusId, SourceId}; +use std::collections::BTreeMap; + +pub const INSERT_SLOTS: usize = 10; +pub const SEND_SLOTS: usize = 10; + +/// Channel formats (track widths). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum ChannelFormat { + #[default] + Mono, + Stereo, + Lcr, + Quad, + Lcrs, + Surround50, + Surround51, + Surround60, + Surround61, + Sdds70, + Sdds71, + Surround70, + Surround71, + Atmos702, + Atmos712, + Atmos502, + Atmos512, + Atmos504, + Atmos514, + Atmos704, + Atmos714, + Atmos706, + Atmos716, + Atmos904, + Atmos914, + Atmos906, + Atmos916, + /// Ambisonics of order 1..=7 ((n+1)² channels). + Ambisonics(u8), +} + +impl ChannelFormat { + pub const ALL: [ChannelFormat; 34] = [ + ChannelFormat::Mono, + ChannelFormat::Stereo, + ChannelFormat::Lcr, + ChannelFormat::Quad, + ChannelFormat::Lcrs, + ChannelFormat::Surround50, + ChannelFormat::Surround51, + ChannelFormat::Surround60, + ChannelFormat::Surround61, + ChannelFormat::Sdds70, + ChannelFormat::Sdds71, + ChannelFormat::Surround70, + ChannelFormat::Surround71, + ChannelFormat::Atmos702, + ChannelFormat::Atmos712, + ChannelFormat::Ambisonics(1), + ChannelFormat::Ambisonics(2), + ChannelFormat::Ambisonics(3), + ChannelFormat::Ambisonics(4), + ChannelFormat::Ambisonics(5), + ChannelFormat::Ambisonics(6), + ChannelFormat::Ambisonics(7), + ChannelFormat::Atmos502, + ChannelFormat::Atmos512, + ChannelFormat::Atmos504, + ChannelFormat::Atmos514, + ChannelFormat::Atmos704, + ChannelFormat::Atmos714, + ChannelFormat::Atmos706, + ChannelFormat::Atmos716, + ChannelFormat::Atmos904, + ChannelFormat::Atmos914, + ChannelFormat::Atmos906, + ChannelFormat::Atmos916, + ]; + pub fn channels(self) -> usize { + match self { + ChannelFormat::Mono => 1, + ChannelFormat::Stereo => 2, + ChannelFormat::Lcr => 3, + ChannelFormat::Quad | ChannelFormat::Lcrs => 4, + ChannelFormat::Surround50 => 5, + ChannelFormat::Surround51 | ChannelFormat::Surround60 => 6, + ChannelFormat::Surround61 | ChannelFormat::Sdds70 | ChannelFormat::Surround70 | ChannelFormat::Atmos502 => 7, + ChannelFormat::Sdds71 | ChannelFormat::Surround71 | ChannelFormat::Atmos512 => 8, + ChannelFormat::Atmos702 | ChannelFormat::Atmos504 => 9, + ChannelFormat::Atmos712 | ChannelFormat::Atmos514 => 10, + ChannelFormat::Atmos704 => 11, + ChannelFormat::Atmos714 => 12, + ChannelFormat::Atmos706 | ChannelFormat::Atmos904 => 13, + ChannelFormat::Atmos716 | ChannelFormat::Atmos914 => 14, + ChannelFormat::Atmos906 => 15, + ChannelFormat::Atmos916 => 16, + ChannelFormat::Ambisonics(n) => { + let n = usize::from(n.clamp(1, 7)) + 1; + n * n + } + } + } + pub fn label(self) -> &'static str { + match self { + ChannelFormat::Mono => "Mono", + ChannelFormat::Stereo => "Stereo", + ChannelFormat::Lcr => "LCR", + ChannelFormat::Quad => "Quad", + ChannelFormat::Lcrs => "LCRS", + ChannelFormat::Surround50 => "5.0", + ChannelFormat::Surround51 => "5.1", + ChannelFormat::Surround60 => "6.0", + ChannelFormat::Surround61 => "6.1", + ChannelFormat::Sdds70 => "7.0 SDDS", + ChannelFormat::Sdds71 => "7.1 SDDS", + ChannelFormat::Surround70 => "7.0", + ChannelFormat::Surround71 => "7.1", + ChannelFormat::Atmos702 => "7.0.2", + ChannelFormat::Atmos712 => "7.1.2", + ChannelFormat::Atmos502 => "5.0.2", + ChannelFormat::Atmos512 => "5.1.2", + ChannelFormat::Atmos504 => "5.0.4", + ChannelFormat::Atmos514 => "5.1.4", + ChannelFormat::Atmos704 => "7.0.4", + ChannelFormat::Atmos714 => "7.1.4", + ChannelFormat::Atmos706 => "7.0.6", + ChannelFormat::Atmos716 => "7.1.6", + ChannelFormat::Atmos904 => "9.0.4", + ChannelFormat::Atmos914 => "9.1.4", + ChannelFormat::Atmos906 => "9.0.6", + ChannelFormat::Atmos916 => "9.1.6", + ChannelFormat::Ambisonics(1) => "1st Order Ambisonics", + ChannelFormat::Ambisonics(2) => "2nd Order Ambisonics", + ChannelFormat::Ambisonics(3) => "3rd Order Ambisonics", + ChannelFormat::Ambisonics(4) => "4th Order Ambisonics", + ChannelFormat::Ambisonics(5) => "5th Order Ambisonics", + ChannelFormat::Ambisonics(6) => "6th Order Ambisonics", + ChannelFormat::Ambisonics(_) => "7th Order Ambisonics", + } + } + pub fn from_id(s: &str) -> Option { + ChannelFormat::ALL.into_iter().find(|f| f.label().eq_ignore_ascii_case(s.trim())) + } + /// Speakers in channel order, or empty for Ambisonics (ACN order, SN3D). + /// + /// Channel order is SMPTE / WAV order: speakers with a WAVE_FORMAT_EXTENSIBLE mask bit come in + /// ascending bit order (L R C LFE Ls Rs Lc Rc Cs Lss Rss Ltf Rtf Ltr Rtr; 7.x puts the rear + /// surrounds Lrs/Rrs on the "back" bits before the sides Lss/Rss), followed by speakers WAV + /// cannot name (the top-middle pair of .6 layouts). E.g. 5.1 = L R C LFE Ls Rs, + /// 7.1 = L R C LFE Lrs Rrs Lss Rss, 7.1.4 = L R C LFE Lrs Rrs Lss Rss Ltf Rtf Ltr Rtr. + pub fn speakers(self) -> &'static [Speaker] { + use Speaker::*; + match self { + ChannelFormat::Mono => &[C], + ChannelFormat::Stereo => &[L, R], + ChannelFormat::Lcr => &[L, R, C], + ChannelFormat::Quad => &[L, R, Ls, Rs], + ChannelFormat::Lcrs => &[L, R, C, Cs], + ChannelFormat::Surround50 => &[L, R, C, Ls, Rs], + ChannelFormat::Surround51 => &[L, R, C, Lfe, Ls, Rs], + ChannelFormat::Surround60 => &[L, R, C, Ls, Rs, Cs], + ChannelFormat::Surround61 => &[L, R, C, Lfe, Ls, Rs, Cs], + ChannelFormat::Sdds70 => &[L, R, C, Ls, Rs, Lc, Rc], + ChannelFormat::Sdds71 => &[L, R, C, Lfe, Ls, Rs, Lc, Rc], + ChannelFormat::Surround70 => &[L, R, C, Lrs, Rrs, Lss, Rss], + ChannelFormat::Surround71 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss], + ChannelFormat::Atmos702 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltm, Rtm], + ChannelFormat::Atmos712 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltm, Rtm], + ChannelFormat::Atmos502 => &[L, R, C, Ls, Rs, Ltm, Rtm], + ChannelFormat::Atmos512 => &[L, R, C, Lfe, Ls, Rs, Ltm, Rtm], + ChannelFormat::Atmos504 => &[L, R, C, Ls, Rs, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos514 => &[L, R, C, Lfe, Ls, Rs, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos704 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos714 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos706 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos716 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos904 => &[L, R, C, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos914 => &[L, R, C, Lfe, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos906 => &[L, R, C, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos916 => &[L, R, C, Lfe, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Ambisonics(_) => &[], + } + } + pub fn is_ambisonic(self) -> bool { + matches!(self, ChannelFormat::Ambisonics(_)) + } + pub fn has_lfe(self) -> bool { + self.speakers().contains(&Speaker::Lfe) + } + pub fn has_height(self) -> bool { + self.speakers().iter().any(|s| s.is_height()) + } + /// Short per-channel labels for meters (`W Y Z X 5…` for Ambisonics). + pub fn channel_label(self, ch: usize) -> String { + match self.speakers().get(ch) { + Some(s) => s.label().to_string(), + None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), ToString::to_string), + None => format!("{}", ch + 1), + } + } + /// WAVE_FORMAT_EXTENSIBLE channel mask (0 = no positions, e.g. Ambisonics). Speakers that WAV + /// cannot name (top middle) borrow the top-front bits when the layout has no top fronts. + pub fn channel_mask(self) -> u32 { + let sp = self.speakers(); + let has_tf = sp.contains(&Speaker::Ltf); + sp.iter().fold(0u32, |m, s| match s { + Speaker::Ltm if !has_tf => m | 0x1000, + Speaker::Rtm if !has_tf => m | 0x4000, + s => m | s.wave_bit(), + }) + } + /// Best format for a channel count. + pub fn for_channels(n: usize) -> ChannelFormat { + [ + ChannelFormat::Mono, + ChannelFormat::Stereo, + ChannelFormat::Lcr, + ChannelFormat::Quad, + ChannelFormat::Surround50, + ChannelFormat::Surround51, + ChannelFormat::Surround70, + ChannelFormat::Surround71, + ] + .into_iter() + .find(|f| f.channels() == n) + .unwrap_or(if n <= 1 { ChannelFormat::Mono } else { ChannelFormat::Stereo }) + } +} + +/// A loudspeaker position in a channel format. +/// +/// Angles follow ITU-R BS.775 / Dolby conventions: azimuth in degrees, 0 = front centre, positive +/// = to the right; elevation in degrees above the listener plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Speaker { + L, + R, + C, + Lfe, + /// 5.x surrounds (±110°). + Ls, + Rs, + /// SDDS inner fronts (±15°). + Lc, + Rc, + /// Centre surround (180°). + Cs, + /// 7.x side surrounds (±90°). + Lss, + Rss, + /// 7.x rear surrounds (±150°). + Lrs, + Rrs, + /// 9.x front wides (±60°). + Lw, + Rw, + /// Top front (±45°, elevation 45°). + Ltf, + Rtf, + /// Top middle (±90°, elevation 45°). + Ltm, + Rtm, + /// Top rear (±135°, elevation 45°). + Ltr, + Rtr, +} + +impl Speaker { + pub fn azimuth(self) -> f32 { + match self { + Speaker::L => -30.0, + Speaker::R => 30.0, + Speaker::C | Speaker::Lfe => 0.0, + Speaker::Ls => -110.0, + Speaker::Rs => 110.0, + Speaker::Lc => -15.0, + Speaker::Rc => 15.0, + Speaker::Cs => 180.0, + Speaker::Lss => -90.0, + Speaker::Rss => 90.0, + Speaker::Lrs => -150.0, + Speaker::Rrs => 150.0, + Speaker::Lw => -60.0, + Speaker::Rw => 60.0, + Speaker::Ltf => -45.0, + Speaker::Rtf => 45.0, + Speaker::Ltm => -90.0, + Speaker::Rtm => 90.0, + Speaker::Ltr => -135.0, + Speaker::Rtr => 135.0, + } + } + pub fn elevation(self) -> f32 { + if self.is_height() { 45.0 } else { 0.0 } + } + pub fn is_height(self) -> bool { + matches!(self, Speaker::Ltf | Speaker::Rtf | Speaker::Ltm | Speaker::Rtm | Speaker::Ltr | Speaker::Rtr) + } + pub fn is_lfe(self) -> bool { + self == Speaker::Lfe + } + pub fn label(self) -> &'static str { + match self { + Speaker::L => "L", + Speaker::R => "R", + Speaker::C => "C", + Speaker::Lfe => "LFE", + Speaker::Ls => "Ls", + Speaker::Rs => "Rs", + Speaker::Lc => "Lc", + Speaker::Rc => "Rc", + Speaker::Cs => "Cs", + Speaker::Lss => "Lss", + Speaker::Rss => "Rss", + Speaker::Lrs => "Lrs", + Speaker::Rrs => "Rrs", + Speaker::Lw => "Lw", + Speaker::Rw => "Rw", + Speaker::Ltf => "Ltf", + Speaker::Rtf => "Rtf", + Speaker::Ltm => "Ltm", + Speaker::Rtm => "Rtm", + Speaker::Ltr => "Ltr", + Speaker::Rtr => "Rtr", + } + } + /// WAVE_FORMAT_EXTENSIBLE `dwChannelMask` bit (0 for speakers WAV cannot name). + pub fn wave_bit(self) -> u32 { + match self { + Speaker::L => 0x1, + Speaker::R => 0x2, + Speaker::C => 0x4, + Speaker::Lfe => 0x8, + Speaker::Ls | Speaker::Lrs => 0x10, + Speaker::Rs | Speaker::Rrs => 0x20, + Speaker::Lc | Speaker::Lw => 0x40, + Speaker::Rc | Speaker::Rw => 0x80, + Speaker::Cs => 0x100, + Speaker::Lss => 0x200, + Speaker::Rss => 0x400, + Speaker::Ltf => 0x1000, + Speaker::Rtf => 0x4000, + Speaker::Ltr => 0x8000, + Speaker::Rtr => 0x2_0000, + Speaker::Ltm | Speaker::Rtm => 0, + } + } +} + +/// Surround panner position (Pro Tools-style X/Y puck). `None` on a mixer means "use the stereo +/// pan", which keeps sessions made before surround mixing sounding exactly as they did. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SurroundPan { + /// -1 (left) ..= 1 (right). + pub x: f32, + /// -1 (back) ..= 1 (front). + pub y: f32, + /// Elevation 0 (ear level) ..= 1 (overhead), used by height formats. + pub z: f32, + /// 0 = point source ..= 1 = spread over every speaker. + pub divergence: f32, + /// Centre speaker share of front-centre images, 0..=100 % (0 = phantom centre on L/R). + pub center: f32, + /// LFE send level, dB (-144 = off). + pub lfe_db: f32, +} + +impl Default for SurroundPan { + fn default() -> Self { + SurroundPan { x: 0.0, y: 1.0, z: 0.0, divergence: 0.0, center: 100.0, lfe_db: -144.0 } + } +} + +impl SurroundPan { + /// Puck at the stereo pan position (front edge). + pub fn from_stereo(pan: f32) -> Self { + SurroundPan { x: finite_or(pan, 0.0).clamp(-1.0, 1.0), ..SurroundPan::default() } + } + /// Clamp into range (NaN → defaults). + pub fn sanitize(&mut self) { + let d = SurroundPan::default(); + self.x = finite_or(self.x, d.x).clamp(-1.0, 1.0); + self.y = finite_or(self.y, d.y).clamp(-1.0, 1.0); + self.z = finite_or(self.z, d.z).clamp(0.0, 1.0); + self.divergence = finite_or(self.divergence, d.divergence).clamp(0.0, 1.0); + self.center = finite_or(self.center, d.center).clamp(0.0, 100.0); + self.lfe_db = finite_or(self.lfe_db, d.lfe_db).clamp(-144.0, 12.0); + } +} + +/// An internal mix bus (Pro Tools "bus" paths). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Bus { + pub id: BusId, + pub name: String, + pub format: ChannelFormat, +} + +/// Physical output paths (from the I/O setup). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct OutputPath { + pub name: String, + /// First hardware channel (0-based). + pub first_channel: u16, + pub format: ChannelFormat, +} + +/// Where a signal goes or comes from. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum Route { + #[default] + None, + /// The main output (hardware outputs 1-2, or the bounce source). + Main, + Bus(BusId), + /// Hardware input/output by path name. + Hardware(String), +} + +/// A plugin instance on an insert slot. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Insert { + /// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`). + pub plugin: String, + #[serde(default, deserialize_with = "finite_params")] + pub params: BTreeMap, + #[serde(default)] + pub bypass: bool, + #[serde(default = "yes")] + pub active: bool, + /// Preset name shown in the plugin window header. + #[serde(default)] + pub preset: String, + /// Opaque plugin state (third-party plugins: CLAP state / VST3 component + controller + /// state), standard base64. Restored into a new instance before it first processes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option, + /// The session audio source a sample-playing instrument (the built-in Sampler) plays. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample: Option, +} + +fn yes() -> bool { + true +} + +impl Insert { + pub fn new(plugin: impl Into) -> Self { + Insert { + plugin: plugin.into(), + params: BTreeMap::new(), + bypass: false, + active: true, + preset: String::from(""), + state: None, + sample: None, + } + } + + /// The decoded plugin state (`None` when absent or malformed). + pub fn state_bytes(&self) -> Option> { + self.state.as_deref().and_then(crate::b64::decode) + } + + /// Stores a plugin state blob (base64-encoded); an empty blob clears it. + pub fn set_state_bytes(&mut self, data: &[u8]) { + self.state = (!data.is_empty()).then(|| crate::b64::encode(data)); + } +} + +/// A send to a bus. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SendSlot { + pub target: Route, + pub level_db: f32, + pub pan: f32, + pub mute: bool, + pub pre_fader: bool, + #[serde(default = "yes")] + pub follow_main_pan: bool, + /// Surround position into a multichannel bus (None = follow the track / stereo pan). + #[serde(default)] + pub surround: Option, +} + +impl SendSlot { + pub fn new(target: Route) -> Self { + SendSlot { target, level_db: -144.0, pan: 0.0, mute: false, pre_fader: false, follow_main_pan: true, surround: None } + } +} + +/// The channel strip. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Mixer { + pub volume_db: f32, + /// One panner per source channel for mono/stereo (stereo tracks: two panners, default hard L/R). + pub pan: Vec, + pub mute: bool, + pub solo: bool, + #[serde(default)] + pub solo_safe: bool, + /// Live record-enable state. Runtime-only: opening a session must never arm an input. + #[serde(skip)] + pub record_arm: bool, + /// Live input-monitor state. Runtime-only: opening a session must never enable monitoring. + #[serde(skip)] + pub input_monitor: bool, + #[serde(default)] + pub phase_invert: bool, + pub input: Route, + pub output: Route, + pub inserts: Vec>, + pub sends: Vec>, + pub automation_mode: AutomationMode, + /// Input gain / trim, dB. + #[serde(default)] + pub trim_db: f32, + /// VCA master that controls this track, if any (track id of the VCA). + #[serde(default)] + pub vca: Option, + /// Surround panner, used when the output is multichannel (None = the stereo pan to L/R). + #[serde(default)] + pub surround: Option, +} + +impl Mixer { + pub fn new(format: ChannelFormat) -> Self { + let pan = match format { + ChannelFormat::Mono => vec![0.0], + ChannelFormat::Stereo => vec![-1.0, 1.0], + _ => Vec::new(), + }; + Mixer { + volume_db: 0.0, + pan, + mute: false, + solo: false, + solo_safe: false, + record_arm: false, + input_monitor: false, + phase_invert: false, + input: Route::None, + output: Route::Main, + inserts: vec![None; INSERT_SLOTS], + sends: vec![None; SEND_SLOTS], + automation_mode: AutomationMode::Read, + trim_db: 0.0, + vca: None, + surround: None, + } + } + + /// Clamp everything into range (after deserialising untrusted files). + pub fn sanitize(&mut self) { + self.volume_db = finite_or(self.volume_db, 0.0).clamp(-144.0, 12.0); + for p in &mut self.pan { + *p = finite_or(*p, 0.0).clamp(-1.0, 1.0); + } + self.inserts.resize(INSERT_SLOTS, None); + self.sends.resize(SEND_SLOTS, None); + for s in self.sends.iter_mut().flatten() { + s.level_db = finite_or(s.level_db, -144.0).clamp(-144.0, 12.0); + s.pan = finite_or(s.pan, 0.0).clamp(-1.0, 1.0); + if let Some(sp) = &mut s.surround { + sp.sanitize(); + } + } + if let Some(sp) = &mut self.surround { + sp.sanitize(); + } + self.trim_db = finite_or(self.trim_db, 0.0).clamp(-144.0, 24.0); + } +} + +impl crate::Session { + /// The main mix format: the format of the first (main) output path, at least stereo and at + /// most 16 channels (wider formats fall back to stereo). + pub fn main_format(&self) -> ChannelFormat { + match self.outputs.first().map(|o| o.format) { + Some(f) if (2..=16).contains(&f.channels()) => f, + _ => ChannelFormat::Stereo, + } + } +} + +pub(crate) fn finite_or(v: f32, d: f32) -> f32 { + if v.is_finite() { v } else { d } +} + +/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so +/// they keep their defaults instead of failing the whole session. +fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let params: BTreeMap> = serde::Deserialize::deserialize(d)?; + Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect()) +} + +/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12). +pub fn fader_pos_to_db(pos: f32) -> f32 { + let p = finite_or(pos, 0.0).clamp(0.0, 1.0); + if p <= 0.0 { + return -144.0; + } + // Piecewise: top 75 % covers -24..+12 linearly-ish, the bottom compresses to -inf. + if p >= 0.25 { -24.0 + (p - 0.25) / 0.75 * 36.0 } else { -24.0 - (1.0 - p / 0.25).powf(1.5) * 120.0 } +} + +pub fn fader_db_to_pos(db: f32) -> f32 { + let db = finite_or(db, -144.0); + if db <= -144.0 { + return 0.0; + } + if db >= -24.0 { + (0.25 + (db.min(12.0) + 24.0) / 36.0 * 0.75).min(1.0) + } else { + let x = ((-24.0 - db) / 120.0).clamp(0.0, 1.0).powf(1.0 / 1.5); + ((1.0 - x) * 0.25).max(0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_state_round_trips_through_json() { + let mut i = Insert::new("vst3:00"); + assert_eq!(i.state_bytes(), None); + let blob: Vec = (0..=255u8).cycle().take(1000).collect(); + i.set_state_bytes(&blob); + let json = serde_json::to_string(&i).unwrap(); + let back: Insert = serde_json::from_str(&json).unwrap(); + assert_eq!(back.state_bytes().unwrap(), blob); + i.set_state_bytes(&[]); + assert_eq!(i.state, None); + // Old sessions have no `state`; stateless inserts don't write one. + let old: Insert = serde_json::from_str(r#"{"plugin":"eq_7band"}"#).unwrap(); + assert_eq!(old.state, None); + assert!(!serde_json::to_string(&old).unwrap().contains("state")); + // A hostile state string decodes to nothing rather than failing the load. + let bad: Insert = serde_json::from_str(r#"{"plugin":"x","state":"!!!"}"#).unwrap(); + assert_eq!(bad.state_bytes(), None); + } + + #[test] + fn params_saved_as_null_or_out_of_range_are_dropped() { + let mut i = Insert::new("eq_7band"); + i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]); + let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap(); + assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)])); + let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap(); + assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)])); + } + + #[test] + fn fader_taper_round_trips() { + for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] { + let p = fader_db_to_pos(db); + assert!((fader_pos_to_db(p) - db).abs() < 0.05, "{db} → {p}"); + } + assert_eq!(fader_pos_to_db(0.0), -144.0); + assert_eq!(fader_db_to_pos(f32::NAN), 0.0); + } + + #[test] + fn mixer_defaults_and_sanitize() { + let mut m = Mixer::new(ChannelFormat::Stereo); + assert_eq!(m.pan, vec![-1.0, 1.0]); + m.volume_db = f32::INFINITY; + m.inserts.truncate(2); + m.sanitize(); + assert_eq!(m.volume_db, 0.0); + assert_eq!(m.inserts.len(), INSERT_SLOTS); + } + + #[test] + fn speaker_layouts_match_channel_counts_and_masks() { + for f in ChannelFormat::ALL { + if !f.is_ambisonic() { + assert_eq!(f.speakers().len(), f.channels(), "{f:?}"); + // Mask bits are unique and in ascending channel order. + let bits: Vec = f.speakers().iter().map(|s| s.wave_bit()).filter(|b| *b != 0).collect(); + assert!(bits.windows(2).all(|w| w[0] < w[1]), "{f:?} not in WAV order"); + } + } + assert_eq!(ChannelFormat::Surround51.channel_mask(), 0x3F); + assert_eq!(ChannelFormat::Surround71.channel_mask(), 0x63F); + assert_eq!(ChannelFormat::Atmos714.channel_mask(), 0x2_D63F); + assert_eq!(ChannelFormat::Ambisonics(1).channel_mask(), 0); + } + + #[test] + fn surround_pan_defaults_and_old_sessions() { + let m = Mixer::new(ChannelFormat::Mono); + assert_eq!(m.surround, None); + // A mixer saved before surround existed still loads. + let mut v = serde_json::to_value(&m).unwrap(); + v.as_object_mut().unwrap().remove("surround"); + let back: Mixer = serde_json::from_value(v).unwrap(); + assert_eq!(back.surround, None); + let mut sp: SurroundPan = serde_json::from_str(r#"{"x": 5.0}"#).unwrap(); + sp.sanitize(); + assert_eq!(sp.x, 1.0); + assert_eq!(sp.center, 100.0); + } + + #[test] + fn formats() { + assert_eq!(ChannelFormat::for_channels(6), ChannelFormat::Surround51); + assert_eq!(ChannelFormat::from_id("stereo"), Some(ChannelFormat::Stereo)); + } +} diff --git a/crates/scraft-format/src/session.rs b/crates/scraft-format/src/session.rs new file mode 100644 index 000000000..021cab8bc --- /dev/null +++ b/crates/scraft-format/src/session.rs @@ -0,0 +1,1331 @@ +//! The session: the whole document. + +use crate::clip::{bound, bound_sequence}; +use crate::soundcraft_time::{FrameRate, GridScale, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat}; +use crate::{ + Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool, + TRACK_COLORS, Track, TrackId, TrackKind, VideoSource, +}; + +/// Native session file extension. +pub const SESSION_EXTENSION: &str = "scraft"; + +/// `EditState::values` key holding the feet+frames reading of sample 0, in frames. +pub const FEET_FRAMES_START: &str = "feet_frames.start"; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SessionError { + #[error("not a SoundCraft session: {0}")] + Format(String), + #[error("session file version {0} is newer than this SoundCraft supports")] + Version(u32), + #[error("{0}")] + Invalid(String), +} + +/// Session bit depth for recording/rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum BitDepthSetting { + Int16, + #[default] + Int24, + Float32, +} + +/// Edit modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum EditMode { + Shuffle, + #[default] + Slip, + Spot, + Grid, + /// Relative grid: moves keep their offset from the grid. + GridRelative, +} + +impl EditMode { + pub fn label(self) -> &'static str { + match self { + EditMode::Shuffle => "SHUFFLE", + EditMode::Slip => "SLIP", + EditMode::Spot => "SPOT", + EditMode::Grid => "GRID", + EditMode::GridRelative => "REL GRID", + } + } + pub fn from_id(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "shuffle" => Some(EditMode::Shuffle), + "slip" => Some(EditMode::Slip), + "spot" => Some(EditMode::Spot), + "grid" | "absolute_grid" => Some(EditMode::Grid), + "relative_grid" | "rel_grid" | "grid_relative" => Some(EditMode::GridRelative), + _ => None, + } + } + pub fn is_grid(self) -> bool { + matches!(self, EditMode::Grid | EditMode::GridRelative) + } +} + +/// Edit tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum Tool { + Zoom, + Trim, + #[default] + Selector, + Grabber, + Scrubber, + Pencil, + Smart, +} + +impl Tool { + pub const ALL: [Tool; 7] = [Tool::Zoom, Tool::Trim, Tool::Selector, Tool::Grabber, Tool::Scrubber, Tool::Pencil, Tool::Smart]; + pub fn id(self) -> &'static str { + match self { + Tool::Zoom => "zoom", + Tool::Trim => "trim", + Tool::Selector => "selector", + Tool::Grabber => "grabber", + Tool::Scrubber => "scrubber", + Tool::Pencil => "pencil", + Tool::Smart => "smart", + } + } + pub fn label(self) -> &'static str { + match self { + Tool::Zoom => "Zoomer", + Tool::Trim => "Trim", + Tool::Selector => "Selector", + Tool::Grabber => "Grabber", + Tool::Scrubber => "Scrubber", + Tool::Pencil => "Pencil", + Tool::Smart => "Smart Tool", + } + } + pub fn from_id(s: &str) -> Option { + Tool::ALL.into_iter().find(|t| t.id() == s.to_ascii_lowercase()) + } + /// F-key shortcut (F5..F10) like the incumbent. + pub fn fkey(self) -> Option<&'static str> { + match self { + Tool::Zoom => Some("F5"), + Tool::Trim => Some("F6"), + Tool::Selector => Some("F7"), + Tool::Grabber => Some("F8"), + Tool::Scrubber => Some("F9"), + Tool::Pencil => Some("F10"), + Tool::Smart => None, + } + } +} + +/// Track view lanes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum TrackView { + Waveform, + Blocks, + Volume, + Pan, + Mute, +} + +/// Horizontal zoom + scroll. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ZoomState { + /// Samples per pixel (point). + pub samples_per_px: f64, + /// Left edge of the visible timeline. + pub scroll: Samples, + /// Waveform vertical zoom factor. + pub waveform_zoom: f32, + /// Zoom preset memories 1..5. + pub presets: [f64; 5], +} + +impl Default for ZoomState { + /// Starts with a practical overview zoom and five reusable preset levels. + fn default() -> Self { + ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] } + } +} + +/// The view Zoom Toggle goes back to. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ZoomToggle { + pub samples_per_px: f64, + pub scroll: Samples, + pub heights: Vec<(TrackId, crate::TrackHeight)>, +} + +/// Edit-window state saved with the session (selection, tools, modes, view settings). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct EditState { + pub tool: Tool, + pub edit_mode: EditMode, + /// The edit selection (a point = insertion position). + pub selection: Range, + /// Tracks in the edit selection. + pub selected_tracks: Vec, + /// Clips explicitly selected (Grabber selection). + #[serde(default)] + pub selected_clips: Vec, + /// Audio files selected in the Clip List. + #[serde(default)] + pub selected_sources: Vec, + /// Playhead position when stopped. + pub playhead: Samples, + pub grid: GridValue, + pub nudge: GridValue, + /// Grid/Nudge time scales follow the main counter when enabled. + #[serde(default)] + pub grid_follow_main: bool, + #[serde(default)] + pub nudge_follow_main: bool, + /// Grid lines and snapping use visible clip boundaries and memory locations while the numeric + /// Grid value is retained for when this mode is left. + #[serde(default)] + pub grid_clips_markers: bool, + #[serde(default = "yes")] + pub grid_lines: bool, + pub main_counter: TimeFormat, + pub sub_counter: Option, + pub zoom: ZoomState, + /// Set while Zoom Toggle is on. + pub zoom_toggle: Option, + /// Visible rulers. + pub rulers: Vec, + pub link_timeline_edit: bool, + pub link_track_edit: bool, + pub insertion_follows_playback: bool, + pub loop_playback: bool, + pub pre_post_roll: bool, + pub pre_roll: Samples, + pub post_roll: Samples, + pub tab_to_transient: bool, + pub automation_follows_edit: bool, + pub markers_follow_edit: bool, + pub layered_editing: bool, + pub mirrored_midi: bool, + pub click: bool, + pub countoff: bool, + pub countoff_bars: u32, + pub midi_merge: bool, + pub record_mode: String, + pub loop_record: bool, + pub quickpunch: bool, + pub scrolling: String, + pub solo_mode: String, + pub pre_fader_metering: bool, + pub delay_compensation: bool, + pub keyboard_focus: String, + /// Timeline selection when it is unlinked from the edit selection. + pub timeline_selection: Range, + /// Generic on/off view and option flags keyed by id (e.g. `view.clip.name`, `waveform.rectified`, + /// `options.midi_thru`). Saved with the session so every view setting persists. + #[serde(default)] + pub flags: std::collections::BTreeSet, + /// Numeric settings keyed by id (e.g. `click.volume_db`, `engine.buffer_size`). + #[serde(default)] + pub values: std::collections::BTreeMap, +} + +impl EditState { + pub fn flag(&self, id: &str) -> bool { + self.flags.contains(id) + } + pub fn set_flag(&mut self, id: &str, on: bool) { + if on { + self.flags.insert(id.to_string()); + } else { + self.flags.remove(id); + } + } + pub fn value(&self, id: &str, default: f64) -> f64 { + self.values.get(id).copied().filter(|v| v.is_finite()).unwrap_or(default) + } +} + +fn yes() -> bool { + true +} + +impl Default for EditState { + /// Starts the Edit window in Slip mode with independent Grid and Nudge scales. + fn default() -> Self { + EditState { + tool: Tool::Smart, + edit_mode: EditMode::Slip, + selection: Range::default(), + selected_tracks: Vec::new(), + selected_clips: Vec::new(), + selected_sources: Vec::new(), + playhead: 0, + grid: GridValue::default(), + nudge: GridValue::Seconds(1.0), + grid_follow_main: false, + nudge_follow_main: false, + grid_clips_markers: false, + grid_lines: true, + main_counter: TimeFormat::MinSecs, + sub_counter: Some(TimeFormat::Samples), + zoom: ZoomState::default(), + zoom_toggle: None, + rulers: vec!["bars_beats".into(), "min_secs".into(), "tempo".into(), "meter".into(), "markers".into()], + link_timeline_edit: true, + link_track_edit: false, + insertion_follows_playback: false, + loop_playback: false, + pre_post_roll: false, + pre_roll: 96_000, + post_roll: 96_000, + tab_to_transient: false, + automation_follows_edit: true, + markers_follow_edit: false, + layered_editing: false, + mirrored_midi: false, + click: false, + countoff: false, + countoff_bars: 2, + midi_merge: false, + record_mode: "normal".into(), + loop_record: false, + quickpunch: false, + scrolling: "page".into(), + solo_mode: "sip".into(), + pre_fader_metering: false, + delay_compensation: true, + keyboard_focus: "commands".into(), + timeline_selection: Range::default(), + flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"] + .iter() + .map(ToString::to_string) + .collect(), + values: std::collections::BTreeMap::new(), + } + } +} + +/// Clip List › Auto Rename: the names that editing gives the clips it separates from a clip. +/// Each new clip is named `name`, then `zeros` zeros, then `number`, then `suffix`, and `number` +/// counts up by one for the next clip. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct AutoName { + pub name: String, + /// The number the next new clip gets. + pub number: u64, + pub zeros: usize, + pub suffix: String, + /// Tells this scheme from another one under the same key after an undo or in another session, + /// for pastes from the clipboard. 0 matches no scheme. + #[serde(default)] + pub id: u64, +} + +impl AutoName { + /// The largest starting number. + pub const MAX_NUMBER: u64 = 999_999_999; + /// The most zeros before the number. + pub const MAX_ZEROS: usize = 9; + + /// Room for `name` and `suffix` together: 29 characters stay free for 9 zeros and the 20 + /// digits of a u64, so a clip name never loses its number to [`MAX_NAME_CHARS`]. + pub const MAX_TEXT_CHARS: usize = MAX_NAME_CHARS - 29; + + /// Repair a scheme read from a file: names that can be drawn and at most [`Self::MAX_ZEROS`] zeros. + pub fn sanitize(&mut self) { + truncate_chars(&mut self.name, Self::MAX_TEXT_CHARS); + truncate_chars(&mut self.suffix, Self::MAX_TEXT_CHARS - self.name.chars().count()); + self.zeros = self.zeros.min(Self::MAX_ZEROS); + } +} + +/// The document. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Session { + pub format_version: u32, + pub name: String, + #[serde(deserialize_with = "sample_rate_or_default")] + pub sample_rate: SampleRate, + pub bit_depth: BitDepthSetting, + pub frame_rate: FrameRate, + /// Timecode of sample 0 (session start), as a sample count. + pub timecode_start: Samples, + pub tempo: TempoMap, + #[serde(default)] + pub key_signatures: Vec<(Samples, String)>, + pub tracks: Vec, + pub sources: Vec, + /// Movies referenced by Video track clips. + #[serde(default)] + pub videos: Vec, + pub busses: Vec, + pub outputs: Vec, + pub markers: Vec, + pub groups: Vec, + /// Clip List › Auto Rename schemes, keyed by the ids in [`Clip::auto_name`]. The clips separated + /// from one clip share its scheme, so they count up one number sequence. + #[serde(default)] + pub auto_names: std::collections::BTreeMap, + pub edit: EditState, + pub next_id: u64, + #[serde(default)] + pub comments: String, + /// Original MIDI performances keyed by clip id (Event › MIDI Operations › Restore/Flatten + /// Performance). Independent of undo so a restore survives later edits. + #[serde(default)] + pub midi_originals: std::collections::BTreeMap, + #[serde(skip)] + pub pool: SourcePool, + /// The folder relative source paths point into: where the session was opened or last saved. + #[serde(skip)] + pub folder: Option, +} + +/// Older SoundCraft builds repaired an in-range but invalid sample rate after reading a session. +/// `craft-time` validates `SampleRate` during serde, so keep that file compatibility at this +/// domain boundary while retaining strict serde validation for direct `SampleRate` use. +fn sample_rate_or_default<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let hz = ::deserialize(deserializer)?; + Ok(SampleRate::new(hz).unwrap_or_default()) +} + +impl Default for Session { + fn default() -> Self { + Session::new("Untitled", SampleRate::HZ_48000) + } +} + +impl Session { + pub const FORMAT_VERSION: u32 = 1; + + pub fn new(name: impl Into, sample_rate: SampleRate) -> Self { + let mut s = Session { + format_version: Self::FORMAT_VERSION, + name: name.into(), + sample_rate, + bit_depth: BitDepthSetting::Int24, + frame_rate: FrameRate::Fps30, + timecode_start: 0, + tempo: TempoMap::default(), + key_signatures: Vec::new(), + tracks: Vec::new(), + sources: Vec::new(), + videos: Vec::new(), + busses: Vec::new(), + outputs: vec![OutputPath { name: "Out 1-2".into(), first_channel: 0, format: ChannelFormat::Stereo }], + markers: Vec::new(), + groups: Vec::new(), + auto_names: std::collections::BTreeMap::new(), + edit: EditState::default(), + next_id: 1, + comments: String::new(), + midi_originals: std::collections::BTreeMap::new(), + pool: SourcePool::default(), + folder: None, + }; + s.edit.pre_roll = sample_rate.samples(2.0); + s.edit.post_roll = sample_rate.samples(2.0); + s + } + + /// Allocate a fresh id. + pub fn alloc(&mut self) -> u64 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } + + // ---- lookups ------------------------------------------------------------------------- + + pub fn track(&self, id: TrackId) -> Option<&Track> { + self.tracks.iter().find(|t| t.id == id) + } + pub fn track_mut(&mut self, id: TrackId) -> Option<&mut Track> { + self.tracks.iter_mut().find(|t| t.id == id) + } + pub fn track_index(&self, id: TrackId) -> Option { + self.tracks.iter().position(|t| t.id == id) + } + pub fn track_by_name(&self, name: &str) -> Option<&Track> { + self.tracks.iter().find(|t| t.name == name).or_else(|| self.tracks.iter().find(|t| t.name.eq_ignore_ascii_case(name))) + } + pub fn master(&self) -> Option<&Track> { + self.tracks.iter().find(|t| t.kind == TrackKind::Master) + } + pub fn source(&self, id: SourceId) -> Option<&Source> { + self.sources.iter().find(|s| s.id == id) + } + pub fn video(&self, id: SourceId) -> Option<&VideoSource> { + self.videos.iter().find(|v| v.id == id) + } + pub fn bus(&self, id: BusId) -> Option<&Bus> { + self.busses.iter().find(|b| b.id == id) + } + pub fn bus_by_name(&self, name: &str) -> Option<&Bus> { + self.busses.iter().find(|b| b.name.eq_ignore_ascii_case(name)) + } + pub fn group(&self, id: GroupId) -> Option<&Group> { + self.groups.iter().find(|g| g.id == id) + } + pub fn marker(&self, id: MarkerId) -> Option<&MemoryLocation> { + self.markers.iter().find(|m| m.id == id) + } + + /// Find a clip anywhere (active playlists). + pub fn find_clip(&self, id: ClipId) -> Option<(TrackId, &Clip)> { + self.tracks.iter().find_map(|t| t.clips().iter().find(|c| c.id == id).map(|c| (t.id, c))) + } + + pub fn find_clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> { + self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id))) + } + + /// Clips on every playlist, alternates included. + pub fn all_clips(&self) -> impl Iterator { + self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips)) + } + + pub fn clips_using(&self, src: SourceId) -> impl Iterator { + self.all_clips().filter(move |c| c.source() == Some(src)) + } + + /// End of the last clip on any track (session length). + pub fn content_end(&self) -> Samples { + self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0) + } + + /// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position). + pub fn feet_frames_start(&self) -> i64 { + self.edit.value(FEET_FRAMES_START, 0.0) as i64 + } + + /// Format a position against this session's timecode and feet+frames references. + pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String { + crate::soundcraft_time::format_position( + at, + fmt, + self.sample_rate, + &self.tempo, + self.frame_rate, + self.timecode_start, + self.feet_frames_start(), + ) + } + + /// Parse a position typed in `fmt` against this session's references. + pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result { + crate::soundcraft_time::parse_position( + text, + fmt, + self.sample_rate, + &self.tempo, + self.frame_rate, + self.timecode_start, + self.feet_frames_start(), + ) + } + + /// Whether the Edit window shows a track: unhidden, with every parent folder open. + pub fn track_shown(&self, track: &Track) -> bool { + if track.hidden { + return false; + } + let mut folder = track.folder; + for _ in 0..64 { + let Some(parent) = folder.and_then(|id| self.track(id)) else { return true }; + if !parent.folder_open { + return false; + } + folder = parent.folder; + } + false + } + + /// Collects unique visible active-playlist clip boundaries and memory-location boundaries. + fn clip_marker_grid_points(&self, keep: impl Fn(Samples) -> bool, excluded: &[ClipId], include_zero: bool) -> Vec { + let mut points = Vec::new(); + let excluded: std::collections::HashSet = excluded.iter().copied().collect(); + if include_zero && keep(0) { + points.push(0); + } + for clip in + self.tracks.iter().filter(|track| self.track_shown(track)).flat_map(|track| track.clips()).filter(|clip| !excluded.contains(&clip.id)) + { + points.extend([clip.start, clip.end()].into_iter().filter(|point| keep(*point))); + } + for marker in &self.markers { + if keep(marker.start) { + points.push(marker.start); + } + if marker.end != marker.start && keep(marker.end) { + points.push(marker.end); + } + } + points.sort_unstable(); + points.dedup(); + points + } + + /// Sample-domain Timecode reference; Feet+Frames applies its origin in frames separately. + fn grid_origin(&self) -> Samples { + match self.edit.grid.scale() { + GridScale::Timecode => self.timecode_start, + _ => 0, + } + } + + /// Grid lines for the current Grid value, including clip and marker boundaries. + pub fn grid_lines(&self, start: Samples, end: Samples, max: usize) -> Vec { + if !self.edit.grid_clips_markers { + if let GridValue::FeetFrames(value) = self.edit.grid { + return value.lines(start, end, self.sample_rate, self.frame_rate, self.feet_frames_start(), max); + } + let origin = self.grid_origin(); + return self + .edit + .grid + .lines(start.saturating_add(origin), end.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate, max) + .into_iter() + .map(|point| point.saturating_sub(origin)) + .collect(); + } + let points = self.clip_marker_grid_points(|point| point >= start && point < end, &[], true); + if points.len() <= max { + return points; + } + match max { + 0 => Vec::new(), + 1 => points.first().copied().into_iter().collect(), + _ => { + let span = i128::from(end).saturating_sub(i128::from(start)).max(1); + let mut bucket = None; + points + .into_iter() + .filter(|point| { + let next = i128::from(*point).saturating_sub(i128::from(start)).saturating_mul(max as i128) / span; + if bucket == Some(next) { + false + } else { + bucket = Some(next); + true + } + }) + .collect() + } + } + } + + /// The current Grid line at or before `position`. + pub fn grid_line_at_or_before(&self, position: Samples) -> Samples { + self.grid_line_at_or_before_excluding(position, &[]) + } + + /// Find the current Grid line at or before `position` without boundaries of `excluded` clips. + pub fn grid_line_at_or_before_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples { + if !self.edit.grid_clips_markers { + if let GridValue::FeetFrames(value) = self.edit.grid { + return value.line_at_or_before(position, self.sample_rate, self.frame_rate, self.feet_frames_start()); + } + let origin = self.grid_origin(); + return self + .edit + .grid + .line_at_or_before(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate) + .saturating_sub(origin); + } + self.clip_marker_grid_points(|_| true, excluded, true).into_iter().take_while(|point| *point <= position).last().unwrap_or(position) + } + + /// Snap to the nearest line in the current Grid. + pub fn snap_to_grid(&self, position: Samples) -> Samples { + self.snap_to_grid_excluding(position, &[]) + } + + /// Snap to the nearest current Grid line without using boundaries of `excluded` clips. + pub fn snap_to_grid_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples { + if !self.edit.grid_clips_markers { + if let GridValue::FeetFrames(value) = self.edit.grid { + return value.snap(position, self.sample_rate, self.frame_rate, self.feet_frames_start()); + } + let origin = self.grid_origin(); + return self.edit.grid.snap(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate).saturating_sub(origin); + } + let mut points = self.clip_marker_grid_points(|_| true, excluded, false); + if !points.is_empty() { + points.push(0); + } + points.into_iter().min_by_key(|point| i128::from(*point).saturating_sub(i128::from(position)).abs()).unwrap_or(position) + } + + pub fn next_track_color(&self) -> [u8; 3] { + let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count(); + TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128]) + } + + /// A unique track name: `base`, or `base 1`, `base 2` … like the incumbent ("Audio 1"). + pub fn unique_track_name(&self, base: &str) -> String { + let taken = |n: &str| self.tracks.iter().any(|t| t.name == n); + if !base.is_empty() && !taken(base) && !matches!(base, "Audio" | "Aux" | "MIDI" | "Inst" | "VCA" | "Folder" | "Master") { + return base.to_string(); + } + let prefix = format!("{base} "); + let used: std::collections::HashSet = self + .tracks + .iter() + .filter_map(|t| t.name.strip_prefix(&prefix)) + .filter_map(|n| n.parse().ok().filter(|i: &u64| i.to_string() == n)) + .collect(); + let i = (1..).find(|i| !used.contains(i)).unwrap_or(1); + format!("{base} {i}") + } + + /// Add a track with defaults; returns its id. + pub fn add_track(&mut self, kind: TrackKind, format: ChannelFormat, name: Option<&str>) -> TrackId { + let id = TrackId(self.alloc()); + let name = self.unique_track_name(name.unwrap_or(kind.default_name())); + let color = if kind == TrackKind::Master { [140, 140, 140] } else { self.next_track_color() }; + let mut t = Track::new(id, name, kind, format, color); + if kind == TrackKind::Master { + t.mixer.output = crate::Route::Main; + t.mixer.input = crate::Route::Main; + } + // Master faders sit at the bottom like the incumbent; others go before any master. + let pos = if kind == TrackKind::Master { + self.tracks.len() + } else { + self.tracks.iter().position(|t| t.kind == TrackKind::Master).unwrap_or(self.tracks.len()) + }; + self.tracks.insert(pos, t); + id + } + + pub fn add_bus(&mut self, name: &str, format: ChannelFormat) -> BusId { + if let Some(b) = self.bus_by_name(name) { + return b.id; + } + let id = BusId(self.alloc()); + self.busses.push(Bus { id, name: name.to_string(), format }); + id + } + + /// Add a memory location; returns its id. + pub fn add_marker(&mut self, name: &str, kind: MarkerKind, start: Samples, end: Samples) -> MarkerId { + let id = MarkerId(self.alloc()); + let number = self.markers.iter().map(|m| m.number).max().unwrap_or(0).saturating_add(1); + self.markers.push(MemoryLocation { + id, + number, + name: name.to_string(), + kind, + start, + end: end.max(start), + comments: String::new(), + color: None, + ruler: 1, + track: None, + }); + self.markers.sort_by_key(|m| m.start); + id + } + + pub fn new_clip_id(&mut self) -> ClipId { + ClipId(self.alloc()) + } + + /// Drop the Auto Rename schemes that no clip on any playlist uses. + pub fn prune_auto_names(&mut self) { + let used: std::collections::BTreeSet = self.all_clips().filter_map(|c| c.auto_name).collect(); + self.auto_names.retain(|k, _| used.contains(k)); + } + + // ---- persistence --------------------------------------------------------------------- + + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| SessionError::Format(e.to_string())) + } + + /// Parse a session; decoded audio must be attached separately (`pool`). + /// + /// The text is parsed straight into the document, never into a `serde_json::Value` tree + /// first (which takes many times the file's size). Only the version is read before that. + /// A field of the document given twice in one object is refused (`duplicate field`), as no + /// writer repeats one. Keys of maps (plugin params, edit values, Auto Rename schemes, MIDI + /// originals) and unknown keys keep the last value given, as before. + pub fn from_json(text: &str) -> Result { + /// The version alone: every other field is skipped, not kept. + #[derive(serde::Deserialize)] + struct Head { + format_version: Option, + } + // `Head` would also take a JSON array as its fields in order: only an object has a version. + let object = text.trim_start_matches([' ', '\t', '\n', '\r']).starts_with('{'); + let version = match object.then(|| serde_json::from_str::(text)) { + Some(Ok(head)) => head.format_version, + // A version given twice is that error, not a missing version. + Some(Err(e)) if e.to_string().starts_with("duplicate field") => return Err(SessionError::Format(e.to_string())), + // Valid JSON of another shape (not an object, or a version that is not a number) + // has no version; anything else is not JSON. + _ => { + serde_json::from_str::(text).map_err(|e| SessionError::Format(e.to_string()))?; + None + } + }; + let ver = version.and_then(|v| v.as_u64()).ok_or_else(|| SessionError::Format("missing format_version".into()))?; + if ver > u64::from(Self::FORMAT_VERSION) { + return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX))); + } + let mut s: Session = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?; + s.sanitize(); + s.validate()?; + Ok(s) + } + + /// Repair invariants after loading untrusted data. + pub fn sanitize(&mut self) { + let max_id = self + .tracks + .iter() + .map(|t| t.id.0) + .chain(self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0)))) + .chain(self.sources.iter().map(|s| s.id.0)) + .chain(self.auto_names.keys().copied()) + .chain(self.videos.iter().map(|v| v.id.0)) + .chain(self.busses.iter().map(|b| b.id.0)) + .chain(self.markers.iter().map(|m| m.id.0)) + .chain(self.groups.iter().map(|g| g.id.0)) + .max() + .unwrap_or(0); + self.next_id = self.next_id.max(max_id.saturating_add(1)); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + truncate_chars(&mut self.comments, MAX_COMMENT_CHARS); + for t in &mut self.tracks { + t.mixer.sanitize(); + truncate_chars(&mut t.name, MAX_NAME_CHARS); + truncate_chars(&mut t.comments, MAX_COMMENT_CHARS); + if t.playlists.is_empty() { + t.playlists.push(crate::Playlist::new(t.name.clone())); + } + if t.active_playlist >= t.playlists.len() { + t.active_playlist = 0; + } + for p in &mut t.playlists { + truncate_chars(&mut p.name, MAX_NAME_CHARS); + p.clips.retain(|c| c.length > 0); + p.clips.iter_mut().for_each(Clip::sanitize); + p.sort(); + } + for l in &mut t.automation { + l.points.retain(|p| p.value.is_finite()); + let (lo, hi, _) = l.param.range(); + l.points.iter_mut().for_each(|p| p.value = p.value.clamp(lo, hi)); + l.points.iter_mut().for_each(|p| bound(&mut p.at)); + l.points.sort_by_key(|p| p.at); + } + } + for m in &mut self.markers { + bound(&mut m.start); + bound(&mut m.end); + truncate_chars(&mut m.name, MAX_NAME_CHARS); + truncate_chars(&mut m.comments, MAX_COMMENT_CHARS); + } + for (at, key) in &mut self.key_signatures { + bound(at); + truncate_chars(key, MAX_NAME_CHARS); + } + self.midi_originals.values_mut().for_each(bound_sequence); + let e = &mut self.edit; + for at in [ + &mut e.selection.start, + &mut e.selection.end, + &mut e.timeline_selection.start, + &mut e.timeline_selection.end, + &mut e.playhead, + &mut e.pre_roll, + &mut e.post_roll, + &mut e.zoom.scroll, + &mut self.timecode_start, + ] { + bound(at); + } + for v in &mut self.videos { + if !v.frame_rate.is_finite() || v.frame_rate < 0.0 { + v.frame_rate = 0.0; + } + if !v.duration.is_finite() || v.duration < 0.0 { + v.duration = 0.0; + } + truncate_chars(&mut v.name, MAX_NAME_CHARS); + } + self.busses.iter_mut().for_each(|b| truncate_chars(&mut b.name, MAX_NAME_CHARS)); + self.outputs.iter_mut().for_each(|o| truncate_chars(&mut o.name, MAX_NAME_CHARS)); + self.groups.iter_mut().for_each(|g| truncate_chars(&mut g.name, MAX_NAME_CHARS)); + self.sources.iter_mut().for_each(|s| truncate_chars(&mut s.name, MAX_NAME_CHARS)); + // Drop performances whose clip no longer exists anywhere (any playlist). + let clip_ids: std::collections::BTreeSet = + self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect(); + self.midi_originals.retain(|id, _| clip_ids.contains(id)); + self.prune_auto_names(); + self.auto_names.values_mut().for_each(crate::AutoName::sanitize); + if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 { + self.edit.zoom.samples_per_px = 1024.0; + } + if SampleRate::new(self.sample_rate.hz()).is_err() { + self.sample_rate = SampleRate::default(); + } + self.tempo.sanitize(); + } + + /// Reject a document whose ids are ambiguous or exhausted, run after [`Session::sanitize`] on + /// untrusted data. Duplicate ids in a namespace make commands, selections and source + /// associations act on an arbitrary one of the objects; a maximal `next_id` -- or a maximal + /// existing id, which `sanitize` folds into `next_id` -- makes every later allocation return + /// `u64::MAX` and repeat itself. + fn validate(&self) -> Result<(), SessionError> { + if self.next_id == u64::MAX { + return Err(SessionError::Invalid("the session id space is exhausted".into())); + } + distinct("track", self.tracks.iter().map(|t| t.id.0))?; + distinct("clip", self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))))?; + distinct("source", self.sources.iter().map(|s| s.id.0))?; + distinct("video", self.videos.iter().map(|v| v.id.0))?; + distinct("bus", self.busses.iter().map(|b| b.id.0))?; + distinct("marker", self.markers.iter().map(|m| m.id.0))?; + distinct("group", self.groups.iter().map(|g| g.id.0))?; + Ok(()) + } +} + +/// The most characters kept of a name read from a file: longer ones can't be shown, and drawing +/// a name of a million characters overflows the renderer's buffers. +pub const MAX_NAME_CHARS: usize = 256; +/// The most characters kept of a comment read from a file. +pub const MAX_COMMENT_CHARS: usize = 4096; + +/// Cut `s` to at most `max` characters. +pub fn truncate_chars(s: &mut String, max: usize) { + if let Some((end, _)) = s.char_indices().nth(max) { + s.truncate(end); + } +} + +/// Every id in `ids` distinct, or an error naming the namespace and the repeated id. +fn distinct(kind: &str, ids: impl Iterator) -> Result<(), SessionError> { + let mut seen = std::collections::HashSet::new(); + for id in ids { + if !seen.insert(id) { + return Err(SessionError::Invalid(format!("duplicate {kind} id {id}"))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_tracks_names_and_order() { + let mut s = Session::default(); + let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None); + let a = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let b = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let v = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal")); + assert_eq!(s.track(a).map(|t| t.name.as_str()), Some("Audio 1")); + assert_eq!(s.track(b).map(|t| t.name.as_str()), Some("Audio 2")); + assert_eq!(s.track(v).map(|t| t.name.as_str()), Some("Vocal")); + assert_eq!(s.tracks.last().map(|t| t.id), Some(m)); + let v2 = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal")); + assert_eq!(s.track(v2).map(|t| t.name.as_str()), Some("Vocal 1")); + } + + #[test] + fn json_round_trip() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + let cid = s.new_clip_id(); + if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) { + p.clips.push(Clip::audio(cid, "c", SourceId(99), 0, 10, 100)); + } + s.add_marker("Verse", MarkerKind::Marker, 48_000, 48_000); + let text = s.to_json().unwrap(); + let back = Session::from_json(&text).unwrap(); + assert_eq!(back, s); + } + + /// Clips/Markers mode snaps and draws at clip and memory-location boundaries. + #[test] + fn clips_markers_grid_uses_visible_clip_edges_and_memory_locations() { + let mut s = Session::default(); + let track = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 2_000, crate::soundcraft_midi::Sequence::default()); + let clip_id = clip.id; + s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip); + let hidden = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let hidden_clip = Clip::midi(s.new_clip_id(), "Hidden", 3_200, 100, crate::soundcraft_midi::Sequence::default()); + if let Some(track) = s.track_mut(hidden) { + track.hidden = true; + track.playlist_mut().unwrap().clips.push(hidden_clip); + } + s.add_marker("Cue", MarkerKind::Marker, 2_500, 2_500); + s.add_marker("Selection", MarkerKind::Selection, 4_000, 4_500); + s.edit.grid_clips_markers = true; + + assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 2_500, 3_000, 4_000, 4_500]); + assert_eq!(s.grid_line_at_or_before(2_800), 2_500); + assert_eq!(s.snap_to_grid(2_800), 3_000); + assert_eq!(s.snap_to_grid(3_700), 4_000); + assert_eq!(s.grid_lines(2_400, 4_100, 2), [2_500, 4_000]); + assert_eq!(s.snap_to_grid(3_200), 3_000); + assert_eq!(s.snap_to_grid_excluding(2_800, &[clip_id]), 2_500); + + let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None); + s.track_mut(track).unwrap().folder = Some(folder); + s.track_mut(folder).unwrap().folder_open = false; + assert_eq!(s.grid_lines(0, 5_000, 20), [0, 2_500, 4_000, 4_500]); + } + + /// Open hidden folders keep their visible descendants eligible for lines and snapping. + #[test] + fn clips_markers_grid_keeps_visible_children_of_hidden_open_folders() { + let mut s = Session::default(); + let outer = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None); + let inner = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None); + let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let clip = Clip::midi(s.new_clip_id(), "Visible", 1_000, 2_000, crate::soundcraft_midi::Sequence::default()); + s.track_mut(inner).unwrap().folder = Some(outer); + s.track_mut(inner).unwrap().hidden = true; + s.track_mut(child).unwrap().folder = Some(inner); + s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip); + s.edit.grid_clips_markers = true; + + assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 3_000]); + assert_eq!(s.snap_to_grid(2_800), 3_000); + assert_eq!(s.grid_line_at_or_before(2_800), 1_000); + + s.track_mut(child).unwrap().hidden = true; + assert_eq!(s.grid_lines(0, 5_000, 20), [0]); + assert_eq!(s.snap_to_grid(2_800), 2_800); + s.track_mut(child).unwrap().hidden = false; + s.track_mut(inner).unwrap().folder_open = false; + assert_eq!(s.grid_lines(0, 5_000, 20), [0]); + s.track_mut(inner).unwrap().folder_open = true; + s.track_mut(outer).unwrap().folder_open = false; + assert_eq!(s.grid_lines(0, 5_000, 20), [0]); + } + + /// With no real Clips/Markers boundaries, snapping leaves the position alone while drawing + /// retains the session-start line. + #[test] + fn empty_clips_markers_grid_does_not_snap_everything_to_zero() { + let mut s = Session::default(); + s.edit.grid_clips_markers = true; + assert_eq!(s.grid_lines(0, 1_000, 10), [0]); + assert_eq!(s.snap_to_grid(678), 678); + } + + /// Frame-based grids share the same references as their displayed counters. + #[test] + fn frame_grids_apply_session_counter_origins() { + let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps30, timecode_start: 1_600, ..Session::default() }; + s.edit.grid = GridValue::Timecode(crate::soundcraft_time::TimecodeValue::Second); + assert_eq!(s.grid_line_at_or_before(47_000), 46_400); + assert_eq!(s.snap_to_grid(46_400), 46_400); + + s.edit.grid = GridValue::FeetFrames(crate::soundcraft_time::FeetFramesValue::Foot); + s.edit.values.insert(FEET_FRAMES_START.into(), 1.0); + assert_eq!(s.grid_line_at_or_before(25_000), 24_000); + assert_eq!(s.snap_to_grid(24_000), 24_000); + } + + /// Every Feet+Frames increment shares the parser's single rounding at fractional frame rates. + #[test] + fn feet_frames_grid_matches_parsed_positions_with_signed_frame_origins() { + use crate::soundcraft_time::FeetFramesValue; + for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: rate, ..Session::default() }; + for origin in [-17, -1, 1, 17] { + s.edit.values.insert(FEET_FRAMES_START.into(), origin as f64); + for value in FeetFramesValue::ALL { + s.edit.grid = GridValue::FeetFrames(value); + for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] { + let at = s.parse_position(&format!("{feet}+00"), TimeFormat::FeetFrames).unwrap(); + assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?} {value:?} origin {origin}, foot {feet}"); + assert_eq!(s.snap_to_grid(at), at); + assert_eq!(s.grid_lines(at - 1, at + 2, 4), [at]); + } + } + } + } + let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps29_97, ..Session::default() }; + s.edit.values.insert(FEET_FRAMES_START.into(), 1.0); + s.edit.grid = GridValue::FeetFrames(FeetFramesValue::Foot); + assert_eq!(s.snap_to_grid(49_649), 49_650); + assert_eq!(s.format_position(49_650, TimeFormat::FeetFrames), "2+00"); + } + + /// Drop-frame minute boundaries keep their counter alignment with signed session origins. + #[test] + fn drop_frame_grids_apply_session_origins_at_minute_boundaries() { + for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1_800), (FrameRate::Fps59_94Drop, 3_600)] { + for origin_frame in [-1, 1] { + let sr = SampleRate::HZ_48000; + let origin = rate.samples_at_frame(origin_frame, sr); + let mut s = Session { sample_rate: sr, frame_rate: rate, timecode_start: origin, ..Session::default() }; + s.edit.grid = GridValue::Timecode(crate::soundcraft_time::TimecodeValue::Second); + for boundary in [-minute_frame, minute_frame] { + let at = rate.samples_at_frame(boundary, sr) - origin; + assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?}, origin {origin_frame}"); + assert_eq!(s.snap_to_grid(at + 1), at, "{rate:?}, origin {origin_frame}"); + assert_eq!(s.grid_lines(at - 1, at + 2, 10), [at]); + } + } + } + } + + #[test] + fn session_with_legacy_fit_height_loads() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + if let Some(track) = s.track_mut(t) { + track.height = crate::TrackHeight::Large; + } + let text = s.to_json().unwrap().replace("\"height\": \"Large\"", "\"height\": \"Fit\""); + assert!(text.contains("\"height\": \"Fit\"")); + let back = Session::from_json(&text).unwrap(); + assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium)); + } + + /// Session JSON omits live flags and ignores their values in legacy documents. + #[test] + fn live_input_states_are_never_saved_or_restored() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let track = s.track_mut(t).unwrap(); + track.mixer.mute = true; + track.mixer.record_arm = true; + track.mixer.input_monitor = true; + + let mut value = serde_json::to_value(&s).unwrap(); + let mixer = value["tracks"][0]["mixer"].as_object_mut().unwrap(); + assert!(!mixer.contains_key("record_arm")); + assert!(!mixer.contains_key("input_monitor")); + + // Files written by older versions may still contain these keys; ignore them on input. + mixer.insert("record_arm".into(), serde_json::Value::Bool(true)); + mixer.insert("input_monitor".into(), serde_json::Value::Bool(true)); + let loaded = Session::from_json(&value.to_string()).unwrap(); + let track = loaded.track(t).unwrap(); + assert!(track.mixer.mute, "persistent mixer state still loads"); + assert!(!track.mixer.record_arm); + assert!(!track.mixer.input_monitor); + } + + #[test] + fn video_clip_round_trips_and_old_sessions_load() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Video, ChannelFormat::Mono, None); + let vid = SourceId(s.alloc()); + s.videos.push(VideoSource { + id: vid, + name: "cut.mov".into(), + path: "/films/cut.mov".into(), + width: 1920, + height: 1080, + frame_rate: 23.976, + duration: 12.5, + codec: "prores".into(), + offline: None, + }); + let cid = s.new_clip_id(); + if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) { + p.clips.push(Clip::video(cid, "cut", vid, 480, 0, 600_000)); + } + let back = Session::from_json(&s.to_json().unwrap()).unwrap(); + assert_eq!(back, s); + let c = &back.track(t).unwrap().clips()[0]; + assert_eq!((c.video_source(), c.source(), c.source_offset(), c.is_audio()), (Some(vid), None, 480, false)); + assert_eq!(back.track(t).unwrap().kind.label(), "Video Track"); + assert!(back.video(vid).is_some()); + // A session written before video support has no `videos` key. + let mut v = serde_json::to_value(Session::default()).unwrap(); + v.as_object_mut().unwrap().remove("videos"); + assert!(Session::from_json(&v.to_string()).unwrap().videos.is_empty()); + // Trimming the start keeps the picture anchored. + let mut c = c.clone(); + c.trim_start_to(100); + assert_eq!(c.source_offset(), 580); + } + + #[test] + fn unique_track_names_take_the_first_free_number() { + let mut s = Session::default(); + for name in ["Piano", "Piano 1", "Piano 3", "Piano 02", "Pianos 2"] { + s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(name)); + } + assert_eq!(s.unique_track_name("Piano"), "Piano 2"); + assert_eq!(s.unique_track_name("Organ"), "Organ"); + assert_eq!(s.unique_track_name("Audio"), "Audio 1"); + } + + #[test] + fn names_from_a_file_are_cut_to_a_length_that_can_be_drawn() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(tr) = s.track_mut(t) { + tr.name = "é".repeat(1_000); + } + s.comments = "c".repeat(10_000); + let back = Session::from_json(&s.to_json().unwrap()).unwrap(); + assert_eq!(back.track(t).map(|tr| tr.name.chars().count()), Some(MAX_NAME_CHARS)); + assert_eq!(back.comments.len(), MAX_COMMENT_CHARS); + } + + #[test] + fn a_bad_sample_rate_or_tempo_is_repaired_on_load() { + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["sample_rate"] = serde_json::json!(0); + v["tempo"]["tempos"] = serde_json::json!([{"tick": 0, "bpm": 4e9}]); + let s = Session::from_json(&v.to_string()).unwrap(); + assert_eq!((s.sample_rate, s.tempo), (SampleRate::HZ_48000, TempoMap::default())); + } + + #[test] + fn a_meter_change_far_beyond_the_position_range_is_dropped_on_load() { + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["tempo"]["meters"] = serde_json::json!([ + {"tick": 0, "numerator": 4, "denominator": 4}, + {"tick": i64::MAX, "numerator": 3, "denominator": 4} + ]); + let s = Session::from_json(&v.to_string()).unwrap(); + assert_eq!(s.parse_position("2|1|000", TimeFormat::BarsBeats), Ok(96_000)); + } + + #[test] + fn rejects_garbage_and_future_versions() { + assert!(Session::from_json("not json").is_err()); + assert!(Session::from_json("{}").is_err()); + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["format_version"] = serde_json::json!(999); + assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999)))); + } + + #[test] + fn the_version_is_checked_as_before() { + // #892: the version is read without building the whole document as a tree; the errors + // stay what they were. + let format = |t: &str| match Session::from_json(t) { + Err(SessionError::Format(m)) => m, + other => panic!("{t}: {other:?}"), + }; + for t in [ + "{}", + "[]", + "[5]", + " \n[1, {}]", + "\"x\"", + "5", + "null", + r#"{"format_version":"1"}"#, + r#"{"format_version":-1}"#, + r#"{"format_version":1.5}"#, + r#"{"format_version":null}"#, + r#"{"format_version":[0,0,0]}"#, + r#"{"name":"x","other":{"format_version":1}}"#, + ] { + assert_eq!(format(t), "missing format_version", "{t}"); + } + for t in ["not json", r#"{"format_version":1"#, r#"{"format_version":1} x"#, r#"{"format_version":"1", ]"#] { + let want = serde_json::from_str::(t).unwrap_err().to_string(); + assert_eq!(format(t), want, "{t}"); + } + let mut v = serde_json::to_value(Session::default()).unwrap(); + for (ver, want) in [(serde_json::json!(2), 2), (serde_json::json!(1u64 << 40), u32::MAX)] { + v["format_version"] = ver; + assert_eq!(Session::from_json(&v.to_string()), Err(SessionError::Version(want))); + } + // A field given twice is refused by name, the version too. + let twice = |key: &str| format(&format!(r#"{{"format_version":1,"{key}":"a","{key}":"b"}}"#)); + assert!(twice("name").starts_with("duplicate field `name` at line 1 column "), "{}", twice("name")); + assert!(twice("format_version").starts_with("duplicate field `format_version` at line 1 column "), "{}", twice("format_version")); + // A newer version is refused even when the rest would not parse. + assert_eq!(Session::from_json(r#"{"format_version":7,"tracks":5}"#), Err(SessionError::Version(7))); + // An older or the current version loads as it did. + for ver in [0, 1] { + v["format_version"] = serde_json::json!(ver); + assert_eq!(Session::from_json(&v.to_string()).map(|s| s.format_version), Ok(ver)); + } + } + + #[test] + fn duplicate_ids_are_rejected_on_load() { + let mut s = Session::default(); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut v = serde_json::to_value(&s).unwrap(); + v["tracks"][1]["id"] = v["tracks"][0]["id"].clone(); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("duplicate track id")), "{err}"); + } + + #[test] + fn an_exhausted_id_space_is_rejected_on_load() { + let mut s = Session::default(); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut v = serde_json::to_value(&s).unwrap(); + v["next_id"] = serde_json::json!(u64::MAX); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}"); + // A maximal existing id is exhausted too, however `next_id` was written: every later + // allocation would return `u64::MAX` again. + let mut v = serde_json::to_value(&s).unwrap(); + v["tracks"][0]["id"] = serde_json::json!(u64::MAX); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}"); + } + + #[test] + fn sanitize_repairs() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(tr) = s.track_mut(t) { + tr.active_playlist = 7; + tr.mixer.volume_db = f32::NAN; + } + s.next_id = 0; + s.sanitize(); + let tr = s.track(t).unwrap(); + assert_eq!(tr.active_playlist, 0); + assert_eq!(tr.mixer.volume_db, 0.0); + assert!(s.next_id > t.0); + } + + #[test] + fn loaded_automation_is_clamped_to_its_range() { + // #884: a session file can carry points outside a lane's range; load clamps them. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tr = s.track_mut(t).unwrap(); + for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] { + tr.lane_mut(&p).points.push(crate::AutomationPoint { at: 0, value: 500.0 }); + } + let s = Session::from_json(&serde_json::to_string(&s).unwrap()).unwrap(); + let tr = s.track(t).unwrap(); + for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] { + assert_eq!(tr.lane(&p).unwrap().points[0].value, 12.0, "{p:?}"); + } + } + + #[test] + fn a_loaded_auto_name_scheme_leaves_room_for_its_number() { + let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() }; + scheme.sanitize(); + assert_eq!(scheme.name.chars().count() + scheme.suffix.chars().count(), crate::AutoName::MAX_TEXT_CHARS); + } +} diff --git a/crates/scraft-format/src/source.rs b/crates/scraft-format/src/source.rs new file mode 100644 index 000000000..7013d0031 --- /dev/null +++ b/crates/scraft-format/src/source.rs @@ -0,0 +1,204 @@ +//! Audio sources (files in the session's Audio Files folder) and their decoded data, and the +//! movies video clips reference. + +use crate::SourceId; +use craft_audio::{AudioBuffer, AudioInfo, CodecId, Container, peaks::Peaks}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// The kind of file a source came from, as session files store it in [`Source::format`]. The +/// variant names are part of the session format: craft-audio's `Container` and `CodecId` name +/// more formats under other names, so this stays here and maps from them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum FileFormat { + Wav, + Aiff, + Flac, + Mp3, + Ogg, + Aac, + Alac, + Caf, + /// Anything else that decodes: Matroska, WebM, raw AC-3. + Other, +} + +impl FileFormat { + /// The kind of the file `info` describes. + pub fn of(info: &AudioInfo) -> FileFormat { + match info.container { + Container::Wav => FileFormat::Wav, + Container::Aiff => FileFormat::Aiff, + Container::Flac => FileFormat::Flac, + Container::MpegAudio => FileFormat::Mp3, + Container::Ogg => FileFormat::Ogg, + Container::Caf => FileFormat::Caf, + Container::Adts => FileFormat::Aac, + // MP4 and QuickTime hold many codecs: the codec names the kind. + Container::Mp4 | Container::QuickTime => match info.codec { + CodecId::Alac => FileFormat::Alac, + CodecId::Aac | CodecId::HeAac => FileFormat::Aac, + _ => FileFormat::Other, + }, + _ => FileFormat::Other, + } + } +} + +/// Metadata for an audio file used by the session (serialised). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Source { + pub id: SourceId, + pub name: String, + /// Path relative to the session folder (or absolute for referenced media). + pub path: String, + pub channels: u16, + pub frames: u64, + pub sample_rate: u32, + pub format: FileFormat, + /// Original BWF time stamp (samples), if any. + #[serde(default)] + pub time_reference: u64, + /// True when the file has not been written to disk yet (recorded/rendered this session). + #[serde(default)] + pub unsaved: bool, +} + +/// A movie used by Video track clips (serialised). The pictures themselves are never stored in +/// the session: the UI opens the movie from `path` and decodes frames on demand. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct VideoSource { + pub id: SourceId, + pub name: String, + /// Absolute path of the movie (referenced, never copied into the session folder). + pub path: String, + #[serde(default)] + pub width: u32, + #[serde(default)] + pub height: u32, + /// Frames per second (0 = unknown). + #[serde(default)] + pub frame_rate: f64, + /// Picture length in seconds. + #[serde(default)] + pub duration: f64, + /// Codec id (`h264`, `prores`, `mjpeg`, `hevc`, …). + #[serde(default)] + pub codec: String, + /// Why the movie is not opened, when its stored path was refused on opening the session. + #[serde(skip)] + pub offline: Option, +} + +impl VideoSource { + /// Display aspect ratio (width / height), 16:9 when unknown. + pub fn aspect(&self) -> f64 { + if self.width == 0 || self.height == 0 { 16.0 / 9.0 } else { f64::from(self.width) / f64::from(self.height) } + } +} + +/// Decoded audio plus waveform overviews (not serialised). +#[derive(Debug)] +pub struct SourceAudio { + pub buffer: AudioBuffer, + pub peaks: Vec, +} + +impl SourceAudio { + pub fn new(buffer: AudioBuffer) -> Self { + let peaks = buffer.channels.iter().map(|c| Peaks::build(c)).collect(); + SourceAudio { buffer, peaks } + } + pub fn frames(&self) -> usize { + self.buffer.frames() + } + pub fn channel(&self, ch: usize) -> Option<&[f32]> { + self.buffer.channels.get(ch).map(Vec::as_slice) + } +} + +/// Shared decoded audio, keyed by source id. +#[derive(Debug, Clone, Default)] +pub struct SourcePool { + map: BTreeMap>, +} + +impl SourcePool { + pub fn insert(&mut self, id: SourceId, audio: Arc) { + self.map.insert(id, audio); + } + pub fn get(&self, id: SourceId) -> Option<&Arc> { + self.map.get(&id) + } + pub fn remove(&mut self, id: SourceId) -> Option> { + self.map.remove(&id) + } + pub fn contains(&self, id: SourceId) -> bool { + self.map.contains_key(&id) + } + pub fn len(&self) -> usize { + self.map.len() + } + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + pub fn ids(&self) -> impl Iterator + '_ { + self.map.keys().copied() + } +} + +impl PartialEq for SourcePool { + fn eq(&self, other: &Self) -> bool { + self.map.len() == other.map.len() && self.map.iter().zip(other.map.iter()).all(|((a, x), (b, y))| a == b && Arc::ptr_eq(x, y)) + } +} + +#[cfg(test)] +mod tests { + use super::FileFormat; + use craft_audio::{AudioBuffer, EncodeFormat, EncodeOptions, encode, probe}; + + /// Sessions written before the format code moved to craft-audio name a source's format with + /// these words; they must keep reading, and new sessions must keep writing them. + #[test] + fn the_format_names_in_session_files_do_not_change() { + use FileFormat::{Aac, Aiff, Alac, Caf, Flac, Mp3, Ogg, Other, Wav}; + let names = ["Wav", "Aiff", "Flac", "Mp3", "Ogg", "Aac", "Alac", "Caf", "Other"]; + for (format, name) in [Wav, Aiff, Flac, Mp3, Ogg, Aac, Alac, Caf, Other].into_iter().zip(names) { + assert_eq!(serde_json::to_string(&format).unwrap(), format!("\"{name}\"")); + assert_eq!(serde_json::from_str::(&format!("\"{name}\"")).unwrap(), format); + } + } + + /// An MP4 is AAC only when its audio is: Opus, AC-3 or PCM in MP4 is not. + #[test] + fn an_mp4_is_named_by_its_codec() { + let wav = encode(&AudioBuffer::new(48_000, 1, 480), &EncodeOptions::default()).unwrap(); + let mut info = serde_json::to_value(probe(&wav, None).unwrap()).unwrap(); + for (container, codec, format) in [ + ("Mp4", "Aac", FileFormat::Aac), + ("Mp4", "HeAac", FileFormat::Aac), + ("QuickTime", "Alac", FileFormat::Alac), + ("Mp4", "Opus", FileFormat::Other), + ("Mp4", "Ac3", FileFormat::Other), + ("QuickTime", "Pcm", FileFormat::Other), + ("Adts", "Aac", FileFormat::Aac), + ] { + info["container"] = container.into(); + info["codec"] = codec.into(); + let info: craft_audio::AudioInfo = serde_json::from_value(info.clone()).unwrap(); + assert_eq!(FileFormat::of(&info), format, "{codec} in {container}"); + } + } + + #[test] + fn a_probed_file_maps_to_its_format() { + let buf = AudioBuffer::new(48_000, 1, 480); + for (written, format) in + [(EncodeFormat::Wav, FileFormat::Wav), (EncodeFormat::Aiff, FileFormat::Aiff), (EncodeFormat::Flac, FileFormat::Flac)] + { + let info = probe(&encode(&buf, &EncodeOptions::new(written)).unwrap(), None).unwrap(); + assert_eq!(FileFormat::of(&info), format); + } + } +} diff --git a/crates/scraft-format/src/track.rs b/crates/scraft-format/src/track.rs new file mode 100644 index 000000000..d31bc5912 --- /dev/null +++ b/crates/scraft-format/src/track.rs @@ -0,0 +1,477 @@ +//! Tracks and playlists. + +use crate::soundcraft_time::{Range, Samples}; +use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId}; + +/// Track types (Track › New). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum TrackKind { + #[default] + Audio, + Aux, + Master, + Midi, + Instrument, + Vca, + Folder, + /// Picture: clips reference a movie ([`crate::VideoSource`]); no audio path. + Video, +} + +impl TrackKind { + pub const ALL: [TrackKind; 8] = [ + TrackKind::Audio, + TrackKind::Aux, + TrackKind::Master, + TrackKind::Midi, + TrackKind::Instrument, + TrackKind::Vca, + TrackKind::Folder, + TrackKind::Video, + ]; + pub fn label(self) -> &'static str { + match self { + TrackKind::Audio => "Audio Track", + TrackKind::Aux => "Aux Input", + TrackKind::Master => "Master Fader", + TrackKind::Midi => "MIDI Track", + TrackKind::Instrument => "Instrument Track", + TrackKind::Vca => "VCA Master", + TrackKind::Folder => "Routing Folder Track", + TrackKind::Video => "Video Track", + } + } + pub fn default_name(self) -> &'static str { + match self { + TrackKind::Audio => "Audio", + TrackKind::Aux => "Aux", + TrackKind::Master => "Master", + TrackKind::Midi => "MIDI", + TrackKind::Instrument => "Inst", + TrackKind::Vca => "VCA", + TrackKind::Folder => "Folder", + TrackKind::Video => "Video", + } + } + pub fn id(self) -> &'static str { + match self { + TrackKind::Audio => "audio", + TrackKind::Aux => "aux", + TrackKind::Master => "master", + TrackKind::Midi => "midi", + TrackKind::Instrument => "instrument", + TrackKind::Vca => "vca", + TrackKind::Folder => "folder", + TrackKind::Video => "video", + } + } + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + TrackKind::ALL.into_iter().find(|k| k.id() == s || k.label().eq_ignore_ascii_case(&s)) + } + /// Track kinds that hold clips on a timeline. + pub fn has_playlist(self) -> bool { + matches!(self, TrackKind::Audio | TrackKind::Midi | TrackKind::Instrument | TrackKind::Video) + } + pub fn is_midi(self) -> bool { + matches!(self, TrackKind::Midi | TrackKind::Instrument) + } + /// Kinds that pass audio through the mixer. + pub fn is_audio_path(self) -> bool { + matches!(self, TrackKind::Audio | TrackKind::Aux | TrackKind::Master | TrackKind::Instrument | TrackKind::Folder) + } +} + +/// Track heights (View › Track Height presets). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)] +pub enum TrackHeight { + /// Any height from Micro to Extreme, in points. + Custom(u16), + Micro, + Mini, + Small, + #[default] + Medium, + Large, + Jumbo, + Extreme, + /// Fit To Window: this many points. + Fit(u16), +} + +/// A saved track height: `"Micro"` … `"Extreme"` (or `{"Micro": null}` …), `{"Custom": points}`, +/// `{"Fit": points}`, or the bare `"Fit"` older sessions used. Read as it comes, never buffered (as an +/// untagged enum would), so anything else in a session file is refused at its first token. +impl<'de> serde::Deserialize<'de> for TrackHeight { + fn deserialize>(d: D) -> Result { + d.deserialize_any(HeightVisitor) + } +} + +const HEIGHT_VARIANTS: &[&str] = &["Custom", "Micro", "Mini", "Small", "Medium", "Large", "Jumbo", "Extreme", "Fit"]; + +struct HeightVisitor; + +impl HeightVisitor { + /// The height a variant without a size names. + fn preset(name: &str) -> Option { + Some(match name { + "Micro" => TrackHeight::Micro, + "Mini" => TrackHeight::Mini, + "Small" => TrackHeight::Small, + "Medium" => TrackHeight::Medium, + "Large" => TrackHeight::Large, + "Jumbo" => TrackHeight::Jumbo, + "Extreme" => TrackHeight::Extreme, + _ => return None, + }) + } +} + +impl<'de> serde::de::Visitor<'de> for HeightVisitor { + type Value = TrackHeight; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a track height") + } + + fn visit_str(self, v: &str) -> Result { + match v { + // Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height. + "Fit" => Ok(TrackHeight::Medium), + "Custom" => Err(E::invalid_type(serde::de::Unexpected::UnitVariant, &"a height in points")), + other => Self::preset(other).ok_or_else(|| E::unknown_variant(other, HEIGHT_VARIANTS)), + } + } + + fn visit_map>(self, mut map: A) -> Result { + use serde::de::Error as _; + let Some(key) = map.next_key::()? else { return Err(A::Error::invalid_length(0, &"one variant")) }; + let h = match key.as_str() { + "Custom" => TrackHeight::Custom(map.next_value()?), + // `{"Fit": null}` is the old size-less Fit too, as above. + "Fit" => map.next_value::>()?.map_or(TrackHeight::Medium, TrackHeight::Fit), + other => { + let h = Self::preset(other).ok_or_else(|| A::Error::unknown_variant(other, HEIGHT_VARIANTS))?; + map.next_value::<()>()?; + h + } + }; + if map.next_key::()?.is_some() { + return Err(A::Error::invalid_length(2, &"one variant")); + } + Ok(h) + } +} + +impl TrackHeight { + pub const ALL: [TrackHeight; 7] = [ + TrackHeight::Micro, + TrackHeight::Mini, + TrackHeight::Small, + TrackHeight::Medium, + TrackHeight::Large, + TrackHeight::Jumbo, + TrackHeight::Extreme, + ]; + pub fn points(self) -> f32 { + match self { + TrackHeight::Custom(points) => f32::from(points).clamp(TrackHeight::Micro.points(), TrackHeight::Extreme.points()), + TrackHeight::Micro => 16.0, + TrackHeight::Mini => 24.0, + TrackHeight::Small => 46.0, + TrackHeight::Medium => 88.0, + TrackHeight::Large => 132.0, + TrackHeight::Jumbo => 220.0, + TrackHeight::Extreme => 440.0, + TrackHeight::Fit(points) => f32::from(points.max(16)), + } + } + pub fn label(self) -> &'static str { + match self { + TrackHeight::Custom(_) => "custom", + TrackHeight::Micro => "micro", + TrackHeight::Mini => "mini", + TrackHeight::Small => "small", + TrackHeight::Medium => "medium", + TrackHeight::Large => "large", + TrackHeight::Jumbo => "jumbo", + TrackHeight::Extreme => "extreme", + TrackHeight::Fit(_) => "fit to window", + } + } + pub fn from_id(s: &str) -> Option { + TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s)) + } + /// The preset this many points tall, else a custom height clamped to Micro..Extreme. + pub fn from_points(points: f32) -> TrackHeight { + let p = points.max(TrackHeight::Micro.points()).min(TrackHeight::Extreme.points()).round() as u16; + TrackHeight::ALL.into_iter().find(|h| h.points() == f32::from(p)).unwrap_or(TrackHeight::Custom(p)) + } + /// The next preset taller (or shorter) than this height; Extreme (or Micro) stays. + pub fn step(self, taller: bool) -> TrackHeight { + let p = self.points(); + let next = + if taller { TrackHeight::ALL.into_iter().find(|h| h.points() > p) } else { TrackHeight::ALL.into_iter().rev().find(|h| h.points() < p) }; + next.unwrap_or(self) + } +} + +/// The default track colour palette (original, chosen for SoundCraft). +pub const TRACK_COLORS: [[u8; 3]; 16] = [ + [86, 132, 214], + [94, 170, 214], + [80, 190, 168], + [104, 186, 98], + [168, 196, 76], + [222, 196, 72], + [230, 152, 62], + [222, 104, 72], + [214, 82, 110], + [196, 86, 168], + [150, 98, 206], + [110, 104, 214], + [130, 150, 170], + [178, 140, 110], + [120, 170, 140], + [190, 120, 150], +]; + +/// A playlist: an alternative take of a track's clips. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub struct Playlist { + pub name: String, + /// Clips sorted by start; non-overlapping except at crossfades. + pub clips: Vec, +} + +impl Playlist { + pub fn new(name: impl Into) -> Self { + Playlist { name: name.into(), clips: Vec::new() } + } + pub fn sort(&mut self) { + self.clips.sort_by_key(|c| (c.start, c.id.0)); + } + pub fn clip(&self, id: ClipId) -> Option<&Clip> { + self.clips.iter().find(|c| c.id == id) + } + pub fn clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> { + self.clips.iter_mut().find(|c| c.id == id) + } + pub fn clips_in(&self, r: Range) -> impl Iterator { + self.clips.iter().filter(move |c| c.range().overlaps(&r)) + } + pub fn clip_at(&self, at: Samples) -> Option<&Clip> { + self.clips.iter().rev().find(|c| c.range().contains(at)) + } + pub fn end(&self) -> Samples { + self.clips.iter().map(Clip::end).max().unwrap_or(0) + } +} + +/// A track. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Track { + pub id: TrackId, + pub name: String, + pub kind: TrackKind, + pub format: ChannelFormat, + pub color: [u8; 3], + pub height: TrackHeight, + pub playlists: Vec, + pub active_playlist: usize, + pub mixer: Mixer, + pub automation: Vec, + /// Which lane the track shows: `waveform`/`blocks`/`notes`/`regions` or an automation param id. + pub view: String, + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub inactive: bool, + #[serde(default)] + pub frozen: bool, + #[serde(default)] + pub comments: String, + /// Folder that contains this track. + #[serde(default)] + pub folder: Option, + #[serde(default = "yes")] + pub folder_open: bool, + /// Instrument plugin (instrument tracks) — registry id. + #[serde(default)] + pub instrument: Option, + /// Ticks (musical) or samples timebase for clips. + #[serde(default)] + pub ticks_timebase: bool, + /// Elastic Audio algorithm, if enabled. + #[serde(default)] + pub elastic: Option, + /// Pinned to top in the Edit window. + #[serde(default)] + pub pinned: bool, +} + +fn yes() -> bool { + true +} + +impl Track { + pub fn new(id: TrackId, name: impl Into, kind: TrackKind, format: ChannelFormat, color: [u8; 3]) -> Self { + let name = name.into(); + let format = if kind.is_midi() && kind == TrackKind::Midi { ChannelFormat::Mono } else { format }; + let view = if kind.is_midi() { + "notes" + } else if kind.has_playlist() { + "waveform" + } else { + "volume" + }; + Track { + id, + playlists: vec![Playlist::new(name.clone())], + name, + kind, + format, + color, + height: TrackHeight::Medium, + active_playlist: 0, + mixer: Mixer::new(format), + automation: Vec::new(), + view: view.into(), + hidden: false, + inactive: false, + frozen: false, + comments: String::new(), + folder: None, + folder_open: true, + instrument: None, + ticks_timebase: kind.is_midi(), + elastic: None, + pinned: false, + } + } + + pub fn channels(&self) -> usize { + self.format.channels() + } + + pub fn playlist(&self) -> Option<&Playlist> { + self.playlists.get(self.active_playlist) + } + + pub fn playlist_mut(&mut self) -> Option<&mut Playlist> { + self.playlists.get_mut(self.active_playlist) + } + + pub fn clips(&self) -> &[Clip] { + self.playlist().map_or(&[], |p| p.clips.as_slice()) + } + + pub fn lane(&self, p: &AutoParam) -> Option<&AutomationLane> { + self.automation.iter().find(|l| &l.param == p) + } + + pub fn lane_mut(&mut self, p: &AutoParam) -> &mut AutomationLane { + let idx = match self.automation.iter().position(|l| &l.param == p) { + Some(i) => i, + None => { + self.automation.push(AutomationLane::new(p.clone())); + self.automation.len() - 1 + } + }; + let len = self.automation.len(); + // `idx` is in range: it came from `position` or is the index of the element just pushed. + &mut self.automation[idx.min(len - 1)] + } + + pub fn is_folder(&self) -> bool { + self.kind == TrackKind::Folder + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_fit_string_loads_as_default_height() { + assert_eq!(serde_json::from_str::("\"Fit\"").unwrap(), TrackHeight::Medium); + assert_eq!(serde_json::from_str::("{\"Fit\":120}").unwrap(), TrackHeight::Fit(120)); + assert_eq!(serde_json::to_string(&TrackHeight::Fit(120)).unwrap(), "{\"Fit\":120}"); + assert_eq!(serde_json::from_str::("\"Large\"").unwrap(), TrackHeight::Large); + assert_eq!(serde_json::from_str::("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50)); + } + + #[test] + fn every_saved_height_form_loads_and_nothing_else() { + for h in TrackHeight::ALL.into_iter().chain([TrackHeight::Custom(50), TrackHeight::Fit(120)]) { + assert_eq!(serde_json::from_str::(&serde_json::to_string(&h).unwrap()).unwrap(), h); + assert_eq!(serde_json::from_value::(serde_json::to_value(h).unwrap()).unwrap(), h); + } + for h in TrackHeight::ALL { + let null = format!("{{{}:null}}", serde_json::to_string(&h).unwrap()); + assert_eq!(serde_json::from_str::(&null).unwrap(), h, "{null}"); + } + assert_eq!(serde_json::from_str::(r#"{"Fit":null}"#).unwrap(), TrackHeight::Medium); + assert!(serde_json::from_str::(r#"{"Custom":null}"#).is_err()); + for bad in + [r#""Custom""#, r#""Huge""#, "7", "[]", "null", "{}", r#"{"Fit":"x"}"#, r#"{"Fit":70000}"#, r#"{"Large":1}"#, r#"{"Fit":1,"Fit":2}"#] + { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } + } + + #[test] + fn a_huge_height_is_refused_at_its_first_token() { + // #892: the height was read through an untagged enum, which buffers the whole value + // first; a session could hold a huge array there. It is refused where it starts. + for (open, rest) in [("[", "0,".repeat(100_000) + "0]"), (r#"{"Fit":["#, "0,".repeat(100_000) + "0]}")] { + let text = format!("{open}{rest}"); + let err = serde_json::from_str::(&text).unwrap_err(); + assert!(err.is_data() && err.line() == 1 && err.column() <= open.len() + 1, "{open}: {err}"); + } + } + + #[test] + fn new_track_defaults() { + let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]); + assert_eq!(t.channels(), 2); + assert_eq!(t.view, "waveform"); + assert_eq!(t.playlists.len(), 1); + let m = Track::new(TrackId(2), "Keys", TrackKind::Midi, ChannelFormat::Stereo, TRACK_COLORS[0]); + assert_eq!(m.format, ChannelFormat::Mono); + assert_eq!(m.view, "notes"); + } + + #[test] + fn lane_mut_creates_once() { + let mut t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Mono, TRACK_COLORS[0]); + t.lane_mut(&AutoParam::Volume).set_point(0, -3.0); + t.lane_mut(&AutoParam::Volume).set_point(10, -6.0); + assert_eq!(t.automation.len(), 1); + assert_eq!(t.lane(&AutoParam::Volume).map(|l| l.points.len()), Some(2)); + } + + #[test] + fn kind_ids() { + for k in TrackKind::ALL { + assert_eq!(TrackKind::from_id(k.id()), Some(k)); + } + } + + #[test] + fn custom_heights_round_to_presets_and_step_through_them() { + assert_eq!(TrackHeight::from_points(88.0), TrackHeight::Medium); + assert_eq!(TrackHeight::from_points(57.4), TrackHeight::Custom(57)); + assert_eq!(TrackHeight::from_points(9999.0), TrackHeight::Extreme); + assert_eq!(TrackHeight::from_points(f32::NAN), TrackHeight::Micro); + assert_eq!(TrackHeight::Custom(u16::MAX).points(), 440.0); + assert_eq!(TrackHeight::Custom(57).step(true), TrackHeight::Medium); + assert_eq!(TrackHeight::Custom(57).step(false), TrackHeight::Small); + assert_eq!(TrackHeight::Extreme.step(true), TrackHeight::Extreme); + assert_eq!(TrackHeight::Micro.step(false), TrackHeight::Micro); + let json = serde_json::to_string(&TrackHeight::Custom(57)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), TrackHeight::Custom(57)); + assert_eq!(serde_json::from_str::("\"Large\"").unwrap(), TrackHeight::Large); + } +} diff --git a/crates/time/src/soundcraft/grid.rs b/crates/time/src/soundcraft/grid.rs index 2d40a454b..9055340ba 100644 --- a/crates/time/src/soundcraft/grid.rs +++ b/crates/time/src/soundcraft/grid.rs @@ -219,15 +219,15 @@ impl FeetFramesValue { } } - /// Apply the counter origin in frames before rounding once to the nearest sample. + /// Apply the counter origin in frames before rounding once to the first sample at the frame + /// boundary. This matches `FrameRate::samples_at_frame` and typed Feet+Frames positions. fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { let (num, den) = self.frame_ratio(); let (rate_num, rate_den) = rate.rational(); let frames = index * num - i128::from(origin) * den; let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den); let divisor = den * i128::from(rate_num); - let magnitude = (samples.abs() + divisor / 2) / divisor; - let rounded = if samples < 0 { -magnitude } else { magnitude }; + let rounded = samples.div_euclid(divisor) + i128::from(samples.rem_euclid(divisor) != 0); i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }) } @@ -588,6 +588,7 @@ fn step_enum(values: &[T], value: T, larger: bool) -> Optio #[cfg(test)] mod tests { use super::*; + use crate::soundcraft::TimeFormat; /// Frame origins are subtracted before rounding whole frames, quarter frames and subframes. #[test] @@ -601,6 +602,24 @@ mod tests { assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]); } + /// Grid lines use the same ceil-rounded sample boundary as typed positions at fractional rates. + #[test] + fn feet_frames_grid_lines_match_typed_positions_at_fractional_rates() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + for origin in [-17, -1, 1, 17] { + for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] { + let text = format!("{feet}+00"); + let at = crate::soundcraft::format::parse_position(&text, TimeFormat::FeetFrames, sr, &map, rate, 0, origin).unwrap(); + for value in FeetFramesValue::ALL { + assert_eq!(value.line_at_or_before(at, sr, rate, origin), at, "{rate:?} {value:?} origin {origin}, {text}"); + } + } + } + } + } + /// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins. #[test] fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() { diff --git a/crates/zip/src/archive.rs b/crates/zip/src/archive.rs index 448e2220b..5739e830a 100644 --- a/crates/zip/src/archive.rs +++ b/crates/zip/src/archive.rs @@ -67,12 +67,38 @@ impl<'a> Archive<'a> { self.zip.is_empty() } + /// The name at `index`, copied on demand so callers can process hostile directories without + /// retaining a second copy of every potentially large name. + pub fn entry_name(&mut self, index: usize) -> Result, Error> { + if index >= self.zip.len() { + return Ok(None); + } + let file = self.zip.by_index_raw(index).map_err(|error| Error::NotZip(error.to_string()))?; + let name = file.name().map_err(|error| Error::NotZip(error.to_string()))?; + Ok(Some(name.into_owned())) + } + + /// Whether the entry at `index` is a directory according to ZIP filename conventions. + pub fn entry_is_dir(&mut self, index: usize) -> Result, Error> { + if index >= self.zip.len() { + return Ok(None); + } + let file = self.zip.by_index_raw(index).map_err(|error| Error::NotZip(error.to_string()))?; + Ok(Some(file.is_dir())) + } + /// Do two entries share a name? `zip` then keeps one of them, and which one a writer meant /// is not known: a format that must not guess refuses the archive. pub fn has_duplicate_names(&self) -> bool { self.duplicates } + /// Do two entries' compressed data ranges overlap? A format that stores many files in one + /// archive can refuse aliases so one payload cannot be expanded and retained repeatedly. + pub fn has_overlapping_files(&mut self) -> Result { + self.zip.has_overlapping_files().map_err(|e| Error::NotZip(e.to_string())) + } + /// The entry names in the archive's order, folders included. A name that is not text is /// left out. pub fn names(&self) -> impl Iterator> { diff --git a/crates/zip/src/lib.rs b/crates/zip/src/lib.rs index 4e802d795..452351ee3 100644 --- a/crates/zip/src/lib.rs +++ b/crates/zip/src/lib.rs @@ -203,4 +203,42 @@ mod tests { assert_eq!(budget.remaining(), 0); assert!(budget.charge(u64::MAX).is_err()); } + + #[test] + #[allow(clippy::unwrap_used, clippy::expect_used)] + fn archive_reports_entries_that_alias_the_same_payload() { + use std::io::Write as _; + + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("first", opts).unwrap(); + zip.write_all(b"payload").unwrap(); + zip.shallow_copy_file("first", "alias").unwrap(); + let bytes = zip.finish().unwrap().into_inner(); + + let mut archive = Archive::open(&bytes, Limits { max_entries: 2, max_total_bytes: 100 }).unwrap(); + assert!(archive.has_overlapping_files().unwrap()); + } + + #[test] + #[allow(clippy::unwrap_used, clippy::expect_used)] + fn archive_can_copy_one_name_at_a_time_by_index() { + use std::io::Write as _; + + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + zip.start_file("first", opts).unwrap(); + zip.write_all(b"1").unwrap(); + zip.start_file("second", opts).unwrap(); + zip.write_all(b"2").unwrap(); + zip.start_file("folder\\", opts).unwrap(); + let bytes = zip.finish().unwrap().into_inner(); + + let mut archive = Archive::open(&bytes, Limits { max_entries: 3, max_total_bytes: 10 }).unwrap(); + assert_eq!(archive.entry_name(0).unwrap().as_deref(), Some("first")); + assert_eq!(archive.entry_name(1).unwrap().as_deref(), Some("second")); + assert_eq!(archive.entry_is_dir(2).unwrap(), Some(true)); + assert_eq!(archive.entry_name(2).unwrap().as_deref(), Some("folder\\")); + assert_eq!(archive.entry_name(3).unwrap(), None); + } }