mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-13 01:17:30 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb547286d |
Generated
+39
-14
@@ -1064,7 +1064,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-audio"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7c108201239d2504bb61e941828dc9f388187a94#7c108201239d2504bb61e941828dc9f388187a94"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"log",
|
||||
@@ -1109,7 +1109,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7c108201239d2504bb61e941828dc9f388187a94#7c108201239d2504bb61e941828dc9f388187a94"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1118,7 +1118,30 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-midi"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7c108201239d2504bb61e941828dc9f388187a94#7c108201239d2504bb61e941828dc9f388187a94"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-scraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7c108201239d2504bb61e941828dc9f388187a94#7c108201239d2504bb61e941828dc9f388187a94"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-midi",
|
||||
"craft-time",
|
||||
"craft-zip",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-time"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7c108201239d2504bb61e941828dc9f388187a94#7c108201239d2504bb61e941828dc9f388187a94"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1172,6 +1195,16 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-zip"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7c108201239d2504bb61e941828dc9f388187a94#7c108201239d2504bb61e941828dc9f388187a94"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
@@ -4446,6 +4479,7 @@ version = "0.20.1"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-docpath",
|
||||
"craft-scraft-format",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4492,13 +4526,7 @@ dependencies = [
|
||||
name = "soundcraft-model"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-midi",
|
||||
"soundcraft-time",
|
||||
"thiserror 2.0.21",
|
||||
"craft-scraft-format",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4520,10 +4548,7 @@ dependencies = [
|
||||
name = "soundcraft-time"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"craft-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-3
@@ -51,12 +51,14 @@ cpal = "0.18"
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed" }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7c108201239d2504bb61e941828dc9f388187a94" }
|
||||
# Audio file formats, codecs and waveform overviews, shared with the other craft apps. It shares
|
||||
# types with craft-media, so both stay on one rev.
|
||||
# Standard MIDI Files and the note/controller model, shared with the other craft apps.
|
||||
craft-midi = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["serde"] }
|
||||
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["decode", "lossless-encode", "peaks", "serde"] }
|
||||
craft-midi = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7c108201239d2504bb61e941828dc9f388187a94", features = ["serde"] }
|
||||
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7c108201239d2504bb61e941828dc9f388187a94", features = ["decode", "lossless-encode", "peaks", "serde"] }
|
||||
craft-time = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7c108201239d2504bb61e941828dc9f388187a94" }
|
||||
craft-scraft-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7c108201239d2504bb61e941828dc9f388187a94" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
|
||||
|
||||
@@ -243,10 +243,10 @@ about egui, so the interface could be swapped for another one.
|
||||
|
||||
| Crate | What it does |
|
||||
|---|---|
|
||||
| `soundcraft-time` | Timebases, tempo and meter maps, timecode, grid |
|
||||
| `soundcraft-time` | Compatibility re-export of `craft-time`'s SoundCraft sample-index API |
|
||||
| `soundcraft-midi` | Standard MIDI Files and MIDI operations |
|
||||
| `soundcraft-dsp` | Plugins, offline processing, meters, loudness, FFT |
|
||||
| `soundcraft-model` | The session document |
|
||||
| `soundcraft-model` | Compatibility re-export of `craft-scraft-format`'s schema, validation and JSON API |
|
||||
| `soundcraft-mix` | The mix engine (realtime and offline share it) |
|
||||
| `soundcraft-engine` | Commands, undo, editing, import/export, bounce |
|
||||
| `soundcraft-playback` | Audio devices (CoreAudio, WASAPI, ALSA, WebAudio) and recording |
|
||||
|
||||
@@ -10,6 +10,7 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
craft-audio.workspace = true
|
||||
craft-scraft-format.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
soundcraft-clap-host.workspace = true
|
||||
@@ -22,7 +23,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
zip.workspace = true
|
||||
craft-docpath.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -30,6 +30,7 @@ winsafe = { version = "0.0.29", features = ["kernel"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+17
-73
@@ -182,46 +182,20 @@ impl Drop for SessionLimit {
|
||||
/// Refuses the session `name` of `len` bytes when it is larger than [`session_limit`]: too large
|
||||
/// to open, or, when `saving`, to save.
|
||||
pub(crate) fn session_size(name: impl std::fmt::Display, len: u64, saving: bool) -> Result<()> {
|
||||
let limit = session_limit();
|
||||
if len <= limit {
|
||||
return Ok(());
|
||||
}
|
||||
let most = if limit >= 1 << 20 { format!("{} MiB", limit >> 20) } else { format!("{limit} bytes") };
|
||||
Err(EngineError::Io(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})")
|
||||
}))
|
||||
craft_scraft_format::check_session_size(&name.to_string(), len, session_limit(), saving).map_err(|error| EngineError::Io(error.to_string()))
|
||||
}
|
||||
|
||||
/// [`read_text`] for a session file: refused, unread, past [`session_limit`].
|
||||
pub(crate) fn read_session_text(path: impl AsRef<Path>) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
utf8(path, read_file_within(path, true)?)
|
||||
craft_scraft_format::read_session_data(&path.display().to_string(), read_file_within(path, true)?, session_limit())
|
||||
.map_err(|error| EngineError::Io(error.to_string()))
|
||||
}
|
||||
|
||||
/// A bare `.scraft` handed over as bytes (the browser) as text: refused, uncopied, past
|
||||
/// [`session_limit`].
|
||||
pub(crate) fn session_bytes_text(name: &str, bytes: &[u8]) -> Result<String> {
|
||||
session_size(name, bytes.len() as u64, false)?;
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))
|
||||
}
|
||||
|
||||
/// How many bytes [`Session::to_json`] makes of `s`, counted without keeping them.
|
||||
fn json_len(s: &Session) -> u64 {
|
||||
struct Count(u64);
|
||||
impl std::io::Write for Count {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(buf.len() as u64);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut n = Count(0);
|
||||
// A session that cannot be written counts as nothing here: `to_json` reports that error.
|
||||
serde_json::to_writer_pretty(&mut n, s).map_or(0, |()| n.0)
|
||||
craft_scraft_format::read_session_bytes(name, bytes, session_limit()).map_err(|error| EngineError::Io(error.to_string()))
|
||||
}
|
||||
|
||||
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
|
||||
@@ -795,11 +769,12 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
// Before any audio is written: a session too large to open again is not saved (#892). With no
|
||||
// audio to write, the text checked is the text saved.
|
||||
let checked = if pending.is_empty() {
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
session_size(p.display(), text.len() as u64, true)?;
|
||||
let label = p.display().to_string();
|
||||
let text = craft_scraft_format::write_session_named(&s, &label, session_limit()).map_err(|error| EngineError::Io(error.to_string()))?;
|
||||
Some(text)
|
||||
} else {
|
||||
session_size(p.display(), json_len(&s), true)?;
|
||||
let size = craft_scraft_format::session_json_len(&s).map_err(|error| EngineError::Io(error.to_string()))?;
|
||||
session_size(p.display(), size, true)?;
|
||||
None
|
||||
};
|
||||
let mut written = 0;
|
||||
@@ -820,7 +795,10 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
}
|
||||
let text = match checked {
|
||||
Some(text) => text,
|
||||
None => s.to_json().map_err(|err| EngineError::Io(err.to_string()))?,
|
||||
None => {
|
||||
let label = p.display().to_string();
|
||||
craft_scraft_format::write_session_named(&s, &label, session_limit()).map_err(|error| EngineError::Io(error.to_string()))?
|
||||
}
|
||||
};
|
||||
make_dir(&dir)?;
|
||||
// Atomic-ish write: temp file then rename.
|
||||
@@ -1107,45 +1085,11 @@ fn write_export_checked(path: &Path, mut bytes: &[u8]) -> std::io::Result<()> {
|
||||
|
||||
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
|
||||
fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
|
||||
use std::io::Read as _;
|
||||
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
|
||||
if z.len() > limits.entries {
|
||||
return Err(EngineError::Io("bundle: too many files".into()));
|
||||
}
|
||||
// Entries are stored, so the files we keep can only outgrow the bundle when several entries
|
||||
// point at the same bytes: a small bundle naming one payload thousands of times would be
|
||||
// copied out once per name.
|
||||
if z.has_overlapping_files().map_err(zerr)? {
|
||||
return Err(EngineError::Io("bundle: files share data".into()));
|
||||
}
|
||||
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).map_err(zerr)?;
|
||||
if f.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let rel = f.name().map_err(zerr)?.to_string();
|
||||
let size = f.size();
|
||||
total = total.saturating_add(size);
|
||||
if total > limits.bytes {
|
||||
return Err(EngineError::Io("bundle: too large".into()));
|
||||
}
|
||||
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
|
||||
if is_session && text.is_none() {
|
||||
session_size(format_args!("bundle: {rel}"), size, false)?;
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
// The declared size is hostile too: never read past it.
|
||||
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
|
||||
if is_session && text.is_none() {
|
||||
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
|
||||
} else {
|
||||
files.insert(rel, data);
|
||||
}
|
||||
}
|
||||
let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?;
|
||||
Ok((text, files))
|
||||
craft_scraft_format::read_bundle(
|
||||
bytes,
|
||||
craft_scraft_format::BundleLimits { entries: limits.entries, bytes: limits.bytes, session_bytes: session_limit() },
|
||||
)
|
||||
.map_err(|error| EngineError::Io(error.to_string()))
|
||||
}
|
||||
|
||||
/// The record-enabled audio tracks, each with the input channels it records (those its input
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::render::sanitize_name;
|
||||
use super::session::decode_source;
|
||||
use super::{Arc, EncodeOptions, Engine, EngineError, FileFormat, OsStr, Path, Result, Session, SourceAudio, read_bundle, write_file};
|
||||
use super::{Arc, Engine, EngineError, OsStr, Path, Result, Session, SourceAudio, read_bundle, session_limit, write_file};
|
||||
|
||||
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
|
||||
|
||||
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
|
||||
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
|
||||
/// saves and opens sessions as bundles.
|
||||
pub const BUNDLE_EXTENSION: &str = "scraftz";
|
||||
pub const BUNDLE_EXTENSION: &str = craft_scraft_format::BUNDLE_EXTENSION;
|
||||
|
||||
/// Most entries and bytes a bundle may unpack to (bundles are hostile input). The writer keeps to
|
||||
/// them too, so a bundle it saves always opens again (#433).
|
||||
@@ -28,54 +28,8 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
|
||||
|
||||
/// [`session_bundle`], refused when it would hold more than `limits` allows.
|
||||
fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec<u8>, Session)> {
|
||||
use std::io::Write as _;
|
||||
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
|
||||
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
|
||||
let mut s = session.clone();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let (mut entries, mut total) = (0usize, 0u64);
|
||||
// Counted as the reader counts them: files, and the bytes they unpack to.
|
||||
let mut fits = |len: usize| {
|
||||
entries += 1;
|
||||
total = total.saturating_add(len as u64);
|
||||
if entries > limits.entries || total > limits.bytes {
|
||||
return Err(EngineError::Io(format!(
|
||||
"bundle: 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(())
|
||||
};
|
||||
for src in &mut s.sources {
|
||||
let Some(audio) = s.pool.get(src.id) else { continue };
|
||||
let base = sanitize_name(&src.name);
|
||||
let mut target = format!("Audio Files/{base}.wav");
|
||||
let mut k = 1;
|
||||
while taken.contains(&target) && k <= 9_999 {
|
||||
target = format!("Audio Files/{base}_{k:02}.wav");
|
||||
k += 1;
|
||||
}
|
||||
let opts_wav = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(craft_audio::BitDepth::Float32);
|
||||
let bytes = craft_audio::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
fits(bytes.len())?;
|
||||
w.start_file(target.as_str(), opts).map_err(zerr)?;
|
||||
w.write_all(&bytes).map_err(ioerr)?;
|
||||
taken.insert(target.clone());
|
||||
src.path = target;
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// Its session too: one the reader would refuse is not saved (#892).
|
||||
super::session_size("the session", text.len() as u64, true)?;
|
||||
fits(text.len())?;
|
||||
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
|
||||
w.write_all(text.as_bytes()).map_err(ioerr)?;
|
||||
let bytes = w.finish().map_err(zerr)?.into_inner();
|
||||
Ok((bytes, s))
|
||||
let shared = craft_scraft_format::BundleLimits { entries: limits.entries, bytes: limits.bytes, session_bytes: session_limit() };
|
||||
craft_scraft_format::write_bundle(session, shared).map_err(|error| EngineError::Io(error.to_string()))
|
||||
}
|
||||
|
||||
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
|
||||
@@ -200,6 +154,48 @@ mod bundle_tests {
|
||||
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_zip64_bundle_opens_with_shared_reader() {
|
||||
use craft_audio::{AudioBuffer, EncodeFormat, EncodeOptions, encode};
|
||||
use soundcraft_model::{FileFormat, Source, SourceId};
|
||||
use std::io::Write as _;
|
||||
|
||||
let id = SourceId(7);
|
||||
let samples = [0.125, -0.75, 1.25, -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: "Legacy Lead".into(),
|
||||
path: "Audio Files/Legacy Lead.wav".into(),
|
||||
channels: 1,
|
||||
frames: samples.len() as u64,
|
||||
sample_rate: 48_000,
|
||||
format: FileFormat::Wav,
|
||||
time_reference: 0,
|
||||
unsaved: false,
|
||||
});
|
||||
session.pool.insert(id, Arc::new(SourceAudio::new(audio)));
|
||||
|
||||
let wav =
|
||||
encode(&session.pool.get(id).unwrap().buffer, &EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(craft_audio::BitDepth::Float32))
|
||||
.unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
|
||||
zip.start_file("Audio Files/Legacy Lead.wav", opts).unwrap();
|
||||
zip.write_all(&wav).unwrap();
|
||||
zip.start_file("Legacy.scraft", opts).unwrap();
|
||||
zip.write_all(session.to_json().unwrap().as_bytes()).unwrap();
|
||||
let legacy = zip.finish().unwrap().into_inner();
|
||||
|
||||
let mut opened = Engine::default();
|
||||
let missing = open_session_bytes(&mut opened, "Legacy.scraftz", &legacy).unwrap();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
assert_eq!(opened.session().pool.get(id).unwrap().buffer.channels[0], samples);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_sharing_one_file_all_load_from_a_bundle() {
|
||||
use std::io::Write as _;
|
||||
|
||||
+2
-10
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "soundcraft-model"
|
||||
description = "SoundCraft session document: tracks, playlists, clips, mixer, routing, automation, markers"
|
||||
description = "Compatibility facade for SoundCraft's shared native session format"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -8,15 +8,7 @@ rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
craft-audio.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
craft-scraft-format.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
//! Automation lanes and modes.
|
||||
|
||||
use 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);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
//! 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() {
|
||||
for (extra, ok) in [(-1, true), (0, true), (1, false), (2, false)] {
|
||||
let data = vec![7u8; MAX_DECODED_BYTES.saturating_add_signed(extra)];
|
||||
let padded = encode(&data);
|
||||
let unpadded = padded.trim_end_matches('=');
|
||||
assert_eq!(decode(&padded).is_some(), ok, "padded, {extra}");
|
||||
assert_eq!(decode(unpadded).is_some(), ok, "unpadded, {extra}");
|
||||
let spaced = format!("{}\n {}", &padded[..8], &padded[8..]);
|
||||
assert_eq!(decode(&spaced).is_some(), ok, "whitespace, {extra}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
//! Clips: windows onto audio sources or MIDI sequences, placed on a playlist.
|
||||
|
||||
use crate::{ClipId, MAX_NAME_CHARS, SourceId, truncate_chars};
|
||||
use soundcraft_midi::Sequence;
|
||||
use soundcraft_time::{MAX_POSITION, Range, Samples};
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//! 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);
|
||||
+4
-29
@@ -1,32 +1,7 @@
|
||||
//! The SoundCraft session document.
|
||||
//! Compatibility facade for SoundCraft's shared session document crate.
|
||||
//!
|
||||
//! 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.
|
||||
//! Engine and UI adapters depend on this name, while the wire schema, JSON reader/writer,
|
||||
//! validation, and bundle format live in `craft-scraft-format`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod automation;
|
||||
pub mod b64;
|
||||
mod clip;
|
||||
mod ids;
|
||||
mod markers;
|
||||
mod mixer;
|
||||
mod session;
|
||||
mod source;
|
||||
mod track;
|
||||
|
||||
pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint};
|
||||
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;
|
||||
pub use craft_scraft_format::*;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//! Memory locations (markers and stored selections) and mix/edit groups.
|
||||
|
||||
use crate::{GroupId, MarkerId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
//! 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
@@ -1,204 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
//! Tracks and playlists.
|
||||
|
||||
use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "soundcraft-time"
|
||||
description = "SoundCraft timebases: samples, Bars|Beats, Min:Secs, Timecode, Feet+Frames, tempo and meter maps, grid"
|
||||
description = "Compatibility facade for SoundCraft's shared sample-index time API"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -8,12 +8,7 @@ rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
craft-time.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
//! Formatting and parsing positions in the five timebases.
|
||||
|
||||
use crate::{BarBeat, FrameRate, SampleRate, Samples, TempoMap, TimeError, Timecode, timecode::feet_frames};
|
||||
|
||||
/// The counters' and rulers' timebases.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TimeFormat {
|
||||
BarsBeats,
|
||||
#[default]
|
||||
MinSecs,
|
||||
Timecode,
|
||||
FeetFrames,
|
||||
Samples,
|
||||
}
|
||||
|
||||
impl TimeFormat {
|
||||
pub const ALL: [TimeFormat; 5] = [TimeFormat::BarsBeats, TimeFormat::MinSecs, TimeFormat::Timecode, TimeFormat::FeetFrames, TimeFormat::Samples];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimeFormat::BarsBeats => "Bars|Beats",
|
||||
TimeFormat::MinSecs => "Min:Secs",
|
||||
TimeFormat::Timecode => "Timecode",
|
||||
TimeFormat::FeetFrames => "Feet+Frames",
|
||||
TimeFormat::Samples => "Samples",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimeFormat::BarsBeats => "bars_beats",
|
||||
TimeFormat::MinSecs => "min_secs",
|
||||
TimeFormat::Timecode => "timecode",
|
||||
TimeFormat::FeetFrames => "feet_frames",
|
||||
TimeFormat::Samples => "samples",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<TimeFormat> {
|
||||
TimeFormat::ALL.into_iter().find(|f| f.id() == id || f.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
}
|
||||
|
||||
fn min_secs(s: Samples, sr: SampleRate) -> String {
|
||||
let neg = s < 0;
|
||||
// Integer maths: a float `seconds * 1000` lands just below exact millisecond positions.
|
||||
let ms_total = i64::try_from(i128::from(s).abs() * 1000 / i128::from(sr.hz().max(1))).unwrap_or(i64::MAX);
|
||||
let mins = ms_total / 60_000;
|
||||
let secs = (ms_total / 1000) % 60;
|
||||
let ms = ms_total % 1000;
|
||||
format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" })
|
||||
}
|
||||
|
||||
/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the
|
||||
/// feet+frames reading of sample 0 in frames.
|
||||
pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String {
|
||||
match fmt {
|
||||
TimeFormat::Samples => s.to_string(),
|
||||
TimeFormat::MinSecs => min_secs(s, sr),
|
||||
TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate),
|
||||
TimeFormat::FeetFrames => {
|
||||
let (ft, fr) = feet_frames(s, rate, sr, ff_start);
|
||||
format!("{ft}+{fr:02}")
|
||||
}
|
||||
TimeFormat::BarsBeats => {
|
||||
let bb = map.bar_beat_at(s, sr);
|
||||
format!("{}| {}| {:03}", bb.bar, bb.beat, bb.tick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a length starting at `start` (Bars|Beats lengths count from 0|0|000).
|
||||
pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> String {
|
||||
match fmt {
|
||||
TimeFormat::BarsBeats => {
|
||||
let t0 = map.samples_to_ticks(start, sr);
|
||||
let t1 = map.samples_to_ticks(start.saturating_add(len), sr);
|
||||
let m = map.meter_at_tick(t0);
|
||||
let d = (t1 - t0).max(0);
|
||||
let bars = d / m.ticks_per_bar().max(1);
|
||||
let rem = d % m.ticks_per_bar().max(1);
|
||||
format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1))
|
||||
}
|
||||
TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate),
|
||||
other => format_position(len, other, sr, map, rate, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a position typed into a counter or dialog.
|
||||
pub fn parse_position(
|
||||
text: &str,
|
||||
fmt: TimeFormat,
|
||||
sr: SampleRate,
|
||||
map: &TempoMap,
|
||||
rate: FrameRate,
|
||||
tc_start: Samples,
|
||||
ff_start: i64,
|
||||
) -> Result<Samples, TimeError> {
|
||||
let t = text.trim();
|
||||
let err = || TimeError::Parse(t.to_string(), fmt.label());
|
||||
match fmt {
|
||||
TimeFormat::Samples => t.replace([',', '_'], "").parse::<i64>().map_err(|_| err()),
|
||||
TimeFormat::MinSecs => {
|
||||
let (neg, body) = match t.strip_prefix('-') {
|
||||
Some(r) => (true, r),
|
||||
None => (false, t),
|
||||
};
|
||||
let (m, s) = match body.split_once(':') {
|
||||
Some((m, s)) => (m.trim().parse::<f64>().map_err(|_| err())?, s.trim().parse::<f64>().map_err(|_| err())?),
|
||||
None => (0.0, body.parse::<f64>().map_err(|_| err())?),
|
||||
};
|
||||
if !m.is_finite() || !s.is_finite() || m < 0.0 || s < 0.0 {
|
||||
return Err(err());
|
||||
}
|
||||
let v = sr.samples(m * 60.0 + s);
|
||||
Ok(if neg { -v } else { v })
|
||||
}
|
||||
TimeFormat::Timecode => {
|
||||
let tc = Timecode::parse(t)?;
|
||||
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
|
||||
return Err(err());
|
||||
}
|
||||
if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 {
|
||||
let dropped = if rate.nominal() >= 60 { 4 } else { 2 };
|
||||
if tc.frames < dropped {
|
||||
return Err(err());
|
||||
}
|
||||
}
|
||||
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
|
||||
}
|
||||
TimeFormat::FeetFrames => {
|
||||
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
|
||||
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
|
||||
let fr: i64 = fr.trim().parse().map_err(|_| err())?;
|
||||
Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr))
|
||||
}
|
||||
TimeFormat::BarsBeats => {
|
||||
let parts: Vec<i64> =
|
||||
t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::<i64>).collect::<Result<_, _>>().map_err(|_| err())?;
|
||||
let bb = BarBeat {
|
||||
bar: *parts.first().ok_or_else(err)?,
|
||||
beat: parts.get(1).copied().unwrap_or(1).max(1),
|
||||
tick: parts.get(2).copied().unwrap_or(0).max(0),
|
||||
};
|
||||
Ok(map.samples_at_bar_beat(bb, sr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> (SampleRate, TempoMap, FrameRate) {
|
||||
(SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_typed_positions_parse_without_overflowing() {
|
||||
let (sr, map, rate) = ctx();
|
||||
for (text, fmt) in [
|
||||
("999999999999999999:0", TimeFormat::Timecode),
|
||||
("100000000000000:00:00:00", TimeFormat::Timecode),
|
||||
("-9223372036854775808", TimeFormat::BarsBeats),
|
||||
] {
|
||||
let _ = parse_position(text, fmt, sr, &map, rate, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_like_pro_tools_counters() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000");
|
||||
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500");
|
||||
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000");
|
||||
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00");
|
||||
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bar_parses_without_overflow() {
|
||||
let (sr, map, rate) = ctx();
|
||||
let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0);
|
||||
assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0));
|
||||
assert_eq!(p("2|3|000").ok(), Some(144_000));
|
||||
assert_eq!(p("0|1|000").ok(), Some(-96_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_round_trips() {
|
||||
let (sr, map, rate) = ctx();
|
||||
for fmt in TimeFormat::ALL {
|
||||
for s in [0i64, 48_000, 96_000 * 3, 1_440_000] {
|
||||
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
|
||||
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
|
||||
assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_round_trips_timecode_negative() {
|
||||
let (sr, map, _) = ctx();
|
||||
let rate = FrameRate::Fps25;
|
||||
for fmt in [TimeFormat::Timecode, TimeFormat::FeetFrames] {
|
||||
for s in [-48_000i64, -96_000 * 3, -1_440_000] {
|
||||
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
|
||||
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
|
||||
assert_eq!(back, s, "{fmt:?} {s} → {text} → {back}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_garbage() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err());
|
||||
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err());
|
||||
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_skipped_labels_are_rejected() {
|
||||
let (sr, map, _) = ctx();
|
||||
for (rate, skipped) in [
|
||||
(FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]),
|
||||
(FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]),
|
||||
] {
|
||||
for text in skipped {
|
||||
assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}");
|
||||
}
|
||||
assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_lengths() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert_eq!(format_length(0, 96_000 + 24_000, TimeFormat::BarsBeats, sr, &map, rate), "1| 1| 000");
|
||||
}
|
||||
}
|
||||
@@ -1,809 +0,0 @@
|
||||
//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping.
|
||||
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples};
|
||||
|
||||
/// Note values for Bars|Beats grids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum NoteValue {
|
||||
Bar,
|
||||
Whole,
|
||||
Half,
|
||||
Quarter,
|
||||
Eighth,
|
||||
Sixteenth,
|
||||
ThirtySecond,
|
||||
SixtyFourth,
|
||||
}
|
||||
|
||||
impl NoteValue {
|
||||
/// Every musical increment in menu order, from largest to smallest.
|
||||
pub const ALL: [NoteValue; 8] = [
|
||||
NoteValue::Bar,
|
||||
NoteValue::Whole,
|
||||
NoteValue::Half,
|
||||
NoteValue::Quarter,
|
||||
NoteValue::Eighth,
|
||||
NoteValue::Sixteenth,
|
||||
NoteValue::ThirtySecond,
|
||||
NoteValue::SixtyFourth,
|
||||
];
|
||||
/// Returns the nominal tick length; bars are adjusted for the active meter when used.
|
||||
pub fn ticks(self) -> i64 {
|
||||
match self {
|
||||
NoteValue::Bar => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Whole => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Half => TICKS_PER_QUARTER * 2,
|
||||
NoteValue::Quarter => TICKS_PER_QUARTER,
|
||||
NoteValue::Eighth => TICKS_PER_QUARTER / 2,
|
||||
NoteValue::Sixteenth => TICKS_PER_QUARTER / 4,
|
||||
NoteValue::ThirtySecond => TICKS_PER_QUARTER / 8,
|
||||
NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16,
|
||||
}
|
||||
}
|
||||
/// Returns the menu label for this musical increment.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "Whole note",
|
||||
NoteValue::Half => "1/2 note",
|
||||
NoteValue::Quarter => "1/4 note",
|
||||
NoteValue::Eighth => "1/8 note",
|
||||
NoteValue::Sixteenth => "1/16 note",
|
||||
NoteValue::ThirtySecond => "1/32 note",
|
||||
NoteValue::SixtyFourth => "1/64 note",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The time scale used by a Grid or Nudge value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridScale {
|
||||
BarsBeats,
|
||||
MinSecs,
|
||||
Timecode,
|
||||
FeetFrames,
|
||||
Samples,
|
||||
ClipsMarkers,
|
||||
}
|
||||
|
||||
impl GridScale {
|
||||
/// Numeric time scales shared by Grid and Nudge.
|
||||
pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples];
|
||||
|
||||
/// Returns the user-facing scale label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "Bars|Beats",
|
||||
GridScale::MinSecs => "Min:Secs",
|
||||
GridScale::Timecode => "Timecode",
|
||||
GridScale::FeetFrames => "Feet+Frames",
|
||||
GridScale::Samples => "Samples",
|
||||
GridScale::ClipsMarkers => "Clips/Markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "bars_beats",
|
||||
GridScale::MinSecs => "min_secs",
|
||||
GridScale::Timecode => "timecode",
|
||||
GridScale::FeetFrames => "feet_frames",
|
||||
GridScale::Samples => "samples",
|
||||
GridScale::ClipsMarkers => "clips_markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<GridScale> {
|
||||
Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Timecode Grid/Nudge increments, ordered independently of the session frame rate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TimecodeValue {
|
||||
Second,
|
||||
/// Five physical frames, independent of skipped drop-frame labels.
|
||||
FiveFrames,
|
||||
Frame,
|
||||
HalfFrame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl TimecodeValue {
|
||||
/// Every Timecode increment in menu order, from largest to smallest.
|
||||
pub const ALL: [TimecodeValue; 6] = [
|
||||
TimecodeValue::Second,
|
||||
TimecodeValue::FiveFrames,
|
||||
TimecodeValue::Frame,
|
||||
TimecodeValue::HalfFrame,
|
||||
TimecodeValue::QuarterFrame,
|
||||
TimecodeValue::SubFrame,
|
||||
];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "1 second",
|
||||
TimecodeValue::FiveFrames => "5 frames",
|
||||
TimecodeValue::Frame => "1 frame",
|
||||
TimecodeValue::HalfFrame => "1/2 frame",
|
||||
TimecodeValue::QuarterFrame => "1/4 frame",
|
||||
TimecodeValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "second",
|
||||
TimecodeValue::FiveFrames => "5_frames",
|
||||
TimecodeValue::Frame => "frame",
|
||||
TimecodeValue::HalfFrame => "half_frame",
|
||||
TimecodeValue::QuarterFrame => "quarter_frame",
|
||||
TimecodeValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<TimecodeValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames at the session rate.
|
||||
fn frames(self, rate: FrameRate) -> f64 {
|
||||
match self {
|
||||
TimecodeValue::Second => rate.nominal() as f64,
|
||||
TimecodeValue::FiveFrames => 5.0,
|
||||
TimecodeValue::Frame => 1.0,
|
||||
TimecodeValue::HalfFrame => 0.5,
|
||||
TimecodeValue::QuarterFrame => 0.25,
|
||||
TimecodeValue::SubFrame => 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FeetFramesValue {
|
||||
Foot,
|
||||
Frame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl FeetFramesValue {
|
||||
/// Every Feet+Frames increment in menu order, from largest to smallest.
|
||||
pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "1 foot",
|
||||
FeetFramesValue::Frame => "1 frame",
|
||||
FeetFramesValue::QuarterFrame => "1/4 frame",
|
||||
FeetFramesValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "foot",
|
||||
FeetFramesValue::Frame => "frame",
|
||||
FeetFramesValue::QuarterFrame => "quarter_frame",
|
||||
FeetFramesValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<FeetFramesValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames.
|
||||
fn frames(self) -> f64 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
num as f64 / den as f64
|
||||
}
|
||||
|
||||
/// Exact numerator and denominator of the increment in physical frames.
|
||||
fn frame_ratio(self) -> (i128, i128) {
|
||||
match self {
|
||||
Self::Foot => (16, 1),
|
||||
Self::Frame => (1, 1),
|
||||
Self::QuarterFrame => (1, 4),
|
||||
Self::SubFrame => (1, 100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the counter origin in frames before rounding once to the nearest sample.
|
||||
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 };
|
||||
i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
/// Find a line index using exact frame arithmetic, allowing for whole-sample rounding.
|
||||
fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
let (rate_num, rate_den) = rate.rational();
|
||||
let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den);
|
||||
let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale;
|
||||
let mut index = (frames * den).div_euclid(scale * num);
|
||||
if self.sample_line(index + 1, sr, rate, origin) <= position {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// The Feet+Frames line at or before a sample, relative to the counter's frame origin.
|
||||
pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin)
|
||||
}
|
||||
|
||||
/// The next distinct sample line; subframe increments may share a sample at low sample rates.
|
||||
fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Snap against frame-origin lines, choosing the earlier line when distances are equal.
|
||||
pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
let lo = self.line_at_or_before(position, sr, rate, origin);
|
||||
let hi = self.next_line(lo, sr, rate, origin);
|
||||
if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi }
|
||||
}
|
||||
|
||||
/// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`.
|
||||
pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec<Samples> {
|
||||
let mut out = Vec::new();
|
||||
let mut line = self.line_at_or_before(start, sr, rate, origin);
|
||||
while line < end && out.len() < max {
|
||||
if line >= start {
|
||||
out.push(line);
|
||||
}
|
||||
let next = self.next_line(line, sr, rate, origin);
|
||||
if next <= line {
|
||||
break;
|
||||
}
|
||||
line = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid or nudge step.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridValue {
|
||||
/// Musical: note value, optionally dotted or triplet.
|
||||
Note { value: NoteValue, dotted: bool, triplet: bool },
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 1 s).
|
||||
Seconds(f64),
|
||||
/// Frames at the session timecode rate.
|
||||
/// Kept for sessions and commands written before named timecode increments were added.
|
||||
Frames(i64),
|
||||
/// A named Timecode increment, including fractional frames and one second.
|
||||
Timecode(TimecodeValue),
|
||||
/// A Feet+Frames increment (one foot is 16 frames).
|
||||
FeetFrames(FeetFramesValue),
|
||||
/// Raw samples.
|
||||
Samples(i64),
|
||||
}
|
||||
|
||||
impl Default for GridValue {
|
||||
/// Starts on a straight sixteenth-note Bars|Beats grid.
|
||||
fn default() -> Self {
|
||||
GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl GridValue {
|
||||
/// Returns the user-facing label for this increment.
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let mut s = value.label().to_string();
|
||||
if *dotted {
|
||||
s.push_str(" dotted");
|
||||
}
|
||||
if *triplet {
|
||||
s.push_str(" triplet");
|
||||
}
|
||||
s
|
||||
}
|
||||
GridValue::Seconds(s) => {
|
||||
if *s >= 1.0 {
|
||||
format!("{s} second{}", if *s == 1.0 { "" } else { "s" })
|
||||
} else {
|
||||
format!("{} ms", (s * 1000.0).round() as i64)
|
||||
}
|
||||
}
|
||||
GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }),
|
||||
GridValue::Timecode(value) => value.label().to_string(),
|
||||
GridValue::FeetFrames(value) => value.label().to_string(),
|
||||
GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the time scale to which this increment belongs.
|
||||
pub fn scale(self) -> GridScale {
|
||||
match self {
|
||||
GridValue::Note { .. } => GridScale::BarsBeats,
|
||||
GridValue::Seconds(_) => GridScale::MinSecs,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode,
|
||||
GridValue::FeetFrames(_) => GridScale::FeetFrames,
|
||||
GridValue::Samples(_) => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default increment for a newly selected time scale.
|
||||
pub fn for_scale(scale: GridScale) -> GridValue {
|
||||
match scale {
|
||||
GridScale::BarsBeats => GridValue::default(),
|
||||
GridScale::MinSecs => GridValue::Seconds(1.0),
|
||||
GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame),
|
||||
GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame),
|
||||
GridScale::Samples => GridValue::Samples(100),
|
||||
GridScale::ClipsMarkers => GridValue::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep
|
||||
/// their dot or triplet modifier.
|
||||
pub fn stepped(self, larger: bool) -> GridValue {
|
||||
/// Finds the next value in a numeric increment ladder without wrapping.
|
||||
fn next<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
|
||||
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
|
||||
}
|
||||
match self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0);
|
||||
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
|
||||
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
|
||||
}
|
||||
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds),
|
||||
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
|
||||
GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode),
|
||||
GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames),
|
||||
GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples),
|
||||
}
|
||||
}
|
||||
|
||||
fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 {
|
||||
let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() };
|
||||
let mut t = base;
|
||||
if dotted {
|
||||
t = t * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
t = t * 2 / 3;
|
||||
}
|
||||
t.max(1)
|
||||
}
|
||||
|
||||
/// The step in samples at position `at` (musical steps depend on tempo).
|
||||
pub fn step_samples(&self, at: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let t0 = map.samples_to_ticks(at, sr);
|
||||
let dt = Self::note_ticks(value, dotted, triplet, map, t0);
|
||||
(map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1)
|
||||
}
|
||||
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
||||
GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1),
|
||||
GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1),
|
||||
GridValue::Samples(n) => n.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest grid line to `pos`.
|
||||
pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.snap(pos, sr, rate, 0);
|
||||
}
|
||||
let lo = self.line_at_or_before(pos, sr, map, rate);
|
||||
let hi = self.next_line(lo, sr, map, rate);
|
||||
if (pos - lo) <= (hi - pos) { lo } else { hi }
|
||||
}
|
||||
|
||||
/// The grid line at or before `pos`.
|
||||
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0),
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
// Float ticks can land a hair either side of a line's own tick.
|
||||
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
|
||||
if map.tick_to_samples(t, sr) > pos {
|
||||
t = t.saturating_sub(1);
|
||||
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
|
||||
t = t.saturating_add(1);
|
||||
}
|
||||
// Sub-bar values restart at each bar so odd meters stay aligned. A whole note is
|
||||
// always four quarter notes and therefore keeps a continuous tick origin.
|
||||
let bb = map.bar_beat_at_tick(t);
|
||||
let bar_tick = map.bar_start_tick(bb.bar);
|
||||
let origin = if value == NoteValue::Whole { 0 } else { bar_tick };
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, origin);
|
||||
let k = (t - origin).div_euclid(step);
|
||||
map.tick_to_samples(origin + k * step, sr)
|
||||
}
|
||||
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
|
||||
/// rounded once, so rounding never accumulates from line to line.
|
||||
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.sample_line(i128::from(k), sr, rate, 0);
|
||||
}
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr);
|
||||
}
|
||||
if let Some(frames) = self.frame_step(rate) {
|
||||
let whole = frames.round();
|
||||
if frames == whole {
|
||||
return rate.samples_at_frame(k.saturating_mul(whole as i64), sr);
|
||||
}
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
return to_samples(k as f64 * step);
|
||||
}
|
||||
match *self {
|
||||
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
|
||||
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
|
||||
GridValue::Note { .. } => 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the absolute grid line at or before `pos`.
|
||||
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate);
|
||||
let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds);
|
||||
let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude };
|
||||
while self.abs_line(k, sr, rate) > pos {
|
||||
let previous = k.saturating_sub(1);
|
||||
if previous == k {
|
||||
break;
|
||||
}
|
||||
k = previous;
|
||||
}
|
||||
while self.abs_line(k.saturating_add(1), sr, rate) <= pos {
|
||||
let next = k.saturating_add(1);
|
||||
if next == k {
|
||||
break;
|
||||
}
|
||||
k = next;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
let mut k = if let Some(frames) = self.frame_step(rate) {
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
(pos as f64 / step).floor() as i64
|
||||
} else {
|
||||
match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0,
|
||||
}
|
||||
};
|
||||
// Lines are rounded to whole samples, so the floored index can be one off either way.
|
||||
if self.abs_line(k, sr, rate) > pos {
|
||||
k = k.saturating_sub(1);
|
||||
} else if self.abs_line(k.saturating_add(1), sr, rate) <= pos {
|
||||
k = k.saturating_add(1);
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The exact (fractional) Seconds step in samples, at least one sample.
|
||||
fn seconds_step(s: f64, sr: SampleRate) -> f64 {
|
||||
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
|
||||
}
|
||||
|
||||
/// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate.
|
||||
fn drop_second_frame(index: i64, rate: FrameRate) -> i64 {
|
||||
let seconds = index.unsigned_abs();
|
||||
let total_minutes = seconds / 60;
|
||||
let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 };
|
||||
Timecode {
|
||||
negative: index < 0,
|
||||
hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX),
|
||||
minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0),
|
||||
seconds: i64::try_from(seconds % 60).unwrap_or(0),
|
||||
frames,
|
||||
}
|
||||
.to_frames(rate)
|
||||
}
|
||||
|
||||
/// A frame-based increment as a possibly fractional number of frames.
|
||||
fn frame_step(&self, rate: FrameRate) -> Option<f64> {
|
||||
match *self {
|
||||
GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64),
|
||||
GridValue::Timecode(value) => Some(value.frames(rate)),
|
||||
GridValue::FeetFrames(value) => Some(value.frames()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first grid line strictly after `line`.
|
||||
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.next_line(line, sr, rate, 0);
|
||||
}
|
||||
if !matches!(self, GridValue::Note { .. }) {
|
||||
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
|
||||
return if next > line { next } else { line.saturating_add(1) };
|
||||
}
|
||||
let step = self.step_samples(line, sr, map, rate);
|
||||
let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate);
|
||||
if candidate > line { candidate } else { line.saturating_add(step) }
|
||||
}
|
||||
|
||||
/// All grid lines within `[start, end)`, capped at `max` lines.
|
||||
pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec<Samples> {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.lines(start, end, sr, rate, 0, max);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut p = self.line_at_or_before(start, sr, map, rate);
|
||||
while p < end && out.len() < max {
|
||||
if p >= start {
|
||||
out.push(p);
|
||||
}
|
||||
let n = self.next_line(p, sr, map, rate);
|
||||
if n <= p {
|
||||
break;
|
||||
}
|
||||
p = n;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves one position through an ordered increment menu without wrapping.
|
||||
fn step_enum<T: Copy + PartialEq>(values: &[T], value: T, larger: bool) -> Option<T> {
|
||||
let index = values.iter().position(|candidate| *candidate == value)?;
|
||||
let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?;
|
||||
values.get(next).copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Frame origins are subtracted before rounding whole frames, quarter frames and subframes.
|
||||
#[test]
|
||||
fn feet_frames_origins_round_only_after_applying_the_frame_offset() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97);
|
||||
for at in [49_650, 100_901, 177_778] {
|
||||
assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at);
|
||||
assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at);
|
||||
}
|
||||
assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050);
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]);
|
||||
}
|
||||
|
||||
/// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins.
|
||||
#[test]
|
||||
fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() {
|
||||
let sr = SampleRate::new(8_000).unwrap();
|
||||
let rate = FrameRate::Fps120;
|
||||
for origin in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]);
|
||||
for at in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at);
|
||||
assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_lines_are_their_own_line_at_odd_tempos() {
|
||||
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
|
||||
for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] {
|
||||
let map = TempoMap::new(bpm, 4, 4).unwrap();
|
||||
let mut line = 0;
|
||||
for _ in 0..64 {
|
||||
assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm");
|
||||
line = g.next_line(line, sr, &map, rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Musical boundaries stay exact across a tempo change while absolute scales remain
|
||||
/// independent of the tempo map.
|
||||
#[test]
|
||||
fn grids_stay_aligned_across_a_tempo_change() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop);
|
||||
let mut map = TempoMap::default();
|
||||
map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
|
||||
let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false };
|
||||
for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] {
|
||||
let line = map.tick_to_samples(tick, sr);
|
||||
assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line);
|
||||
}
|
||||
for absolute in [
|
||||
GridValue::Seconds(0.5),
|
||||
GridValue::Timecode(TimecodeValue::Second),
|
||||
GridValue::FeetFrames(FeetFramesValue::Foot),
|
||||
GridValue::Samples(1_000),
|
||||
] {
|
||||
let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate);
|
||||
assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteenth_grid_at_120() {
|
||||
let g = GridValue::default();
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30);
|
||||
assert_eq!(g.step_samples(0, sr, &map, rate), 6000);
|
||||
assert_eq!(g.snap(2900, sr, &map, rate), 0);
|
||||
assert_eq!(g.snap(3100, sr, &map, rate), 6000);
|
||||
assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]);
|
||||
}
|
||||
|
||||
/// Absolute increments report their expected sample lengths at a fixed rate.
|
||||
#[test]
|
||||
fn absolute_grids() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19);
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720);
|
||||
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
|
||||
}
|
||||
|
||||
/// Bar length follows the meter while a whole note always spans four quarter notes.
|
||||
#[test]
|
||||
fn a_bar_and_a_whole_note_differ_in_three_four() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25);
|
||||
let note = |value| GridValue::Note { value, dotted: false, triplet: false };
|
||||
assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_grid_lines_land_on_whole_seconds() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
|
||||
for g in [GridValue::Seconds(0.001), GridValue::Seconds(0.01)] {
|
||||
for n in 0..20 {
|
||||
let s = n * 44_100;
|
||||
assert_eq!(g.snap(s, sr, &map, rate), s, "{g:?} at {n} s");
|
||||
assert_eq!(g.line_at_or_before(s, sr, &map, rate), s, "{g:?} at {n} s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy frame grids stay aligned to exact frame starts at fractional rates.
|
||||
#[test]
|
||||
fn frame_grid_lines_land_on_frame_starts() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
|
||||
for f in [1, 10] {
|
||||
let g = GridValue::Frames(f);
|
||||
for k in (0..5000).step_by(37) {
|
||||
let s = rate.samples_at_frame(k, sr);
|
||||
let line = rate.samples_at_frame(k.div_euclid(f) * f, sr);
|
||||
assert_eq!(g.line_at_or_before(s, sr, &map, rate), line, "{rate:?} {f} frames at frame {k}");
|
||||
if k % f == 0 {
|
||||
assert_eq!(g.snap(s, sr, &map, rate), s, "{rate:?} {f} frames at frame {k}");
|
||||
}
|
||||
}
|
||||
let start = rate.samples_at_frame(4000, sr);
|
||||
let end = rate.samples_at_frame(4000 + 20 * f, sr);
|
||||
let want: Vec<_> = (0..20).map(|i| rate.samples_at_frame(4000 + i * f, sr)).collect();
|
||||
assert_eq!(g.lines(start, end, sr, &map, rate, 100), want, "{rate:?} {f} frames");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids.
|
||||
#[test]
|
||||
fn named_frame_grids_land_on_exact_subdivisions() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames));
|
||||
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
|
||||
for grid in grids.clone() {
|
||||
for index in 0..200 {
|
||||
let line = grid.abs_line(index, sr, rate);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}");
|
||||
assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-frame second grids follow labelled second boundaries in both timeline directions.
|
||||
#[test]
|
||||
fn drop_frame_second_grids_follow_timecode_labels() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grid = GridValue::Timecode(TimecodeValue::Second);
|
||||
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
for seconds in [0, 59, 60, 61, 600, 601, 3600] {
|
||||
let frame = GridValue::drop_second_frame(seconds, rate);
|
||||
let line = rate.samples_at_frame(frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}");
|
||||
assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}");
|
||||
for offset in -2..=rate.nominal() + 2 {
|
||||
let position = rate.samples_at_frame(frame.saturating_add(offset), sr);
|
||||
let before = grid.line_at_or_before(position, sr, &map, rate);
|
||||
assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
}
|
||||
}
|
||||
let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr);
|
||||
assert_eq!(grid.snap(labelled, sr, &map, rate), labelled);
|
||||
}
|
||||
|
||||
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] {
|
||||
let boundary = rate.samples_at_frame(minute_frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary");
|
||||
assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap");
|
||||
assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary");
|
||||
assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_capped() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
assert_eq!(GridValue::Samples(1).lines(0, 1_000_000, sr, &map, rate, 50).len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triplets_and_dots() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
let t = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: true };
|
||||
assert_eq!(t.step_samples(0, sr, &map, rate), 16_000);
|
||||
let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false };
|
||||
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
|
||||
}
|
||||
|
||||
/// Stepping follows each menu ladder and stops at its endpoints.
|
||||
#[test]
|
||||
fn values_step_to_the_next_larger_or_smaller() {
|
||||
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
|
||||
assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false));
|
||||
assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true));
|
||||
assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false));
|
||||
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
|
||||
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0));
|
||||
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame));
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot));
|
||||
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
|
||||
assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2));
|
||||
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_at_extreme_position_does_not_overflow() {
|
||||
let sr = SampleRate::new(8000).unwrap();
|
||||
let map = TempoMap::new(1000.0, 4, 4).unwrap();
|
||||
let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30);
|
||||
}
|
||||
}
|
||||
+2
-153
@@ -1,155 +1,4 @@
|
||||
//! SoundCraft timebases.
|
||||
//!
|
||||
//! Every position in a session is an absolute sample index (`i64`) at the session sample rate.
|
||||
//! This crate converts those positions to and from the five Pro Tools-style timebases
|
||||
//! (Bars|Beats, Min:Secs, Timecode, Feet+Frames, Samples) through a [`TempoMap`], and computes
|
||||
//! grid lines and snapping for the Grid / Nudge values.
|
||||
//! Compatibility facade for SoundCraft's sample-index time API in shared `craft-time`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod format;
|
||||
mod grid;
|
||||
mod tempo;
|
||||
mod timecode;
|
||||
|
||||
pub use format::{TimeFormat, format_length, format_position, parse_position};
|
||||
pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue};
|
||||
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
|
||||
pub use timecode::{FrameRate, Timecode};
|
||||
|
||||
/// A position or length in samples.
|
||||
pub type Samples = i64;
|
||||
|
||||
/// Largest position accepted from parameters and files: 2^40 samples (about 265 days at 48 kHz).
|
||||
/// Clamping there keeps every later addition and subtraction on positions far away from overflow.
|
||||
pub const MAX_POSITION: Samples = 1 << 40;
|
||||
|
||||
/// Errors from parsing or converting time values.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum TimeError {
|
||||
#[error("cannot parse `{0}` as a {1} value")]
|
||||
Parse(String, &'static str),
|
||||
#[error("invalid sample rate {0}")]
|
||||
SampleRate(u32),
|
||||
#[error("invalid tempo {0}")]
|
||||
Tempo(f64),
|
||||
#[error("invalid meter {0}/{1}")]
|
||||
Meter(u32, u32),
|
||||
}
|
||||
|
||||
/// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SampleRate(u32);
|
||||
|
||||
impl SampleRate {
|
||||
pub const HZ_44100: SampleRate = SampleRate(44_100);
|
||||
pub const HZ_48000: SampleRate = SampleRate(48_000);
|
||||
pub const COMMON: [u32; 8] = [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000];
|
||||
|
||||
pub fn new(hz: u32) -> Result<Self, TimeError> {
|
||||
if (8_000..=768_000).contains(&hz) { Ok(SampleRate(hz)) } else { Err(TimeError::SampleRate(hz)) }
|
||||
}
|
||||
pub fn hz(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
pub fn as_f64(self) -> f64 {
|
||||
f64::from(self.0)
|
||||
}
|
||||
/// Samples → seconds.
|
||||
pub fn seconds(self, s: Samples) -> f64 {
|
||||
s as f64 / self.as_f64()
|
||||
}
|
||||
/// Seconds → samples, rounded to nearest and saturated.
|
||||
pub fn samples(self, seconds: f64) -> Samples {
|
||||
to_samples(seconds * self.as_f64())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SampleRate {
|
||||
fn default() -> Self {
|
||||
SampleRate::HZ_48000
|
||||
}
|
||||
}
|
||||
|
||||
/// Round a float sample count to `i64`, mapping NaN to 0 and saturating infinities.
|
||||
pub fn to_samples(v: f64) -> Samples {
|
||||
if v.is_nan() {
|
||||
0
|
||||
} else if v >= i64::MAX as f64 {
|
||||
i64::MAX
|
||||
} else if v <= i64::MIN as f64 {
|
||||
i64::MIN
|
||||
} else {
|
||||
v.round() as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// A half-open sample range `[start, end)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Range {
|
||||
pub start: Samples,
|
||||
pub end: Samples,
|
||||
}
|
||||
|
||||
impl Range {
|
||||
pub fn new(a: Samples, b: Samples) -> Self {
|
||||
if a <= b { Range { start: a, end: b } } else { Range { start: b, end: a } }
|
||||
}
|
||||
pub fn point(at: Samples) -> Self {
|
||||
Range { start: at, end: at }
|
||||
}
|
||||
pub fn len(&self) -> Samples {
|
||||
self.end.saturating_sub(self.start)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.end <= self.start
|
||||
}
|
||||
pub fn contains(&self, s: Samples) -> bool {
|
||||
s >= self.start && s < self.end
|
||||
}
|
||||
pub fn overlaps(&self, o: &Range) -> bool {
|
||||
self.start < o.end && o.start < self.end
|
||||
}
|
||||
pub fn intersect(&self, o: &Range) -> Option<Range> {
|
||||
let s = self.start.max(o.start);
|
||||
let e = self.end.min(o.end);
|
||||
(s < e).then_some(Range { start: s, end: e })
|
||||
}
|
||||
pub fn shifted(&self, by: Samples) -> Range {
|
||||
Range { start: self.start.saturating_add(by), end: self.end.saturating_add(by) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sample_rate_bounds() {
|
||||
assert!(SampleRate::new(48_000).is_ok());
|
||||
assert!(SampleRate::new(0).is_err());
|
||||
assert!(SampleRate::new(10_000_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_round_trip() {
|
||||
let sr = SampleRate::HZ_44100;
|
||||
assert_eq!(sr.samples(1.0), 44_100);
|
||||
assert!((sr.seconds(22_050) - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_samples_is_total() {
|
||||
assert_eq!(to_samples(f64::NAN), 0);
|
||||
assert_eq!(to_samples(f64::INFINITY), i64::MAX);
|
||||
assert_eq!(to_samples(f64::NEG_INFINITY), i64::MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_ops() {
|
||||
let a = Range::new(10, 0);
|
||||
assert_eq!(a, Range { start: 0, end: 10 });
|
||||
assert_eq!(a.intersect(&Range::new(5, 20)), Some(Range::new(5, 10)));
|
||||
assert_eq!(a.intersect(&Range::new(10, 20)), None);
|
||||
assert!(a.contains(9) && !a.contains(10));
|
||||
}
|
||||
}
|
||||
pub use craft_time::soundcraft::*;
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
//! Tempo and meter maps: the conversion between musical time (ticks) and samples.
|
||||
|
||||
use crate::{MAX_POSITION, SampleRate, Samples, TimeError, to_samples};
|
||||
|
||||
/// Ticks per quarter note (Pro Tools' resolution).
|
||||
pub const TICKS_PER_QUARTER: i64 = 960;
|
||||
|
||||
/// A tempo change at a tick position. `bpm` is quarter notes per minute.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TempoEvent {
|
||||
pub tick: i64,
|
||||
pub bpm: f64,
|
||||
}
|
||||
|
||||
/// A meter (time signature) change at a tick position, which must fall on a bar line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MeterEvent {
|
||||
pub tick: i64,
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
}
|
||||
|
||||
impl MeterEvent {
|
||||
pub fn ticks_per_beat(&self) -> i64 {
|
||||
let d = i64::from(self.denominator.max(1));
|
||||
(TICKS_PER_QUARTER * 4 / d).max(1)
|
||||
}
|
||||
pub fn ticks_per_bar(&self) -> i64 {
|
||||
self.ticks_per_beat().saturating_mul(i64::from(self.numerator.max(1)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A musical position: 1-based bar and beat plus ticks within the beat.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BarBeat {
|
||||
pub bar: i64,
|
||||
pub beat: i64,
|
||||
pub tick: i64,
|
||||
}
|
||||
|
||||
/// Tempo and meter changes for a session. Always has at least one of each, at tick 0.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "TempoMapData")]
|
||||
pub struct TempoMap {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
/// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm,
|
||||
/// summed over the earlier segments). Lets tick/sample conversions binary-search the segment
|
||||
/// instead of walking every earlier tempo change, so converting many positions against a large
|
||||
/// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes.
|
||||
#[serde(skip)]
|
||||
elapsed: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The serialized form of [`TempoMap`], without the derived lookup table.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TempoMapData {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
}
|
||||
|
||||
impl From<TempoMapData> for TempoMap {
|
||||
fn from(d: TempoMapData) -> Self {
|
||||
TempoMap::from_parts(d.tempos, d.meters)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TempoMap {
|
||||
fn default() -> Self {
|
||||
TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }])
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_bpm(bpm: f64) -> Result<f64, TimeError> {
|
||||
if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) }
|
||||
}
|
||||
|
||||
fn valid_meter(numerator: u32, denominator: u32) -> bool {
|
||||
(1..=64).contains(&numerator) && [1, 2, 4, 8, 16, 32, 64].contains(&denominator)
|
||||
}
|
||||
|
||||
impl TempoMap {
|
||||
fn from_parts(tempos: Vec<TempoEvent>, meters: Vec<MeterEvent>) -> Self {
|
||||
let mut m = TempoMap { tempos, meters, elapsed: Vec::new() };
|
||||
m.reindex();
|
||||
m
|
||||
}
|
||||
|
||||
/// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo.
|
||||
fn reindex(&mut self) {
|
||||
let mut elapsed = Vec::with_capacity(self.tempos.len());
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for (i, e) in self.tempos.iter().enumerate() {
|
||||
if i > 0 {
|
||||
// Saturating: a file's ticks are indexed before `sanitize` orders them.
|
||||
acc += e.tick.saturating_sub(seg_tick) as f64 / bpm;
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
elapsed.push(acc);
|
||||
}
|
||||
self.elapsed = elapsed;
|
||||
}
|
||||
|
||||
/// Samples per quarter-note minute (the unit of `elapsed`) at `sr`.
|
||||
fn samples_per_elapsed(sr: SampleRate) -> f64 {
|
||||
sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64
|
||||
}
|
||||
|
||||
/// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for
|
||||
/// the first), bpm and elapsed time at its start.
|
||||
fn segment(&self, i: usize) -> (i64, f64, f64) {
|
||||
let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm);
|
||||
let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) };
|
||||
(tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_tempo(0, bpm)?;
|
||||
m.set_meter(0, numerator, denominator)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub fn tempos(&self) -> &[TempoEvent] {
|
||||
&self.tempos
|
||||
}
|
||||
pub fn meters(&self) -> &[MeterEvent] {
|
||||
&self.meters
|
||||
}
|
||||
|
||||
/// Repair a map read from a file: only valid tempos and meters within `0..=MAX_POSITION` ticks, in
|
||||
/// tick order, with one of each at tick 0.
|
||||
pub fn sanitize(&mut self) {
|
||||
self.tempos.retain(|e| (0..=MAX_POSITION).contains(&e.tick) && valid_bpm(e.bpm).is_ok());
|
||||
self.tempos.sort_by_key(|e| e.tick);
|
||||
self.tempos.dedup_by_key(|e| e.tick);
|
||||
if self.tempos.first().is_none_or(|e| e.tick > 0) {
|
||||
let bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
self.tempos.insert(0, TempoEvent { tick: 0, bpm });
|
||||
}
|
||||
self.meters.retain(|m| (0..=MAX_POSITION).contains(&m.tick) && valid_meter(m.numerator, m.denominator));
|
||||
self.meters.sort_by_key(|m| m.tick);
|
||||
self.meters.dedup_by_key(|m| m.tick);
|
||||
if self.meters.first().is_none_or(|m| m.tick > 0) {
|
||||
let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator));
|
||||
self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator });
|
||||
}
|
||||
self.reindex();
|
||||
}
|
||||
|
||||
/// Insert or replace the tempo at `tick`.
|
||||
pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> {
|
||||
let bpm = valid_bpm(bpm)?;
|
||||
let tick = tick.max(0);
|
||||
match self.tempos.binary_search_by(|e| e.tick.cmp(&tick)) {
|
||||
Ok(i) => {
|
||||
if let Some(e) = self.tempos.get_mut(i) {
|
||||
e.bpm = bpm;
|
||||
}
|
||||
}
|
||||
Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }),
|
||||
}
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a tempo event (the one at tick 0 is kept).
|
||||
pub fn remove_tempo(&mut self, tick: i64) -> bool {
|
||||
if tick == 0 {
|
||||
return false;
|
||||
}
|
||||
let before = self.tempos.len();
|
||||
self.tempos.retain(|e| e.tick != tick);
|
||||
self.reindex();
|
||||
before != self.tempos.len()
|
||||
}
|
||||
|
||||
/// Insert or replace the meter at `tick` (snapped to the bar line at or before it).
|
||||
/// Later meter changes keep their bar numbers, so they stay on bar lines.
|
||||
pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> {
|
||||
if !valid_meter(numerator, denominator) {
|
||||
return Err(TimeError::Meter(numerator, denominator));
|
||||
}
|
||||
let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) };
|
||||
let later = self.take_meters_after(tick);
|
||||
self.put_meter(MeterEvent { tick, numerator, denominator });
|
||||
self.restore_meters_at_bars(later);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers.
|
||||
pub fn remove_meter(&mut self, tick: i64) -> bool {
|
||||
if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) {
|
||||
return false;
|
||||
}
|
||||
let later = self.take_meters_after(tick);
|
||||
self.meters.retain(|e| e.tick != tick);
|
||||
self.restore_meters_at_bars(later);
|
||||
true
|
||||
}
|
||||
|
||||
fn put_meter(&mut self, ev: MeterEvent) {
|
||||
match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) {
|
||||
Ok(i) => {
|
||||
if let Some(e) = self.meters.get_mut(i) {
|
||||
*e = ev;
|
||||
}
|
||||
}
|
||||
Err(i) => self.meters.insert(i, ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
|
||||
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
|
||||
/// walk per meter, so a session with many meter changes edits in linear time.
|
||||
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
|
||||
let mut later = Vec::new();
|
||||
let (mut bar, mut seg_tick) = (1i64, 0i64);
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let tpb = m.ticks_per_bar();
|
||||
if m.tick > tick {
|
||||
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
|
||||
}
|
||||
if let Some(n) = self.meters.get(i + 1) {
|
||||
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
|
||||
seg_tick = n.tick;
|
||||
}
|
||||
}
|
||||
self.meters.retain(|e| e.tick <= tick);
|
||||
later
|
||||
}
|
||||
|
||||
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
|
||||
fn last_segment(&self) -> (i64, i64) {
|
||||
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
if let Some(n) = self.meters.get(i + 1) {
|
||||
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
|
||||
cur_tick = n.tick;
|
||||
}
|
||||
}
|
||||
(cur_bar, cur_tick)
|
||||
}
|
||||
|
||||
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
|
||||
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
|
||||
/// before it (meters off their bar lines) takes the full walk.
|
||||
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
|
||||
let (mut cur_bar, mut cur_tick) = self.last_segment();
|
||||
for (bar, ev) in later {
|
||||
let tick = if bar >= cur_bar {
|
||||
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
|
||||
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
|
||||
} else {
|
||||
self.bar_start_tick(bar)
|
||||
};
|
||||
self.put_meter(MeterEvent { tick, ..ev });
|
||||
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
|
||||
}
|
||||
}
|
||||
|
||||
/// Tempo in effect at `tick`.
|
||||
pub fn tempo_at_tick(&self, tick: i64) -> f64 {
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in &self.tempos {
|
||||
if e.tick <= tick {
|
||||
bpm = e.bpm;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bpm
|
||||
}
|
||||
|
||||
/// Meter in effect at `tick`.
|
||||
pub fn meter_at_tick(&self, tick: i64) -> MeterEvent {
|
||||
let mut m = self.meters.first().copied().unwrap_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 });
|
||||
for e in &self.meters {
|
||||
if e.tick <= tick {
|
||||
m = *e;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn samples_per_tick(bpm: f64, sr: SampleRate) -> f64 {
|
||||
sr.as_f64() * 60.0 / (bpm * TICKS_PER_QUARTER as f64)
|
||||
}
|
||||
|
||||
/// Ticks → samples (exact for constant-tempo segments).
|
||||
pub fn tick_to_samples(&self, tick: i64, sr: SampleRate) -> Samples {
|
||||
to_samples(self.tick_to_samples_f(tick, sr))
|
||||
}
|
||||
|
||||
fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 {
|
||||
// Tempo events after the first that start before `tick` (the segments it has passed).
|
||||
let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick));
|
||||
let (seg_tick, bpm, elapsed) = self.segment(passed);
|
||||
elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
/// Samples → ticks (fractional).
|
||||
pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 {
|
||||
let s = s as f64;
|
||||
let scale = Self::samples_per_elapsed(sr);
|
||||
// Tempo events after the first that start at or before `s`.
|
||||
let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s));
|
||||
let (seg_tick, bpm, elapsed) = self.segment(passed);
|
||||
seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 {
|
||||
to_samples(self.samples_to_ticks_f(s, sr))
|
||||
}
|
||||
|
||||
/// Tick of the first beat of `bar` (1-based; bars before 1 extend the first meter backwards).
|
||||
pub fn bar_start_tick(&self, bar: i64) -> i64 {
|
||||
let mut cur_bar = 1i64;
|
||||
let mut cur_tick = 0i64;
|
||||
let first = self.meter_at_tick(0);
|
||||
if bar < 1 {
|
||||
return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar());
|
||||
}
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
|
||||
let tpb = m.ticks_per_bar();
|
||||
let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb));
|
||||
match bars_in_seg {
|
||||
Some(n) if bar >= cur_bar.saturating_add(n) => {
|
||||
cur_bar = cur_bar.saturating_add(n);
|
||||
cur_tick = next_tick.unwrap_or(cur_tick);
|
||||
}
|
||||
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
|
||||
}
|
||||
}
|
||||
cur_tick
|
||||
}
|
||||
|
||||
/// Bar|beat|tick at `tick`.
|
||||
pub fn bar_beat_at_tick(&self, tick: i64) -> BarBeat {
|
||||
let first = self.meter_at_tick(0);
|
||||
if tick < 0 {
|
||||
let tpb = first.ticks_per_bar();
|
||||
let abs = tick.unsigned_abs();
|
||||
let tpb_u = tpb.unsigned_abs();
|
||||
let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u));
|
||||
let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX);
|
||||
// Offset of `tick` inside its bar, without negating `i64::MIN`.
|
||||
let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64;
|
||||
let beat_t = first.ticks_per_beat();
|
||||
return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t };
|
||||
}
|
||||
let mut bar = 1i64;
|
||||
let mut seg_tick = 0i64;
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let tpb = m.ticks_per_bar();
|
||||
if let Some(n) = self.meters.get(i + 1)
|
||||
&& tick >= n.tick
|
||||
{
|
||||
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
|
||||
seg_tick = n.tick;
|
||||
continue;
|
||||
}
|
||||
let rel = tick - seg_tick;
|
||||
let bars = rel / tpb;
|
||||
let within = rel % tpb;
|
||||
let beat_t = m.ticks_per_beat();
|
||||
return BarBeat { bar: bar + bars, beat: within / beat_t + 1, tick: within % beat_t };
|
||||
}
|
||||
BarBeat { bar, beat: 1, tick: 0 }
|
||||
}
|
||||
|
||||
/// Bar|beat|tick → tick.
|
||||
pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 {
|
||||
let start = self.bar_start_tick(bb.bar);
|
||||
let m = self.meter_at_tick(start);
|
||||
start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
|
||||
}
|
||||
|
||||
pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat {
|
||||
self.bar_beat_at_tick(self.samples_to_ticks(s, sr))
|
||||
}
|
||||
|
||||
pub fn samples_at_bar_beat(&self, bb: BarBeat, sr: SampleRate) -> Samples {
|
||||
self.tick_to_samples(self.tick_at_bar_beat(bb), sr)
|
||||
}
|
||||
|
||||
/// Scale every tempo by `factor` (Tempo Operations › Scale).
|
||||
pub fn scale_tempos(&mut self, factor: f64) -> Result<(), TimeError> {
|
||||
let mut out = self.tempos.clone();
|
||||
for e in &mut out {
|
||||
e.bpm = valid_bpm(e.bpm * factor)?;
|
||||
}
|
||||
self.tempos = out;
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling division for a non-negative `num` and positive `den`, without overflow.
|
||||
fn ceil_div(num: i64, den: i64) -> i64 {
|
||||
num / den + i64::from(num % den != 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_map_is_120_4_4() {
|
||||
let m = TempoMap::default();
|
||||
let sr = SampleRate::HZ_48000;
|
||||
// One bar of 4/4 at 120 bpm = 2 seconds.
|
||||
assert_eq!(m.samples_at_bar_beat(BarBeat { bar: 2, beat: 1, tick: 0 }, sr), 96_000);
|
||||
assert_eq!(m.bar_beat_at(96_000, sr), BarBeat { bar: 2, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_near_max_tick_does_not_overflow() {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_meter(i64::MAX, 7, 8).unwrap();
|
||||
let bb = m.bar_beat_at_tick(i64::MAX);
|
||||
assert!(bb.bar > 1);
|
||||
assert!(m.bar_start_tick(bb.bar) > 0);
|
||||
assert!(m.bar_start_tick(i64::MAX) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bar_saturates_instead_of_overflowing() {
|
||||
let m = TempoMap::default();
|
||||
assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN);
|
||||
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_beat_is_clamped_without_overflow() {
|
||||
let m = TempoMap::default();
|
||||
assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_beat_at_min_tick_does_not_overflow() {
|
||||
let m = TempoMap::default();
|
||||
let bb = m.bar_beat_at_tick(i64::MIN);
|
||||
assert!(bb.bar < 1);
|
||||
assert!(bb.beat >= 1 && bb.tick >= 0);
|
||||
assert_eq!(m.bar_beat_at_tick(-1).bar, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_change_is_integrated() {
|
||||
let mut m = TempoMap::default();
|
||||
let sr = SampleRate::HZ_48000;
|
||||
m.set_tempo(TICKS_PER_QUARTER * 4, 60.0).unwrap();
|
||||
// Bar 1 at 120 = 96000 samples; bar 2 at 60 = 192000 samples.
|
||||
assert_eq!(m.tick_to_samples(TICKS_PER_QUARTER * 8, sr), 96_000 + 192_000);
|
||||
assert_eq!(m.samples_to_ticks(96_000 + 192_000, sr), TICKS_PER_QUARTER * 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_change_counts_bars() {
|
||||
let mut m = TempoMap::default();
|
||||
// 2 bars of 4/4 then 3/4.
|
||||
m.set_meter(TICKS_PER_QUARTER * 8, 3, 4).unwrap();
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 8), BarBeat { bar: 3, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 11), BarBeat { bar: 4, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_start_tick(4), TICKS_PER_QUARTER * 11);
|
||||
let bb = BarBeat { bar: 4, beat: 2, tick: 10 };
|
||||
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
|
||||
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
|
||||
// minutes to edit here, and even to build through `set_meter`.
|
||||
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
|
||||
let mut tick = 0;
|
||||
for bar in 1..200_000i64 {
|
||||
let numerator = 3 + (bar % 2) as u32;
|
||||
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
|
||||
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
|
||||
}
|
||||
let sample = [1usize, 1_000, 199_998];
|
||||
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
|
||||
assert_eq!(bars(&m), [2, 1_001, 199_999]);
|
||||
m.set_meter(0, 7, 8).unwrap();
|
||||
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
|
||||
assert_eq!(m.meters.len(), 200_000);
|
||||
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
|
||||
assert!(m.remove_meter(m.meters[1].tick));
|
||||
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
|
||||
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
|
||||
// bar 2 of the new meter, then two bars of their own later.
|
||||
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
|
||||
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
|
||||
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
|
||||
off.set_meter(0, 2, 4).unwrap();
|
||||
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
|
||||
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eighth_note_meters() {
|
||||
let m = TempoMap::new(120.0, 6, 8).unwrap();
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 3), BarBeat { bar: 2, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_what_the_setters_accept() {
|
||||
let tempo = |tick, bpm| TempoEvent { tick, bpm };
|
||||
let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator };
|
||||
let mut m = TempoMap::from_parts(
|
||||
vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
|
||||
vec![meter(0, 0, 4), meter(3840, 3, 4)],
|
||||
);
|
||||
m.sanitize();
|
||||
assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]);
|
||||
assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]);
|
||||
let mut empty = TempoMap::from_parts(Vec::new(), Vec::new());
|
||||
empty.sanitize();
|
||||
assert_eq!(empty, TempoMap::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_events_beyond_the_position_range() {
|
||||
let mut m = TempoMap::from_parts(
|
||||
vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
|
||||
vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
|
||||
);
|
||||
m.sanitize();
|
||||
assert_eq!(m, TempoMap::default());
|
||||
assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_values() {
|
||||
let mut m = TempoMap::default();
|
||||
assert!(m.set_tempo(0, f64::NAN).is_err());
|
||||
assert!(m.set_tempo(0, 0.0).is_err());
|
||||
assert!(m.set_meter(0, 0, 4).is_err());
|
||||
assert!(m.set_meter(0, 4, 3).is_err());
|
||||
assert!(!m.remove_tempo(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_positions() {
|
||||
let m = TempoMap::default();
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 });
|
||||
}
|
||||
|
||||
/// The segment walk the indexed conversions replaced, kept as the reference they must match.
|
||||
fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 {
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = m.tempos[0].bpm;
|
||||
for e in m.tempos.iter().skip(1) {
|
||||
if e.tick >= tick {
|
||||
break;
|
||||
}
|
||||
acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr);
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_conversions_match_the_segment_walk() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let mut m = TempoMap::default();
|
||||
for i in 1..200i64 {
|
||||
m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap();
|
||||
}
|
||||
assert!(m.remove_tempo(487 * 50));
|
||||
for tick in (-1000..200 * 487 + 5000).step_by(113) {
|
||||
let s = m.tick_to_samples(tick, sr);
|
||||
assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}");
|
||||
assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}");
|
||||
}
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(!json.contains("elapsed"));
|
||||
let back: TempoMap = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, m);
|
||||
assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_wild_ticks_is_indexed_without_overflow() {
|
||||
let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#;
|
||||
let mut m: TempoMap = serde_json::from_str(json).unwrap();
|
||||
m.sanitize();
|
||||
assert_eq!(m.tempos().first().map(|e| e.tick), Some(0));
|
||||
assert_eq!(m.elapsed.len(), m.tempos().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversions_against_a_large_tempo_map_stay_fast() {
|
||||
// 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks
|
||||
// the earlier segments, well under a second when it binary-searches them.
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let n = 200_000i64;
|
||||
let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect();
|
||||
let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]);
|
||||
m.sanitize();
|
||||
let start = std::time::Instant::now();
|
||||
let mut sum = 0i64;
|
||||
for i in 0..n {
|
||||
let s = m.tick_to_samples(i * 10 + 5, sr);
|
||||
sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr));
|
||||
}
|
||||
assert!(sum != 0);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
//! SMPTE timecode and Feet+Frames.
|
||||
|
||||
use crate::{SampleRate, Samples, TimeError};
|
||||
|
||||
/// Session timecode rates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FrameRate {
|
||||
Fps23_976,
|
||||
#[default]
|
||||
Fps24,
|
||||
Fps25,
|
||||
Fps29_97,
|
||||
Fps29_97Drop,
|
||||
Fps30,
|
||||
Fps47_952,
|
||||
Fps48,
|
||||
Fps50,
|
||||
Fps59_94,
|
||||
Fps59_94Drop,
|
||||
Fps60,
|
||||
Fps100,
|
||||
Fps119_88,
|
||||
Fps120,
|
||||
}
|
||||
|
||||
impl FrameRate {
|
||||
pub const ALL: [FrameRate; 15] = [
|
||||
FrameRate::Fps23_976,
|
||||
FrameRate::Fps24,
|
||||
FrameRate::Fps25,
|
||||
FrameRate::Fps29_97,
|
||||
FrameRate::Fps29_97Drop,
|
||||
FrameRate::Fps30,
|
||||
FrameRate::Fps47_952,
|
||||
FrameRate::Fps48,
|
||||
FrameRate::Fps50,
|
||||
FrameRate::Fps59_94,
|
||||
FrameRate::Fps59_94Drop,
|
||||
FrameRate::Fps60,
|
||||
FrameRate::Fps100,
|
||||
FrameRate::Fps119_88,
|
||||
FrameRate::Fps120,
|
||||
];
|
||||
|
||||
/// Real frames per second as a rational (num, den).
|
||||
pub fn rational(self) -> (i64, i64) {
|
||||
match self {
|
||||
FrameRate::Fps23_976 => (24_000, 1001),
|
||||
FrameRate::Fps24 => (24, 1),
|
||||
FrameRate::Fps25 => (25, 1),
|
||||
FrameRate::Fps29_97 | FrameRate::Fps29_97Drop => (30_000, 1001),
|
||||
FrameRate::Fps30 => (30, 1),
|
||||
FrameRate::Fps47_952 => (48_000, 1001),
|
||||
FrameRate::Fps48 => (48, 1),
|
||||
FrameRate::Fps50 => (50, 1),
|
||||
FrameRate::Fps59_94 | FrameRate::Fps59_94Drop => (60_000, 1001),
|
||||
FrameRate::Fps60 => (60, 1),
|
||||
FrameRate::Fps100 => (100, 1),
|
||||
FrameRate::Fps119_88 => (120_000, 1001),
|
||||
FrameRate::Fps120 => (120, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames per labelled second (the counting base).
|
||||
pub fn nominal(self) -> i64 {
|
||||
let (n, d) = self.rational();
|
||||
(n + d / 2) / d
|
||||
}
|
||||
|
||||
pub fn is_drop(self) -> bool {
|
||||
matches!(self, FrameRate::Fps29_97Drop | FrameRate::Fps59_94Drop)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FrameRate::Fps23_976 => "23.976",
|
||||
FrameRate::Fps24 => "24",
|
||||
FrameRate::Fps25 => "25",
|
||||
FrameRate::Fps29_97 => "29.97",
|
||||
FrameRate::Fps29_97Drop => "29.97 Drop",
|
||||
FrameRate::Fps30 => "30",
|
||||
FrameRate::Fps47_952 => "47.952",
|
||||
FrameRate::Fps48 => "48",
|
||||
FrameRate::Fps50 => "50",
|
||||
FrameRate::Fps59_94 => "59.94",
|
||||
FrameRate::Fps59_94Drop => "59.94 Drop",
|
||||
FrameRate::Fps60 => "60",
|
||||
FrameRate::Fps100 => "100",
|
||||
FrameRate::Fps119_88 => "119.88",
|
||||
FrameRate::Fps120 => "120",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole frame index at a sample position (floored).
|
||||
pub fn frames_at(self, s: Samples, sr: SampleRate) -> i64 {
|
||||
let (n, d) = self.rational();
|
||||
let num = i128::from(s) * i128::from(n);
|
||||
let den = i128::from(sr.hz()) * i128::from(d);
|
||||
if den == 0 {
|
||||
return 0;
|
||||
}
|
||||
i64::try_from(num.div_euclid(den)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Sample position of the start of frame `f`.
|
||||
pub fn samples_at_frame(self, f: i64, sr: SampleRate) -> Samples {
|
||||
let (n, d) = self.rational();
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
// Round half away from zero so negative frames mirror positive ones.
|
||||
let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
|
||||
let v = if f < 0 { -mag } else { mag };
|
||||
i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
/// Samples per frame (fractional).
|
||||
pub fn samples_per_frame(self, sr: SampleRate) -> f64 {
|
||||
let (n, d) = self.rational();
|
||||
sr.as_f64() * d as f64 / n as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// A timecode label.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Timecode {
|
||||
pub negative: bool,
|
||||
pub hours: i64,
|
||||
pub minutes: i64,
|
||||
pub seconds: i64,
|
||||
pub frames: i64,
|
||||
}
|
||||
|
||||
impl Timecode {
|
||||
/// Label for an absolute frame count.
|
||||
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
|
||||
let negative = frames < 0;
|
||||
// Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before
|
||||
// taking the magnitude so i64::MIN is represented exactly too.
|
||||
let mut f = i128::from(frames).abs();
|
||||
let base = i128::from(rate.nominal().max(1));
|
||||
if rate.is_drop() {
|
||||
// Drop 2 (or 4) labels each minute except every tenth.
|
||||
let drop = if base >= 60 { 4 } else { 2 };
|
||||
let per_10min = base * 600 - drop * 9;
|
||||
let per_min = base * 60 - drop;
|
||||
let tens = f / per_10min;
|
||||
let rem = f % per_10min;
|
||||
let extra = if rem > drop { drop * 9 * tens + drop * ((rem - drop) / per_min) } else { drop * 9 * tens };
|
||||
f += extra;
|
||||
}
|
||||
let frames_l = f % base;
|
||||
let total_secs = f / base;
|
||||
Timecode {
|
||||
negative,
|
||||
hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX),
|
||||
minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0),
|
||||
seconds: i64::try_from(total_secs % 60).unwrap_or(0),
|
||||
frames: i64::try_from(frames_l).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute frame count of this label.
|
||||
pub fn to_frames(&self, rate: FrameRate) -> i64 {
|
||||
let base = rate.nominal().max(1);
|
||||
let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes);
|
||||
let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames);
|
||||
if rate.is_drop() {
|
||||
let drop = if base >= 60 { 4 } else { 2 };
|
||||
f -= drop * (total_min - total_min / 10);
|
||||
}
|
||||
let f = if self.negative { -f } else { f };
|
||||
i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode {
|
||||
Timecode::from_frames(rate.frames_at(s, sr), rate)
|
||||
}
|
||||
|
||||
pub fn to_samples(&self, rate: FrameRate, sr: SampleRate) -> Samples {
|
||||
rate.samples_at_frame(self.to_frames(rate), sr)
|
||||
}
|
||||
|
||||
pub fn format(&self, rate: FrameRate) -> String {
|
||||
let sep = if rate.is_drop() { ';' } else { ':' };
|
||||
let sign = if self.negative { "-" } else { "" };
|
||||
format!("{sign}{:02}:{:02}:{:02}{sep}{:02}", self.hours, self.minutes, self.seconds, self.frames)
|
||||
}
|
||||
|
||||
/// Parse `HH:MM:SS:FF` (also `;` / `.` separators and fewer fields, right-aligned).
|
||||
pub fn parse(text: &str) -> Result<Timecode, TimeError> {
|
||||
let t = text.trim();
|
||||
let (negative, t) = match t.strip_prefix('-') {
|
||||
Some(r) => (true, r),
|
||||
None => (false, t),
|
||||
};
|
||||
let parts: Vec<&str> = t.split([':', ';', '.']).collect();
|
||||
if parts.is_empty() || parts.len() > 4 {
|
||||
return Err(TimeError::Parse(text.to_string(), "timecode"));
|
||||
}
|
||||
let mut nums = [0i64; 4];
|
||||
let offset = 4 - parts.len();
|
||||
for (i, p) in parts.iter().enumerate() {
|
||||
let v: i64 = p.trim().parse().map_err(|_| TimeError::Parse(text.to_string(), "timecode"))?;
|
||||
if v < 0 {
|
||||
return Err(TimeError::Parse(text.to_string(), "timecode"));
|
||||
}
|
||||
if let Some(slot) = nums.get_mut(offset + i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok(Timecode { negative, hours: nums[0], minutes: nums[1], seconds: nums[2], frames: nums[3] })
|
||||
}
|
||||
}
|
||||
|
||||
/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0.
|
||||
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) {
|
||||
let f = rate.frames_at(s, sr).saturating_add(start);
|
||||
(f.div_euclid(16), f.rem_euclid(16))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_drop_round_trip() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let tc = Timecode::from_samples(48_000 * 3661, FrameRate::Fps25, sr);
|
||||
assert_eq!(tc.format(FrameRate::Fps25), "01:01:01:00");
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_timecode_rounds_symmetrically() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let tc = Timecode::parse("-00:00:01:00").unwrap();
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000);
|
||||
for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] {
|
||||
for f in [1i64, 7, 25, 1799, 107_892] {
|
||||
assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_labels() {
|
||||
let r = FrameRate::Fps29_97Drop;
|
||||
// Frame 1800 is the first frame of minute 1 → labelled 00:01:00;02.
|
||||
assert_eq!(Timecode::from_frames(1800, r).format(r), "00:01:00;02");
|
||||
assert_eq!(Timecode::from_frames(1799, r).format(r), "00:00:59;29");
|
||||
// Minute 10 keeps its labels.
|
||||
assert_eq!(Timecode::from_frames(17_982, r).format(r), "00:10:00;00");
|
||||
for f in [0, 1, 1799, 1800, 1801, 17_981, 17_982, 107_892, 500_000] {
|
||||
assert_eq!(Timecode::from_frames(f, r).to_frames(r), f, "frame {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_timecode_saturates_instead_of_overflowing() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] {
|
||||
let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap();
|
||||
assert_eq!(tc.to_samples(r, sr), i64::MAX);
|
||||
let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap();
|
||||
assert_eq!(tc.to_samples(r, sr), i64::MIN);
|
||||
}
|
||||
let tc = Timecode::parse("01:02:03:04").unwrap();
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_frame_counts_round_trip_at_every_rate() {
|
||||
for rate in FrameRate::ALL {
|
||||
for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] {
|
||||
let tc = Timecode::from_frames(frames, rate);
|
||||
assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}");
|
||||
assert_eq!(tc.negative, frames < 0);
|
||||
assert!((0..60).contains(&tc.minutes));
|
||||
assert!((0..60).contains(&tc.seconds));
|
||||
assert!((0..rate.nominal()).contains(&tc.frames));
|
||||
assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_drop_frame_labels_do_not_overflow() {
|
||||
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variants() {
|
||||
let tc = Timecode::parse("01:02:03:04").unwrap();
|
||||
assert_eq!((tc.hours, tc.minutes, tc.seconds, tc.frames), (1, 2, 3, 4));
|
||||
let tc = Timecode::parse("3:04").unwrap();
|
||||
assert_eq!((tc.seconds, tc.frames), (3, 4));
|
||||
assert!(Timecode::parse("a:b").is_err());
|
||||
assert!(Timecode::parse("1:2:3:4:5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feet_and_frames() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user