Merge pull request #1165 from storytold/codex/soundcraft-format

Add shared SoundCraft session format
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 05:26:41 -05:00
commit db49c2d50e
19 files changed
+4369 -3

No files matched your search

Generated
+14
View File
@@ -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"
+1
View File
@@ -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" }
+1
View File
@@ -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,
+24
View File
@@ -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
+15
View File
@@ -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.
+394
View File
@@ -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<AutomationMode> {
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<AutoParam> {
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<u8> {
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<AutomationPoint>,
#[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);
}
}
+114
View File
@@ -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<u32> {
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<Vec<u8>> {
if text.bytes().filter(|c| !c.is_ascii_whitespace()).count() > MAX_DECODED_BYTES.div_ceil(3).saturating_mul(4) {
return None;
}
let syms: Vec<u8> = 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<u8> = (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");
}
}
+248
View File
@@ -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/<name>.wav`,
/// its format set to `Wav`, and `unsaved` cleared.
pub fn write_bundle(session: &Session, limits: BundleLimits) -> Result<(Vec<u8>, 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<String, Vec<u8>>), 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());
}
}
+544
View File
@@ -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<FadeShape> {
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<u64>,
/// 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<u64>,
/// 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<u64>,
}
fn one() -> f64 {
1.0
}
impl Clip {
pub fn audio(id: ClipId, name: impl Into<String>, 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<String>, 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<String>, 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<SourceId> {
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<SourceId> {
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);
}
}
+21
View File
@@ -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);
+123
View File
@@ -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<String, BundleError> {
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<u8>, limit: u64) -> Result<String, BundleError> {
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<u64, BundleError> {
struct Count(u64);
impl std::io::Write for Count {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
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<String, BundleError> {
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<String, BundleError> {
struct Bounded(Vec<u8>, u64, bool);
impl std::io::Write for Bounded {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
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(), "{}");
}
}
+58
View File
@@ -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<TrackId>,
}
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<TrackId>,
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<String>,
}
+714
View File
@@ -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> {
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<String, f32>,
#[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<String>,
/// The session audio source a sample-playing instrument (the built-in Sampler) plays.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample: Option<SourceId>,
}
fn yes() -> bool {
true
}
impl Insert {
pub fn new(plugin: impl Into<String>) -> Self {
Insert {
plugin: plugin.into(),
params: BTreeMap::new(),
bypass: false,
active: true,
preset: String::from("<factory default>"),
state: None,
sample: None,
}
}
/// The decoded plugin state (`None` when absent or malformed).
pub fn state_bytes(&self) -> Option<Vec<u8>> {
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<SurroundPan>,
}
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<f32>,
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<Option<Insert>>,
pub sends: Vec<Option<SendSlot>>,
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<u64>,
/// Surround panner, used when the output is multichannel (None = the stereo pan to L/R).
#[serde(default)]
pub surround: Option<SurroundPan>,
}
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<BTreeMap<String, f32>, D::Error> {
let params: BTreeMap<String, Option<f64>> = 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<u8> = (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<u32> = 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));
}
}
File diff suppressed because it is too large. Load diff
+204
View File
@@ -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<String>,
}
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<Peaks>,
}
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<SourceId, Arc<SourceAudio>>,
}
impl SourcePool {
pub fn insert(&mut self, id: SourceId, audio: Arc<SourceAudio>) {
self.map.insert(id, audio);
}
pub fn get(&self, id: SourceId) -> Option<&Arc<SourceAudio>> {
self.map.get(&id)
}
pub fn remove(&mut self, id: SourceId) -> Option<Arc<SourceAudio>> {
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<Item = SourceId> + '_ {
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::<FileFormat>(&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);
}
}
}
+477
View File
@@ -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<TrackKind> {
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: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<TrackHeight> {
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<E: serde::de::Error>(self, v: &str) -> Result<TrackHeight, E> {
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<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<TrackHeight, A::Error> {
use serde::de::Error as _;
let Some(key) = map.next_key::<String>()? 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::<Option<u16>>()?.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::<serde::de::IgnoredAny>()?.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> {
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<Clip>,
}
impl Playlist {
pub fn new(name: impl Into<String>) -> 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<Item = &Clip> {
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<Playlist>,
pub active_playlist: usize,
pub mixer: Mixer,
pub automation: Vec<AutomationLane>,
/// 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<TrackId>,
#[serde(default = "yes")]
pub folder_open: bool,
/// Instrument plugin (instrument tracks) — registry id.
#[serde(default)]
pub instrument: Option<crate::Insert>,
/// Ticks (musical) or samples timebase for clips.
#[serde(default)]
pub ticks_timebase: bool,
/// Elastic Audio algorithm, if enabled.
#[serde(default)]
pub elastic: Option<String>,
/// 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<String>, 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::<TrackHeight>("\"Fit\"").unwrap(), TrackHeight::Medium);
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Fit\":120}").unwrap(), TrackHeight::Fit(120));
assert_eq!(serde_json::to_string(&TrackHeight::Fit(120)).unwrap(), "{\"Fit\":120}");
assert_eq!(serde_json::from_str::<TrackHeight>("\"Large\"").unwrap(), TrackHeight::Large);
assert_eq!(serde_json::from_str::<TrackHeight>("{\"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::<TrackHeight>(&serde_json::to_string(&h).unwrap()).unwrap(), h);
assert_eq!(serde_json::from_value::<TrackHeight>(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::<TrackHeight>(&null).unwrap(), h, "{null}");
}
assert_eq!(serde_json::from_str::<TrackHeight>(r#"{"Fit":null}"#).unwrap(), TrackHeight::Medium);
assert!(serde_json::from_str::<TrackHeight>(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::<TrackHeight>(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::<TrackHeight>(&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::<TrackHeight>(&json).unwrap(), TrackHeight::Custom(57));
assert_eq!(serde_json::from_str::<TrackHeight>("\"Large\"").unwrap(), TrackHeight::Large);
}
}
+22 -3
View File
@@ -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<T: Copy + PartialEq>(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() {
+26
View File
@@ -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<Option<String>, 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<Option<bool>, 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<bool, Error> {
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<Item = Cow<'_, str>> {
+38
View File
@@ -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);
}
}