Compare commits

..
80 changed files with 415 additions and 3511 deletions
-1
View File
@@ -41,4 +41,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+5 -35
View File
@@ -1422,19 +1422,6 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools 0.15.0",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1488,26 +1475,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -4407,7 +4374,6 @@ dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"log",
"same-file",
"serde",
"serde_json",
@@ -4523,7 +4489,6 @@ dependencies = [
"craft-ui",
"craft-update",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
@@ -6544,6 +6509,11 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
-2
View File
@@ -60,8 +60,6 @@ craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-l
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
egui = { version = "0.37" }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
web-time = "1.1"
-1
View File
@@ -25,7 +25,6 @@ soundcraft-dsp.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
clap.workspace = true
same-file.workspace = true
+1 -17
View File
@@ -15,7 +15,6 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
let source_rate = buf.sample_rate;
if let Some(rate) = rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
@@ -31,28 +30,13 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else {
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
};
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
// samples, so it moves to the output rate.
let bwf = info.bwf.map(|mut b| {
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
b
});
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf };
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
Ok(())
}
/// The sample position `pos` at `from` Hz as the nearest sample at `to` Hz.
fn at_rate(pos: u64, from: u32, to: u32) -> u64 {
if from == 0 || from == to {
return pos;
}
let scaled = (u128::from(pos) * u128::from(to) + u128::from(from) / 2) / u128::from(from);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
/// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target`
/// (the input, for --in-place) holds either its old or its new contents. The new file is removed when
/// writing or renaming fails.
-1
View File
@@ -11,7 +11,6 @@ mod catalog;
mod convert;
mod error;
mod info;
pub mod logger;
mod mcp;
mod session;
-49
View File
@@ -1,49 +0,0 @@
//! The command line's logger. Without one, every `log::warn!` and `log::error!` in the workspace
//! was dropped: a plugin that failed to load just vanished from `engine.clap_plugins`, and nothing
//! said why. Warnings and errors now go to standard error as `warning: <message>` and
//! `error: <message>`, like the CLI's own errors; standard output keeps only the results.
//! `RUST_LOG` stays a desktop app variable (docs/cli.md), so the level is fixed.
use std::io::Write;
struct StderrLogger;
impl log::Log for StderrLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Warn
}
fn log(&self, record: &log::Record<'_>) {
if self.enabled(record.metadata()) {
// A closed standard error is not worth failing a command over.
let _ = std::io::stderr().write_all(line(record.level(), &record.args().to_string()).as_bytes());
}
}
fn flush(&self) {}
}
/// One record as the CLI prints it.
fn line(level: log::Level, message: &str) -> String {
let label = if level == log::Level::Error { "error" } else { "warning" };
format!("{label}: {}\n", message.strip_suffix('\n').unwrap_or(message))
}
/// Installs the logger. Once per process: `main` calls it, not `run`, which tests call many times.
pub fn install() {
static LOGGER: StderrLogger = StderrLogger;
if log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Warn);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_print_like_the_cli_errors() {
assert_eq!(line(log::Level::Warn, "cannot load CLAP plugin x.clap: bad file\n"), "warning: cannot load CLAP plugin x.clap: bad file\n");
assert_eq!(line(log::Level::Error, "panic: boom"), "error: panic: boom\n");
}
}
-1
View File
@@ -3,7 +3,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
soundcraft_cli::logger::install();
// `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed.
let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
// The subcommand's engine and its plugin instances are gone by now. This is process teardown,
+1 -29
View File
@@ -5,7 +5,7 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use soundcraft_audio_io::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat};
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
@@ -93,31 +93,3 @@ fn convert_dithers_when_reducing_depth_unless_told_not_to() {
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_keeps_the_bwf_time_stamp() {
let dir = tmpdir("bext");
let src = dir.join("take.wav");
// 01:00:00:00 at 48 kHz.
let bwf = BwfInfo {
description: "take 3".into(),
originator: "recorder".into(),
origination_date: "2026-11-03".into(),
origination_time: "10:00:00".into(),
time_reference: 172_800_000,
..Default::default()
};
let buf = AudioBuffer::new(48_000, 1, 4800);
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: Some(bwf.clone()) };
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let bext = |p: &Path| soundcraft_audio_io::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
let out = dir.join("16.wav");
convert(&src, &out, &["--bit-depth", "16"]);
assert_eq!(bext(&out), Some(bwf.clone()));
// Resampled, the stamp still points at 01:00:00:00: 158,760,000 samples at 44.1 kHz.
let out = dir.join("44k.wav");
convert(&src, &out, &["--rate", "44100"]);
assert_eq!(bext(&out), Some(BwfInfo { time_reference: 158_760_000, ..bwf }));
let _ = std::fs::remove_dir_all(&dir);
}
-19
View File
@@ -88,25 +88,6 @@ fn assert_refused(got: &Invoke, port: u16) {
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
}
// ---- logging ----
/// Warnings SoundCraft logs (a plugin that fails to load, a backup it can't write) go to standard
/// error. Without a logger they were dropped.
#[test]
fn logged_warnings_reach_standard_error() {
let dir = sandbox("log");
let first = invoke(&dir, &["run", "--save", "s.scraft"]);
assert_eq!(first.code, 0, "{}", first.stderr);
// A file where the backups folder should go: the next save that changes the session can't
// back the old one up.
std::fs::write(dir.join("Backups"), b"not a folder").unwrap();
let again = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", r#"track.new={"count":1}"#, "--save", "s.scraft", "--in-place"]);
assert_eq!(again.code, 0, "{}", again.stderr);
assert!(again.stderr.contains("warning: no backup of"), "{}", again.stderr);
assert!(!again.stdout.contains("no backup"), "results only on standard output: {}", again.stdout);
let _ = std::fs::remove_dir_all(&dir);
}
// ---- unknown flags ----
#[test]
-1
View File
@@ -27,7 +27,6 @@ use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_p
use clap_sys::ext::params::{
CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params,
};
use clap_sys::ext::render::{CLAP_EXT_RENDER, CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state};
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR};
+2 -15
View File
@@ -7,7 +7,6 @@ use super::{
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
};
use super::{CLAP_EXT_RENDER, clap_plugin_render};
// ---- loaded binaries -----------------------------------------------------------------------
@@ -43,16 +42,6 @@ fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
L.get_or_init(|| Mutex::new(HashMap::new()))
}
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
/// source.
fn with_reason(e: &libloading::Error) -> String {
match std::error::Error::source(e) {
Some(why) => format!("{e}: {why}"),
None => e.to_string(),
}
}
impl Bundle {
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
@@ -70,12 +59,12 @@ impl Bundle {
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?;
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
Ok(sym) => *sym,
Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))),
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
};
if entry.is_null() {
return Err(err("null clap_entry".into()));
@@ -189,7 +178,6 @@ impl Bundle {
latency: std::ptr::null(),
notes: std::ptr::null(),
state_ext: std::ptr::null(),
render: std::ptr::null(),
gui: Arc::new(GuiLink::default()),
raw: Vec::with_capacity(EVENT_CAP),
out_params: Vec::with_capacity(EVENT_CAP),
@@ -217,7 +205,6 @@ impl Bundle {
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
inst.render = inst.extension::<clap_plugin_render>(CLAP_EXT_RENDER);
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
inst.gui = Arc::new(GuiLink {
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
-12
View File
@@ -8,7 +8,6 @@ use super::{
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
read_name,
};
use super::{CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
// ---- audio buffers -------------------------------------------------------------------------
@@ -100,7 +99,6 @@ pub(crate) struct Instance {
pub(super) latency: *const clap_plugin_latency,
pub(super) notes: *const clap_plugin_note_ports,
pub(super) state_ext: *const clap_plugin_state,
pub(super) render: *const clap_plugin_render,
pub(super) gui: Arc<GuiLink>,
pub(super) raw: Vec<RawEvent>,
/// Parameter values the plugin reported through output events in the current `process`.
@@ -160,16 +158,6 @@ impl Instance {
unsafe { load(self.plugin, &stream) }
}
/// `clap_plugin_render.set`: offline (no realtime pressure) or realtime. `false` without the
/// extension or when the plugin can't switch.
pub fn set_render_mode(&self, offline: bool) -> bool {
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let Some(set) = (unsafe { self.render.as_ref() }).and_then(|r| r.set) else { return false };
let mode = if offline { CLAP_RENDER_OFFLINE } else { CLAP_RENDER_REALTIME };
// SAFETY: valid instance, called from the thread that owns it.
unsafe { set(self.plugin, mode) }
}
/// Runs `on_main_thread` if the plugin asked for a callback.
pub fn service_callback(&mut self) {
if self.state.callback.swap(false, Ordering::Relaxed)
-13
View File
@@ -262,17 +262,4 @@ mod tests {
assert!(instantiate("clap:does.not.exist").is_err());
let _ = std::fs::remove_dir_all(&d);
}
/// A binary the system refuses to load reports the system's reason (missing library, wrong
/// glibc, not an ELF file), not just "dlopen failed".
#[test]
fn a_binary_that_does_not_load_says_why() {
let d = fresh_private_dir("soundcraft-clap-reason");
let f = d.join("garbage.clap");
std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default();
let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim());
assert!(!reason.is_empty(), "no reason in: {msg}");
let _ = std::fs::remove_dir_all(&d);
}
}
-5
View File
@@ -178,11 +178,6 @@ impl Plugin for ClapPlugin {
}
}
fn set_offline(&mut self, offline: bool) {
// `clap.render`: plugins without it render the same way either way.
self.inst.set_render_mode(offline);
}
fn reset(&mut self) {
self.inst.reset();
self.queue.retain(|e| matches!(e, Event::Param { .. }));
+1 -3
View File
@@ -150,9 +150,7 @@ mod tests {
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
// Created exclusively: a folder still there after the cleanup (someone else's) would
// let a link inside it redirect the test's writes.
std::fs::create_dir(&d).unwrap();
std::fs::create_dir_all(&d).unwrap();
d
}
+5 -23
View File
@@ -9,8 +9,7 @@
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
//! advertised layout (2.0 once the host set the offline render mode through `clap.render`), -1.0
//! when they do not.
//! advertised layout, -1.0 when they do not.
//!
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
@@ -28,7 +27,6 @@ use clap_sys::ext::latency::*;
use clap_sys::ext::log::*;
use clap_sys::ext::note_ports::*;
use clap_sys::ext::params::*;
use clap_sys::ext::render::*;
use clap_sys::factory::plugin_factory::*;
use clap_sys::host::clap_host;
use clap_sys::id::clap_id;
@@ -104,8 +102,6 @@ struct Fx {
gui_created: bool,
gui_visible: bool,
gui_edit_pending: bool,
/// `clap.render` set the offline mode (wide fixtures only).
offline: bool,
}
unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {
@@ -193,8 +189,8 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
}
}
/// Fill every output channel with `fit` when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize, fit: f32) {
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
@@ -204,7 +200,7 @@ unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize, fit:
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { fit } else { -1.0 } };
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
@@ -256,7 +252,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide {
unsafe { fill_wide(pr, ports, channels, n, if f.offline { 2.0 } else { 1.0 }) };
unsafe { fill_wide(pr, ports, channels, n) };
return CLAP_PROCESS_CONTINUE;
}
if pr.audio_outputs_count != 1 {
@@ -547,17 +543,6 @@ static GUI: clap_plugin_gui = clap_plugin_gui {
hide: Some(gui_hide),
};
unsafe extern "C" fn render_hard_realtime(_p: *const clap_plugin) -> bool {
false
}
unsafe extern "C" fn render_set(p: *const clap_plugin, mode: clap_plugin_render_mode) -> bool {
unsafe { fx(p) }.offline = mode == CLAP_RENDER_OFFLINE;
true
}
static RENDER: clap_plugin_render = clap_plugin_render { has_hard_realtime_requirement: Some(render_hard_realtime), set: Some(render_set) };
unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> *const c_void {
let id = unsafe { CStr::from_ptr(id) };
let synth = unsafe { fx(p) }.synth;
@@ -573,8 +558,6 @@ unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) ->
&STATE as *const _ as *const c_void
} else if id == CLAP_EXT_GUI && !synth {
&GUI as *const _ as *const c_void
} else if id == CLAP_EXT_RENDER && unsafe { fx(p) }.wide.is_some() {
&RENDER as *const _ as *const c_void
} else {
std::ptr::null()
}
@@ -640,7 +623,6 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
gui_created: false,
gui_visible: false,
gui_edit_pending: false,
offline: false,
});
let raw = Box::into_raw(b);
unsafe {
-13
View File
@@ -130,19 +130,6 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
}
}
#[test]
fn offline_renders_set_the_render_mode() {
fixture_dir();
for (offline, want) in [(false, 1.0), (true, 2.0)] {
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
p.set_offline(offline);
p.prepare(44_100.0, 64, 2);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]);
}
}
#[test]
fn many_instances_create_and_drop_cleanly() {
fixture_dir();
-5
View File
@@ -195,11 +195,6 @@ pub struct Transport {
pub trait Plugin: Send {
fn info(&self) -> &'static PluginInfo;
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize);
/// The plugin renders offline (a bounce: faster or slower than realtime, no deadline) rather
/// than in realtime. Hosted plugins pass it on (VST3 `processMode`, CLAP `clap.render`): some
/// use better algorithms then, or wait for what they load instead of playing on without it.
/// Called before `prepare`; plugins are realtime until told otherwise.
fn set_offline(&mut self, _offline: bool) {}
fn reset(&mut self);
/// Sets a parameter (clamped into range; NaN becomes the default). `false` if `id` is unknown.
fn set_param(&mut self, id: &str, value: f32) -> bool;
+38 -27
View File
@@ -212,9 +212,6 @@ impl Autosave {
let audio_dir = self.dir.join("Audio Files");
let mut s = session.clone();
let mut copies = BTreeMap::new();
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
// (a session may name one file under many ids) share one file.
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
for src in &mut s.sources {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
@@ -227,7 +224,6 @@ impl Autosave {
let Some(audio) = audio else { continue };
let name = match self.copies.remove(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
_ => {
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
self.next += 1;
@@ -239,16 +235,13 @@ impl Autosave {
};
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
src.unsaved = true;
written.insert(Arc::as_ptr(audio), name.clone());
copies.insert(src.id, (Arc::downgrade(audio), name));
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
if !self.copies.values().any(|(_, kept)| *kept == name) {
let _ = std::fs::remove_file(audio_dir.join(name));
}
let _ = std::fs::remove_file(audio_dir.join(name));
}
if let Some(r) = recording {
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
@@ -432,6 +425,27 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
builder.create(dir)
}
/// A new folder directly in `parent`, named `{prefix}-` and 32 random hex digits. It is made
/// exclusively (it is created by this call or the call fails), so a folder or link another account
/// put there first is never used, and the name cannot be guessed ahead. On Unix it is owner-only
/// (`0o700`); on other systems it has the mode `parent` gives it.
pub fn create_unique_folder(parent: &Path, prefix: &str) -> std::io::Result<PathBuf> {
use std::hash::{BuildHasher, Hasher};
for _ in 0..16 {
let random = |_| std::collections::hash_map::RandomState::new().build_hasher().finish();
let dir = parent.join(format!("{prefix}-{:016x}{:016x}", random(0), random(1)));
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(&dir) {
Ok(()) => return Ok(dir),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no unused folder name"))
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
@@ -454,6 +468,22 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
mod tests {
use super::*;
use crate::io::open_session;
#[test]
fn unique_folders_are_new_and_owner_only() {
let parent = std::env::temp_dir().join(format!("soundcraft-unique-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&parent);
std::fs::create_dir_all(&parent).unwrap();
let (a, b) = (create_unique_folder(&parent, "T").unwrap(), create_unique_folder(&parent, "T").unwrap());
assert_ne!(a, b);
assert!(a.is_dir() && a.parent() == Some(parent.as_path()));
assert_eq!(a.file_name().unwrap().len(), "T-".len() + 32);
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&a).unwrap().permissions()) & 0o777, 0o700);
assert!(create_unique_folder(&parent.join("missing"), "T").is_err(), "no parents are made");
let _ = std::fs::remove_dir_all(&parent);
}
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
@@ -514,25 +544,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sources_sharing_one_buffer_share_one_copy() {
let dir = folder("autosave-aliases");
let mut e = Engine::default();
record(&mut e, 0.5);
// Nine more source ids over the same decoded audio, as a session file may declare.
let s = e.session_mut();
let (src, audio) = (s.sources[0].clone(), s.pool.get(s.sources[0].id).cloned().unwrap());
for _ in 0..9 {
let id = SourceId(s.alloc());
s.sources.push(soundcraft_model::Source { id, ..src.clone() });
s.pool.insert(id, audio.clone());
}
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), None, None).unwrap();
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1, "one file for the ten ids");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_recorded_after_undo_gets_its_own_copy() {
let dir = folder("autosave-undo");
+1 -4
View File
@@ -347,10 +347,7 @@ pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
// Each clip once, in the order given: a repeated id would otherwise be processed (and,
// for a render, re-rendered into another source) once per repeat.
let mut seen = std::collections::HashSet::new();
return arr.iter().filter_map(Value::as_u64).map(ClipId).filter(|id| seen.insert(*id)).collect();
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
}
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
return vec![ClipId(n)];
+1 -15
View File
@@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AudioSuite Process",
[],
None,
"{process: <built-in plugin id (engine.plugins)> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
has_selection,
process
)]
@@ -55,9 +55,6 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
| "speed_ramp"
| "dc_offset_removal"
);
if !known && soundcraft_mix::is_third_party(&name) {
return Err(bad("audiosuite.process", format!("`{name}` is a hosted plugin; AudioSuite runs built-in plugins only (engine.plugins)")));
}
if !known {
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
}
@@ -231,17 +228,6 @@ mod tests {
(crate::Engine::new(s), id)
}
#[test]
fn hosted_plugin_ids_are_refused_with_the_reason() {
let (mut e, id) = gained_clip();
for plugin in ["clap:com.example.reverb", "vst3:00112233445566778899AABBCCDDEEFF", "au:aufx:dely:appl"] {
let err = e.execute("audiosuite.process", &json!({"process": plugin, "clips": [id.0]})).unwrap_err().to_string();
assert!(err.contains("built-in"), "{plugin}: {err}");
}
let spec = specs().into_iter().find(|s| s.id == "audiosuite.process").unwrap();
assert!(spec.params.contains("<built-in plugin id"), "the help says which ids it takes: {}", spec.params);
}
#[test]
fn invert_and_duplicate_keep_clip_gain() {
let (mut e, id) = gained_clip();
+1 -1
View File
@@ -425,7 +425,7 @@ fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session();
let moves: Vec<(soundcraft_model::ClipId, i64)> =
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
let s = e.session_mut();
for (id, d) in &moves {
crate::edit::move_clips(s, &[*id], *d, &[]);
+6 -19
View File
@@ -301,7 +301,6 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
cut_track(s, *t, range, sh, follow);
}
s.edit.selection = soundcraft_time::Range::point(range.start);
cb.cut_from = Some(range.start);
}
let n: usize = cb.tracks.iter().map(Vec::len).sum();
e.clipboard = cb;
@@ -329,7 +328,6 @@ pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
cb
}
/// Pastes the clipboard at an explicit position or at the current edit insertion point.
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.paste", p)?;
let given = position_param(e, "edit.paste", p, "at")?;
@@ -337,10 +335,9 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
let at = match given {
Some(at) => at,
None if s.edit.edit_mode == EditMode::GridRelative && s.edit.selection.is_empty() && cb.cut_from == Some(s.edit.selection.start) => {
s.edit.selection.start
None if s.edit.edit_mode == EditMode::GridRelative => {
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
}
None if s.edit.edit_mode == EditMode::GridRelative => s.grid_line_at_or_before(s.edit.selection.start).saturating_add(cb.grid_offset),
None => s.edit.selection.start,
};
let sh = shuffle(s) && !cb.automation_only;
@@ -671,16 +668,11 @@ fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"separated": n}))
}
/// Separates selected clips at every active Grid boundary in the requested range.
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
let r = range_param(e, "edit.separate_on_grid", p)?;
let s = e.session_mut();
let max = if s.edit.grid_clips_markers { usize::MAX } else { 10_001 };
let lines = s.grid_lines(r.start, r.end, max);
if lines.len() > 10_000 {
return Err(bad("edit.separate_on_grid", "the range contains more than 10,000 grid boundaries"));
}
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
let mut n = 0;
for t in &tracks {
for at in &lines {
@@ -964,7 +956,6 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"moved": n, "by": step}))
}
/// Same-track placement at the existing start is a no-op; quantization is a separate command.
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
if ids.is_empty() {
@@ -987,21 +978,17 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
};
let mode = s.edit.edit_mode;
let delta = match first {
Some(_) if mode.is_grid() && p.get("to").is_some() && delta == 0 && dest.iter().all(|(from, to)| from == to) => 0,
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative, &ids),
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
_ => delta,
};
let effective_delta = first.map_or(delta, |clip| delta.max(clip.start.saturating_neg()));
let s = e.session_mut();
let n = if mode == EditMode::Shuffle {
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
} else if effective_delta == 0 && dest.iter().all(|(from, to)| from == to) {
0
} else {
ops::move_clips(s, &ids, effective_delta, &dest)
ops::move_clips(s, &ids, delta, &dest)
};
s.edit.selected_clips = ids;
Ok(json!({"moved": n, "by": if mode == EditMode::Shuffle { delta } else { effective_delta }}))
Ok(json!({"moved": n, "by": delta}))
}
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
+15 -51
View File
@@ -598,7 +598,6 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
}
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
#[cfg(test)]
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
let mut prev: Option<(i64, f32)> = None;
for &(t, v) in env {
@@ -613,38 +612,6 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
prev.map_or(0.0, |(_, v)| v)
}
/// Repeated lookups in one clip-gain envelope, each O(log n) instead of a walk from its start.
/// Gives exactly what walking the points in stored order gives, sorted or not: the first point
/// at or after `at` is the first whose running maximum position reaches `at`.
struct EnvLookup<'a> {
env: &'a [(i64, f32)],
/// Largest position among `env[..=i]`.
reach: Vec<i64>,
}
impl<'a> EnvLookup<'a> {
fn new(env: &'a [(i64, f32)]) -> Self {
let reach = env
.iter()
.scan(i64::MIN, |m, &(t, _)| {
*m = (*m).max(t);
Some(*m)
})
.collect();
EnvLookup { env, reach }
}
/// Value in dB at `at` (see `env_db`).
fn db(&self, at: i64) -> f32 {
let i = self.reach.partition_point(|&m| m < at);
let Some(&(t, v)) = self.env.get(i) else { return self.env.last().map_or(0.0, |&(_, v)| v) };
match i.checked_sub(1).and_then(|p| self.env.get(p)) {
Some(&(pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
_ => v,
}
}
}
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
let delta = |at: i64| lane.value_at(at, base) - base;
@@ -654,41 +621,39 @@ fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model
return;
}
let rel = |at: i64| at - c.start;
let old_env = std::mem::take(&mut c.gain_env);
let env = EnvLookup::new(&old_env);
let env_db = |at: i64| env.db(at);
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> = old_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(rel0 - 1)));
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
}
pts.push((rel0, env_db(rel0) + delta(sel0)));
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(at) + delta(pt.at)));
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in old_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(at) + delta(c.start.saturating_add(at))));
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(at) + delta(sel1 - 1)));
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(rel1)));
pts.push((rel1, env_db(&c.gain_env, rel1)));
}
pts.sort_by_key(|p| p.0);
let mut baked = Vec::new();
let mut env = Vec::new();
for p in pts {
if baked.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
baked.pop();
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
}
baked.push(p);
env.push(p);
}
c.gain_env = baked;
c.gain_env = env;
}
/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there.
@@ -761,9 +726,8 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some(l) = &old {
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
}
let env = EnvLookup::new(&c.gain_env);
for at in ats {
pts.push((at, level(at) + c.gain_db + env.db(at - c.start)));
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
}
c.gain_db = 0.0;
c.gain_env.clear();
-33
View File
@@ -345,36 +345,3 @@ fn insert_and_send_reject_invalid_slot() {
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
assert_eq!(r["slot"], 0);
}
#[test]
fn indexed_envelope_lookup_matches_the_walk() {
let envs: [Vec<(i64, f32)>; 5] = [
Vec::new(),
vec![(100, -3.0)],
vec![(0, 0.0), (100, -6.0), (100, 3.0), (250, -12.0), (900, 1.0)],
// Out of order, as a hand-edited session file may hold.
vec![(500, -1.0), (100, -6.0), (700, 4.0), (300, 2.0), (700, -9.0), (50, 0.5)],
(0..200).map(|i| ((i * 37) % 1000, (i as f32 * 0.7).sin() * 12.0)).collect(),
];
for env in &envs {
let lookup = EnvLookup::new(env);
for at in -20..1100 {
assert_eq!(lookup.db(at).to_bits(), env_db(env, at).to_bits(), "{env:?} at {at}");
}
}
}
#[test]
fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
let (mut e, t) = tone_session(&[]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let n = 200_000i64;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.length = n * 5 + 10;
c.gain_env = (0..n).map(|i| (i * 5, if i % 2 == 0 { -6.0 } else { 0.0 })).collect();
let start = std::time::Instant::now();
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": n * 5 + 10})).unwrap();
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
assert!(lane.points.len() as i64 >= n);
}
+14 -186
View File
@@ -3,9 +3,9 @@ use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, MAX_POSITION, NoteValue, TimeFormat, TimecodeValue};
use soundcraft_time::TimeFormat;
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
/// Returns commands that control saved view, counter, Grid, and Nudge state.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
@@ -65,18 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
let edit = &mut e.session_mut().edit;
edit.main_counter = f;
let scale = grid_scale(f);
if edit.grid_follow_main && edit.grid.scale() != scale {
edit.grid = GridValue::for_scale(scale);
}
if edit.grid_follow_main {
edit.grid_clips_markers = false;
}
if edit.nudge_follow_main && edit.nudge.scale() != scale {
edit.nudge = GridValue::for_scale(scale);
}
e.session_mut().edit.main_counter = f;
Ok(json!({"format": f}))
}),
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
@@ -84,80 +73,37 @@ pub fn specs() -> Vec<CommandSpec> {
e.session_mut().edit.sub_counter = f;
Ok(json!({"format": f}))
}),
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples|clips_markers, follow_main?: bool, lines?: bool}", always, |e, p| {
let current = e.session().edit.grid;
let requested_scale = str_param(p, "format").and_then(GridScale::from_id);
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.grid", "unknown grid value or format"))?;
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| {
let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?;
let lines = p.get("lines").and_then(Value::as_bool);
let follow = p.get("follow_main").and_then(Value::as_bool);
if follow == Some(true) {
let scale = grid_scale(e.session().edit.main_counter);
if g.scale() != scale {
g = GridValue::for_scale(scale);
}
}
let s = e.session_mut();
s.edit.grid = g;
if p.get("value").is_some() || p.get("step").is_some() {
s.edit.grid_clips_markers = false;
}
if let Some(scale) = requested_scale {
s.edit.grid_clips_markers = scale == GridScale::ClipsMarkers;
}
if follow == Some(true) {
s.edit.grid_clips_markers = false;
}
s.edit.grid_follow_main = follow.unwrap_or(
s.edit.grid_follow_main && !s.edit.grid_clips_markers && g.scale() == grid_scale(s.edit.main_counter),
);
if let Some(l) = lines { s.edit.grid_lines = l }
let format = if s.edit.grid_clips_markers { GridScale::ClipsMarkers } else { g.scale() };
Ok(json!({"grid": g.label(), "format": format.id(), "follow_main": s.edit.grid_follow_main}))
Ok(json!({"grid": g.label()}))
}),
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
let s = e.session_mut();
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
Ok(json!({"value": s.edit.grid_lines}))
}),
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples, follow_main?: bool}", always, |e, p| {
let current = e.session().edit.nudge;
if str_param(p, "format").and_then(GridScale::from_id) == Some(GridScale::ClipsMarkers) {
return Err(bad("view.nudge", "Clips/Markers is available only for Grid"));
}
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.nudge", "unknown nudge value or format"))?;
let follow = p.get("follow_main").and_then(Value::as_bool);
if follow == Some(true) {
let scale = grid_scale(e.session().edit.main_counter);
if g.scale() != scale {
g = GridValue::for_scale(scale);
}
}
let edit = &mut e.session_mut().edit;
edit.nudge = g;
edit.nudge_follow_main = follow.unwrap_or(edit.nudge_follow_main && g.scale() == grid_scale(edit.main_counter));
Ok(json!({"nudge": g.label(), "format": g.scale().id(), "follow_main": edit.nudge_follow_main}))
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| {
let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
e.session_mut().edit.nudge = g;
Ok(json!({"nudge": g.label()}))
}),
]
}
const MIN_SPP: f64 = 0.25;
const MAX_SPP: f64 = 1_000_000.0;
/// Pixel column an off-screen edit cursor is brought to when zooming (the engine does not know the viewport width).
const OFFSCREEN_CURSOR_PX: f64 = 500.0;
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
let insertion = e.session().edit.selection.start as f64;
let z = e.zoom_mut();
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
// Without a pointer, the edit cursor is the reference: held in place when visible, centred when off-screen.
let cursor_x = (insertion - scroll) / old;
if let Some(x) = x {
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
} else if (0.0..4000.0).contains(&cursor_x) {
z.scroll = soundcraft_time::to_samples(scroll + cursor_x * (old - new)).clamp(0, MAX_POSITION);
} else {
z.scroll = soundcraft_time::to_samples(insertion - OFFSCREEN_CURSOR_PX * new).clamp(0, MAX_POSITION);
}
z.samples_per_px = new;
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
@@ -183,33 +129,7 @@ fn value_or_step(p: &Value, current: GridValue) -> Option<GridValue> {
Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0)))
}
/// Maps a counter format to the corresponding Grid/Nudge scale.
fn grid_scale(format: TimeFormat) -> GridScale {
match format {
TimeFormat::BarsBeats => GridScale::BarsBeats,
TimeFormat::MinSecs => GridScale::MinSecs,
TimeFormat::Timecode => GridScale::Timecode,
TimeFormat::FeetFrames => GridScale::FeetFrames,
TimeFormat::Samples => GridScale::Samples,
}
}
/// Resolves an explicit value, a step, or a scale change while preserving the current increment.
fn requested_grid_value(p: &Value, current: GridValue) -> Option<GridValue> {
if p.get("value").is_some() || p.get("step").is_some() {
return value_or_step(p, current);
}
let scale = str_param(p, "format").and_then(GridScale::from_id);
match scale {
Some(GridScale::ClipsMarkers) => Some(current),
Some(scale) if current.scale() == scale => Some(current),
Some(scale) => Some(GridValue::for_scale(scale)),
None if p.get("follow_main").is_some() || p.get("lines").is_some() => Some(current),
None => None,
}
}
/// Parse grid/nudge values; legacy `1` and `1/1` mean a bar, while `whole` means four quarter notes.
/// Parse grid/nudge values.
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
let v = p.get(key)?;
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
@@ -218,23 +138,15 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
return Some(GridValue::Frames(f.clamp(1, 100_000)));
}
if let Some(value) = v.get("timecode").and_then(Value::as_str).and_then(TimecodeValue::from_id) {
return Some(GridValue::Timecode(value));
}
if let Some(value) = v.get("feet_frames").and_then(Value::as_str).and_then(FeetFramesValue::from_id) {
return Some(GridValue::FeetFrames(value));
}
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
}
let s = v.as_str()?.trim().to_ascii_lowercase();
let triplet = s.ends_with('t') || s.contains("triplet");
let dotted = s.ends_with('.') || s.contains("dotted");
let core = s.replace("triplet", "").replace("dotted", "");
let core = core.trim().trim_end_matches(['t', '.']);
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
let value = match core.trim() {
"1" | "1/1" | "1 bar" | "bar" => NoteValue::Bar,
"whole" | "whole note" => NoteValue::Whole,
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
"1/2" => NoteValue::Half,
"1/4" => NoteValue::Quarter,
"1/8" => NoteValue::Eighth,
@@ -250,59 +162,15 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
mod tests {
use super::*;
use soundcraft_time::Samples;
/// Grid values accept compact and word-form musical modifiers plus every absolute scale.
#[test]
fn grid_values_parse() {
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
assert_eq!(grid_param(&json!({"v": "1/8 triplet"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
for alias in ["1", "1/1"] {
assert_eq!(grid_param(&json!({"v": alias}), "v"), Some(GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }));
}
assert_eq!(grid_param(&json!({"v": "whole"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": "whole note"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
assert_eq!(grid_param(&json!({"v": {"timecode": "quarter_frame"}}), "v"), Some(GridValue::Timecode(TimecodeValue::QuarterFrame)));
assert_eq!(grid_param(&json!({"v": {"feet_frames": "foot"}}), "v"), Some(GridValue::FeetFrames(FeetFramesValue::Foot)));
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
}
/// Explicit values and formats retain Follow Main only when the selected scale still matches.
#[test]
fn explicit_grid_and_nudge_scales_keep_follow_state_consistent() {
for command in ["view.grid", "view.nudge"] {
let mut e = Engine::default();
e.execute("view.main_counter", &json!({"format": "min_secs"})).unwrap();
e.execute(command, &json!({"follow_main": true})).unwrap();
let result = e.execute(command, &json!({"value": {"seconds": 0.1}})).unwrap();
assert_eq!(result["follow_main"], true);
let result = e.execute(command, &json!({"value": {"feet_frames": "foot"}})).unwrap();
assert_eq!(result["follow_main"], false);
e.execute(command, &json!({"follow_main": true})).unwrap();
let result = e.execute(command, &json!({"format": "bars_beats"})).unwrap();
assert_eq!(result["follow_main"], false);
}
let mut e = Engine::default();
e.execute("view.grid", &json!({"format": "clips_markers"})).unwrap();
assert!(!e.session().edit.grid_follow_main);
e.execute("view.grid", &json!({"value": {"samples": 100}})).unwrap();
assert!(!e.session().edit.grid_clips_markers);
assert_eq!(e.session().edit.grid, GridValue::Samples(100));
}
/// Historical bar aliases retain meter-dependent duration; the explicit whole note remains distinct.
#[test]
fn legacy_bar_aliases_and_whole_notes_remain_distinct_in_odd_meter() {
let mut e = Engine::default();
e.session_mut().tempo = soundcraft_time::TempoMap::new(120.0, 3, 4).unwrap();
for (value, expected) in [("1", 72_000), ("1/1", 72_000), ("whole", 96_000)] {
e.execute("view.grid", &json!({"value": value, "follow_main": false})).unwrap();
let s = e.session();
assert_eq!(s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate), expected);
}
}
fn view(e: &Engine) -> (f64, Samples) {
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
}
@@ -318,17 +186,6 @@ mod tests {
assert_eq!(view(&e), (100.0, 48_000));
}
#[test]
fn zoom_in_brings_an_offscreen_edit_cursor_into_view() {
let mut e = Engine::default();
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
e.execute("edit.select", &json!({"start": 1_000_000, "end": 1_000_000})).unwrap();
e.execute("view.zoom_in", &json!({})).unwrap();
let (spp, scroll) = view(&e);
let x = (1_000_000.0 - scroll as f64) / spp;
assert!((x - OFFSCREEN_CURSOR_PX).abs() < 1.0, "cursor at {x}px");
}
#[test]
fn zoom_and_scroll_stay_in_range() {
let mut e = Engine::default();
@@ -365,7 +222,6 @@ mod tests {
assert_eq!(state(&e), before);
}
/// Grid and Nudge steps drive edit movement while preserving their selected increments.
#[test]
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
let mut e = Engine::default();
@@ -387,32 +243,4 @@ mod tests {
assert_eq!(e.session().edit.selection.start, 6_000);
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
}
/// Grid and Nudge expose independent scale-following state, while Clips/Markers remains Grid-only.
#[test]
fn grid_and_nudge_choose_scales_follow_the_main_counter_and_keep_clips_markers_grid_only() {
let mut e = Engine::default();
e.execute("view.grid", &json!({"format": "timecode", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::Frame));
e.execute("view.grid", &json!({"value": {"timecode": "quarter_frame"}})).unwrap();
e.execute("view.grid", &json!({"format": "clips_markers", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
assert!(e.session().edit.grid_clips_markers);
assert!(e.execute("view.nudge", &json!({"format": "clips_markers"})).is_err());
e.execute("view.grid", &json!({"follow_main": true})).unwrap();
e.execute("view.nudge", &json!({"follow_main": true})).unwrap();
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Seconds(1.0), GridValue::Seconds(1.0)));
assert!(!e.session().edit.grid_clips_markers);
e.execute("view.main_counter", &json!({"format": "samples"})).unwrap();
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Samples(100), GridValue::Samples(100)));
assert!(e.session().edit.grid_follow_main && e.session().edit.nudge_follow_main);
e.execute("view.grid", &json!({"format": "bars_beats", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::default());
assert!(!e.session().edit.grid_follow_main);
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::default());
assert_eq!(e.session().edit.nudge, GridValue::Timecode(TimecodeValue::Frame));
}
}
+1 -17
View File
@@ -52,9 +52,6 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
grid,
nudge,
grid_lines,
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
main_counter,
sub_counter,
zoom: _,
@@ -133,17 +130,7 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
scrolling,
solo_mode,
),
(
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
pre_fader_metering,
delay_compensation,
keyboard_focus,
&navigation.timeline_selection,
flags,
values,
),
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
)
}
@@ -199,9 +186,6 @@ mod tests {
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
("grid_lines", |e| e.grid_lines = !e.grid_lines),
("grid_follow_main", |e| e.grid_follow_main = !e.grid_follow_main),
("nudge_follow_main", |e| e.nudge_follow_main = !e.nudge_follow_main),
("grid_clips_markers", |e| e.grid_clips_markers = !e.grid_clips_markers),
("main_counter", |e| e.main_counter = TimeFormat::Samples),
("sub_counter", |e| e.sub_counter = None),
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
+21 -11
View File
@@ -528,15 +528,9 @@ fn join_gain_env(l: &mut Clip, r: &Clip) {
}
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
/// elsewhere (see [`destinations`]). Same-track moves clamped to zero leave all material intact.
/// elsewhere (see [`destinations`]).
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
// Limit the shift before removing any clips, so a stationary edit cannot overwrite overlaps.
let limit = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| ids.contains(&c.id) && !locked(c)).map(|c| c.start.saturating_neg()).max();
let delta = delta.max(limit.unwrap_or(0));
if delta == 0 && dest.iter().all(|(from, to)| from == to) {
return 0;
}
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
for t in &mut s.tracks {
let tid = t.id;
@@ -552,6 +546,8 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
}
}
let n = moving.len();
// Limit the shift once, so the group stops at zero together and keeps its spacing.
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
let placed = moving.into_iter().map(|(tid, mut c)| {
c.start = c.start.saturating_add(delta);
(lands(dest, tid), c)
@@ -576,7 +572,20 @@ fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
pub fn shown(s: &Session, t: &Track) -> bool {
s.track_shown(t)
!t.hidden && !folder_collapsed(s, t)
}
/// Hidden because a folder at any level above this track is closed.
fn folder_collapsed(s: &Session, track: &Track) -> bool {
let mut current = track.folder;
for _ in 0..64 {
let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false };
if !folder.folder_open {
return true;
}
current = folder.folder;
}
false
}
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
@@ -781,11 +790,12 @@ pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(T
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
/// grid steps, keeping its offset from the grid.
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool, moving: &[ClipId]) -> Samples {
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
let anchor = clip.start.saturating_add(sync);
let from = if relative { s.grid_line_at_or_before_excluding(anchor, moving) } else { anchor };
s.snap_to_grid_excluding(from.saturating_add(raw), moving).saturating_sub(from)
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
}
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
+3 -3
View File
@@ -330,9 +330,9 @@ fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
let (s, t) = session_with_clip();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
let c = s.track(t).unwrap().clips()[0].clone();
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false, &[c.id]), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true, &[c.id]), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true, &[c.id]), 0);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
}
#[test]
-16
View File
@@ -178,13 +178,6 @@ pub struct Bounce {
enc: Option<soundcraft_audio_io::FileEncoder<Out>>,
}
/// The Broadcast Wave stamp of timeline position `at`: the session's timecode start plus `at`, in
/// samples since midnight (`bext` TimeReference; 0 before midnight).
fn time_stamp(s: &Session, at: Samples) -> soundcraft_audio_io::BwfInfo {
let time_reference = u64::try_from(s.timecode_start.saturating_add(at)).unwrap_or(0);
soundcraft_audio_io::BwfInfo { time_reference, ..Default::default() }
}
/// A finished bounce: the size of its file and its levels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bounced {
@@ -215,9 +208,6 @@ impl Bounce {
let part = path.with_file_name(format!("{name}.part"));
crate::file_access::check_write(path).map_err(EngineError::Io)?;
crate::file_access::check_write(&part).map_err(EngineError::Io)?;
// A WAV bounce says where it starts on the session's timecode, as other DAWs' bounces do,
// so post and broadcast tools can place it (only WAV has a `bext` chunk to say it in).
let opts = &EncodeOptions { bwf: opts.bwf.clone().or_else(|| Some(time_stamp(&s, r.start))), ..opts.clone() };
let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> {
let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?;
enc.check_room(u64::try_from(r.len()).unwrap_or(0))?;
@@ -744,12 +734,6 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
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)?;
-17
View File
@@ -194,23 +194,6 @@ mod bundle_tests {
}
}
#[test]
fn entries_sharing_one_payload_are_refused() {
use std::io::Write as _;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("Song.scraft", opts).unwrap();
w.write_all(Session::default().to_json().unwrap().as_bytes()).unwrap();
w.start_file("Audio Files/a.wav", opts).unwrap();
w.write_all(&[7u8; 4096]).unwrap();
for i in 0..100 {
w.shallow_copy_file("Audio Files/a.wav", &format!("Audio Files/copy{i}.wav")).unwrap();
}
let bundle = w.finish().unwrap().into_inner();
let err = read_bundle(&bundle, BUNDLE_LIMITS).unwrap_err();
assert!(err.to_string().contains("share data"), "{err}");
}
#[test]
fn a_bundle_is_saved_only_if_it_opens_again() {
// #433: the writer keeps to the reader's limits.
+1 -3
View File
@@ -119,8 +119,6 @@ pub struct Clipboard {
pub markers: Vec<soundcraft_model::MemoryLocation>,
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
pub grid_offset: Samples,
/// Original insertion point when the clipboard was populated by Cut.
pub cut_from: Option<Samples>,
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
pub automation_only: bool,
/// The Auto Rename schemes of the copied clips, keyed as in their `auto_name`.
@@ -130,7 +128,7 @@ pub struct Clipboard {
impl Clipboard {
/// An empty clipboard for material copied from `range`.
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
let line = s.grid_line_at_or_before(range.start);
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
}
+1 -11
View File
@@ -418,9 +418,7 @@ fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<Str
const STAFF_GAP: f64 = 70.0;
const SYSTEM_GAP: f64 = 40.0;
let n_measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
// The layout range the setup offers (1 to 16): a caller's own value can't multiply the
// barlines drawn per system beyond it.
let per = setup.bars_per_system.clamp(1, 16) as usize;
let per = setup.bars_per_system.max(1) as usize;
let systems = n_measures.div_ceil(per).max(1);
let label_w = if setup.show_track_names { 70.0 } else { 0.0 };
let staff_x0 = MARGIN + label_w + 34.0;
@@ -784,14 +782,6 @@ mod tests {
assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests");
}
#[test]
fn bars_per_system_is_held_to_the_supported_range() {
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: u32::MAX, show_track_names: false };
let part = Part { name: "p".into(), bass: false, measures: vec![Measure { numerator: 4, denominator: 4, items: Vec::new() }] };
let out = svg_budgeted(std::slice::from_ref(&part), &setup, 1 << 20).unwrap();
assert_eq!(out.matches("stroke-width=\"1\"").count(), 16, "sixteen barlines, not billions");
}
#[test]
fn oversized_title_is_refused_by_the_header_check() {
// An oversized part name/title must be caught by the header check even with no measures:
-32
View File
@@ -123,38 +123,6 @@ fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A WAV bounce is a Broadcast Wave stamped with where it starts on the session's timecode (`bext`
/// TimeReference, samples since midnight), as other DAWs write it, so post tools can place it.
#[test]
fn wav_bounces_carry_their_timecode_position_as_a_bwf_time_reference() {
let mut e = soundcraft_engine::demo::demo_engine();
let sr = u64::from(e.session().sample_rate.hz());
// The session starts at 01:00:00:00; the bounce at 2 s into it.
e.session_mut().timecode_start = (3600 * sr) as i64;
let (start, end) = (2 * sr, 3 * sr);
let dir = scratch("bounce-bext");
let stamp = |path: &std::path::Path| {
let ext = path.extension().and_then(|x| x.to_str());
soundcraft_audio_io::probe(&std::fs::read(path).unwrap(), ext).unwrap().bwf.map(|b| b.time_reference)
};
let mix = dir.join("Mix.wav");
e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap();
assert_eq!(stamp(&mix), Some(3600 * sr + start), "mix");
let stems = dir.join("stems");
let files = e.execute("file.bounce_stems", &serde_json::json!({"dir": stems, "start": start, "end": end})).unwrap();
let files = files["files"].as_array().unwrap();
assert!(!files.is_empty());
for f in files {
let f = std::path::PathBuf::from(f.as_str().unwrap());
assert_eq!(stamp(&f), Some(3600 * sr + start), "{}", f.display());
}
// A session that starts at zero still says where the bounce starts.
e.session_mut().timecode_start = 0;
e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap();
assert_eq!(stamp(&mix), Some(start));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() {
let e = soundcraft_engine::demo::demo_engine();
-123
View File
@@ -220,129 +220,6 @@ fn relative_grid_pastes_keep_the_offset_from_the_grid() {
assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000)));
}
/// A relative paste restores the insertion point even when Cut creates a new boundary there.
#[test]
fn relative_grid_cut_pastes_back_at_its_insertion_boundary() {
let (mut e, t, _) = long("relative_grid", &[(1_000, 2_000, 0)]);
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 1_500, "end": 2_000}));
run(&mut e, "edit.paste", json!({"tracks": [t.0]}));
assert_eq!(e.session().edit.selection.start, 1_500);
assert!(clips(&e, t).contains(&(1_500, 500)), "{:?}", clips(&e, t));
}
/// Moving a group excludes all its clips from Clips/Markers snap candidates.
#[test]
fn clips_markers_grid_ignores_every_clip_being_moved() {
let (mut e, _, ids) = long("grid", &[(1_000, 1_000, 0), (4_000, 500, 2_000)]);
e.session_mut().add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 1_000}));
assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000, 4_000]);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 2_200}));
assert_eq!(start(&e, ids[0]), 2_500);
assert_eq!(start(&e, ids[1]), 5_500);
}
/// Zero and clamped moves preserve overlaps while off-grid quantization still changes position.
#[test]
fn zero_distance_move_keeps_overlapping_material_and_quantize_still_moves() {
let (mut e, t, ids) = long("slip", &[(1_000, 1_000, 0), (1_500, 1_000, 2_000)]);
let before = material(&e, t);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 0}));
assert_eq!(material(&e, t), before);
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
run(&mut e, "clip.quantize_to_grid", json!({}));
assert_eq!(start(&e, ids[0]), 0);
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
let before = material(&e, t);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": -1_000}));
assert_eq!(material(&e, t), before);
}
/// A vertical move remains a real edit and snaps its off-grid start on the destination track.
#[test]
fn zero_horizontal_grid_move_to_another_track_still_snaps() {
let (mut e, _, [_, clip]) = engine("grid");
let destination = new_track(&mut e);
run(&mut e, "edit.move_clips", json!({"clips": [clip.0], "to": 2_000, "track": destination.0}));
let (track, moved) = e.session().find_clip(clip).unwrap();
assert_eq!(track, destination);
assert_eq!(moved.start, 0);
}
/// Nudge and quantization must preserve overlapping material when the clip cannot move.
#[test]
fn stationary_nudges_and_quantization_preserve_overlaps() {
for (command, params) in [("edit.nudge", json!({"direction": -1})), ("clip.quantize_to_grid", json!({}))] {
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
let before = e.session().track(t).unwrap().clips().to_vec();
let result = e.execute(command, &params).unwrap();
assert_eq!(e.session().track(t).unwrap().clips(), before, "{command}");
if command == "edit.nudge" {
assert_eq!(result["moved"], 0);
}
}
}
/// Every Nudge scale shifts clips and insertion across a tempo change and drop-frame rates.
#[test]
fn every_nudge_scale_moves_by_its_selected_increment() {
use soundcraft_time::{FrameRate, SampleRate, TICKS_PER_QUARTER};
for rate in [FrameRate::Fps30, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
for at in [48_000, 144_000] {
let cases = [
(json!("1/4"), if at < 96_000 { 24_000 } else { 32_000 }),
(json!({"seconds": 0.5}), 24_000),
(json!({"timecode": "5_frames"}), rate.samples_at_frame(5, SampleRate::HZ_48000)),
(json!({"feet_frames": "foot"}), rate.samples_at_frame(16, SampleRate::HZ_48000)),
(json!({"samples": 100}), 100),
];
for (value, expected) in cases {
let (mut e, track, ids) = long("slip", &[(at, 1_000, 0)]);
e.session_mut().frame_rate = rate;
e.session_mut().tempo.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
run(&mut e, "edit.select", json!({"tracks": [track.0], "clips": [ids[0].0], "start": at, "end": at}));
run(&mut e, "view.nudge", json!({"value": value, "follow_main": false}));
let result = e.execute("edit.nudge", &json!({"direction": 1})).unwrap();
assert_eq!(result["by"], expected, "{rate:?} at {at}");
assert_eq!(result["moved"], 1);
assert_eq!(start(&e, ids[0]), at + expected);
assert_eq!(e.session().edit.selection.start, at + expected);
}
}
}
}
/// Separation refuses oversized numeric grids before splitting any material.
#[test]
fn separate_on_grid_refuses_a_truncated_boundary_set() {
let (mut e, t, _) = long("grid", &[(0, 20_000, 0)]);
run(&mut e, "view.grid", json!({"value": {"samples": 1}, "follow_main": false}));
assert!(e.execute("edit.separate_on_grid", &json!({"tracks": [t.0], "start": 0, "end": 10_002})).is_err());
assert_eq!(clips(&e, t), [(0, 20_000)]);
}
/// Display thinning must never hide an oversized Clips/Markers separation request.
#[test]
fn separate_on_grid_refuses_dense_markers_before_changing_clips() {
let (mut e, track, _) = long("grid", &[(0, 100_000, 0)]);
let s = e.session_mut();
for at in 1..=10_001 {
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, at, at);
}
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
let before = e.session().clone();
let undo_len = e.undo_history().len();
let error = e.execute("edit.separate_on_grid", &json!({"tracks": [track.0], "start": 0, "end": 100_000})).unwrap_err();
assert!(error.to_string().contains("10,000"), "{error}");
assert_eq!(e.session(), &before);
assert_eq!(e.undo_history().len(), undo_len);
}
#[test]
fn shuffle_moves_keep_crossfade_partners_whole() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
-16
View File
@@ -68,23 +68,7 @@ pub struct NewInstance {
/// Creates a plugin (built-in or hosted), restores `state` (if any) and prepares it. Expensive
/// for third-party plugins: in realtime use, call it on the control thread.
pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option<NewInstance> {
create_instance_for(id, state, sample_rate, max_block, ch, false)
}
/// [`create_instance`] for an engine that renders offline (`offline`) or in realtime: the plugin
/// is told which before it is prepared ([`Plugin::set_offline`]).
pub(crate) fn create_instance_for(
id: &str,
state: Option<&[u8]>,
sample_rate: f32,
max_block: usize,
ch: usize,
offline: bool,
) -> Option<NewInstance> {
let mut plugin = create_plugin(id)?;
if offline {
plugin.set_offline(true);
}
let refused_state = state.is_some_and(|st| !plugin.load_state(st));
if refused_state {
log::warn!("{id}: the plugin did not accept its stored state");
+1 -10
View File
@@ -144,8 +144,6 @@ pub struct MixEngine {
pub metronome: bool,
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
external: bool,
/// An offline render: the plugins `sync` creates are told so ([`Plugin::set_offline`]).
offline: bool,
/// Instances handed over by the host, waiting for `sync` to place them.
adopt: HashMap<(TrackId, usize), PreparedInstance>,
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
@@ -179,7 +177,6 @@ impl MixEngine {
recording: false,
metronome: false,
external: false,
offline: false,
adopt: HashMap::new(),
retired: Vec::new(),
work: Vec::new(),
@@ -192,11 +189,6 @@ impl MixEngine {
self.external = on;
}
/// Offline render (a bounce): plugins created from now on are told they render offline.
pub fn set_offline(&mut self, on: bool) {
self.offline = on;
}
/// Takes instances created off the audio thread; the next render's `sync` places them.
/// An earlier instance waiting for the same slot is retired.
pub fn adopt(&mut self, instances: Vec<PreparedInstance>) {
@@ -279,8 +271,7 @@ impl MixEngine {
if self.main.len() != main_ch {
self.main = vec![vec![0.0; mb]; main_ch];
}
let mut cx =
SlotCtx { sr: self.sample_rate, mb, external: self.external, offline: self.offline, adopt: &mut self.adopt, retired: &mut self.retired };
let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired };
let gone: Vec<TrackId> = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect();
for id in gone {
self.meters.remove(&id);
+2 -8
View File
@@ -46,20 +46,12 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
rtp("delay", 0.0).clamp(-1e7, 1e7) as i64,
)
};
// The block in clip ticks, one tick wider on each side so rounding never drops an event.
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
for n0 in &sequence.notes {
let mut n = *n0;
n.velocity = (i64::from(n.velocity) + dv).clamp(1, 127) as u8;
n.pitch = (i64::from(n.pitch) + dp).clamp(0, 127) as u8;
n.length = ((n.length as f64 * dur) as i64).max(1);
n.start = n.start.saturating_add(delay).max(0);
// A note whose on and off both fall outside the block sends nothing: skip it before
// converting its positions, so a long sequence costs a comparison per note per block.
if n.start > *near.end() || n.start.saturating_add(n.length) < *near.start() {
continue;
}
let on = s.tempo.tick_to_samples(base.saturating_add(n.start), sr);
let off = s.tempo.tick_to_samples(base.saturating_add(n.start).saturating_add(n.length), sr).min(clip.end());
if on >= clip.end() {
@@ -76,6 +68,8 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
held.retain(|p| *p != n.pitch);
}
}
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
// Events before tick 0 are what a start trim hid: they don't play.
for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) {
let tick = c.tick.saturating_add(delay).max(0);
+1 -2
View File
@@ -57,8 +57,7 @@ impl RangeRender {
/// Renders the main mix over `range`, or `bus` when one is named.
pub fn new(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Self {
let nch = source_channels(s, bus);
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
eng.set_offline(true);
let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
let tmp = vec![vec![0.0f32; eng.max_block()]; nch];
let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) };
RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 }
+24 -108
View File
@@ -46,8 +46,7 @@ pub(crate) fn processing_order(s: &Session) -> Vec<usize> {
} else {
// A cycle keeps the existing bounded ordering for the entire graph, including any
// independent acyclic branches. Feedback does not have a finite longest-path depth.
let depths = bounded_aux_depths(s);
auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0));
auxes.sort_by_key(|&i| bounded_aux_depth(s, i));
}
order.extend(auxes);
order
@@ -116,38 +115,32 @@ fn aux_output_busses(t: &Track) -> impl Iterator<Item = BusId> + '_ {
.filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None })
}
/// Hop cap for the bounded depth used when the aux graph has a cycle.
const MAX_BOUNDED_DEPTH: usize = 8;
/// For each track, the bounded depth used for a cyclic graph: the length of the longest chain of
/// Aux/Folder feeders into it (through a bus its input reads and the feeder's output or a send
/// writes), capped at [`MAX_BOUNDED_DEPTH`] hops. One pass per hop over the tracks and their bus
/// writes, so the cost is linear in the routing, not a fresh feeder search per track per
/// sort comparison.
fn bounded_aux_depths(s: &Session) -> Vec<usize> {
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Tracks with a feeder chain of the current length ending at them (length 0: every aux).
let mut reached: Vec<bool> = s.tracks.iter().map(is_aux).collect();
let mut depths = vec![0usize; s.tracks.len()];
let mut fed = std::collections::HashSet::<BusId>::new();
for hop in 1..=MAX_BOUNDED_DEPTH {
fed.clear();
for (t, _) in s.tracks.iter().zip(&reached).filter(|(t, r)| **r && is_aux(t)) {
fed.extend(aux_output_busses(t));
}
let mut any = false;
for ((t, r), d) in s.tracks.iter().zip(reached.iter_mut()).zip(depths.iter_mut()) {
*r = matches!(&t.mixer.input, Route::Bus(b) if fed.contains(b));
if *r {
*d = hop;
any = true;
}
}
if !any {
/// The previous bounded depth calculation, retained unchanged for any cyclic graph.
fn bounded_aux_depth(s: &Session, i: usize) -> usize {
let mut d = 0;
let mut cur = vec![i];
for _ in 0..8 {
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
if feeders.is_empty() {
break;
}
d += 1;
cur = feeders;
}
depths
d
}
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
s.tracks
.iter()
.enumerate()
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
.filter(|(_, t)| {
inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
})
.map(|(i, _)| i)
.collect()
}
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
@@ -211,81 +204,4 @@ mod processing_order_tests {
// Both deepest independent returns tie with the cycle at the legacy eight-hop cap.
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]);
}
/// The per-track feeder search `bounded_aux_depths` replaced, kept as its reference.
fn reference_depth(s: &Session, i: usize) -> usize {
let mut d = 0;
let mut cur = vec![i];
for _ in 0..MAX_BOUNDED_DEPTH {
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
let feeders: Vec<usize> = s
.tracks
.iter()
.enumerate()
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
.filter(|(_, t)| {
inputs
.iter()
.any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
})
.map(|(i, _)| i)
.collect();
if feeders.is_empty() {
break;
}
d += 1;
cur = feeders;
}
d
}
#[test]
fn bounded_depths_match_the_per_track_feeder_search() {
// A small pseudo-random graph of auxes, folders and audio tracks with cycles, joins,
// sends and unrouted inputs.
let mut s = Session::default();
let busses: Vec<_> = (0..6).map(|i| s.add_bus(&format!("B{i}"), ChannelFormat::Stereo)).collect();
let mut x = 12345u32;
let mut next = |m: usize| {
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
(x >> 16) as usize % m
};
for i in 0..30 {
let kind = [TrackKind::Aux, TrackKind::Folder, TrackKind::Audio][next(3)];
let id = s.add_track(kind, ChannelFormat::Stereo, None);
let pick = |n: usize| if n < busses.len() { Route::Bus(busses[n]) } else { Route::Main };
let (input, output, send) = (pick(next(8)), pick(next(8)), pick(next(8)));
let t = s.track_mut(id).unwrap();
t.mixer.input = input;
t.mixer.output = output;
if i % 2 == 0 {
t.mixer.sends[0] = Some(SendSlot::new(send));
}
}
let depths = bounded_aux_depths(&s);
for (i, (t, depth)) in s.tracks.iter().zip(&depths).enumerate() {
if matches!(t.kind, TrackKind::Aux | TrackKind::Folder) {
assert_eq!(*depth, reference_depth(&s, i), "track {i}");
}
}
assert!(depths.contains(&MAX_BOUNDED_DEPTH));
}
#[test]
fn a_large_cyclic_graph_orders_quickly() {
// Thousands of auxes all reading and writing one bus: every aux feeds every other.
let mut s = Session::default();
let bus = s.add_bus("Loop", ChannelFormat::Stereo);
for _ in 0..3000 {
let id = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
let t = s.track_mut(id).unwrap();
t.mixer.input = Route::Bus(bus);
t.mixer.output = Route::Bus(bus);
t.mixer.sends[0] = Some(SendSlot::new(Route::Bus(bus)));
}
let start = std::time::Instant::now();
let order = processing_order(&s);
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
assert_eq!(order, (0..3000).collect::<Vec<_>>());
}
}
+8 -128
View File
@@ -1,6 +1,6 @@
use super::automation::{GainRamp, transport_at, volume_db_at};
use super::clips::{ClipFx, clip_fx_params, render_clip};
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party};
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance, is_third_party};
use super::midi::{chase_controllers, schedule_notes};
use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix};
use super::routing::receives_solo;
@@ -24,9 +24,6 @@ pub(crate) struct PluginSlot {
pub(crate) plugin: Box<dyn Plugin>,
/// Last values pushed to the plugin, in the insert's parameter-map order.
applied: Vec<f32>,
/// Scratch for `apply_params`: indexes of the track's automation lanes for this slot, kept
/// so the audio thread reuses its allocation.
lanes: Vec<usize>,
pub(crate) failed: bool,
}
@@ -160,8 +157,6 @@ pub(crate) struct SlotCtx<'a> {
pub(crate) sr: f32,
pub(crate) mb: usize,
pub(crate) external: bool,
/// The engine renders offline: plugins it creates are told so.
pub(crate) offline: bool,
pub(crate) adopt: &'a mut HashMap<(TrackId, usize), PreparedInstance>,
pub(crate) retired: &'a mut Vec<Box<dyn Plugin>>,
}
@@ -194,9 +189,9 @@ impl SlotCtx<'_> {
}
} else {
let state = if third { ins.state_bytes() } else { None };
create_instance_for(&ins.plugin, state.as_deref(), self.sr, self.mb, ch, self.offline).map(|n| n.plugin)
create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin)
}?;
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false })
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false })
}
}
@@ -496,40 +491,21 @@ pub(crate) fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert
let Ok(slot_u8) = u8::try_from(i) else {
return apply_values(slot, ins, |_, v| v);
};
let lanes = &t.automation;
let name = |j: usize| match lanes.get(j).map(|l| &l.param) {
Some(AutoParam::Plugin { param, .. }) => param.as_str(),
_ => "",
};
// This slot's lanes, ordered by parameter name (the parameter map's order) and keeping only
// the first lane per name (the one a lookup by name finds). Matching them to the parameters
// in one merge costs O(lanes log lanes + params) per block, not O(lanes x params).
let mut order = std::mem::take(&mut slot.lanes);
order.clear();
if t.mixer.automation_mode.reads() {
order
.extend(lanes.iter().enumerate().filter(|(_, l)| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8)).map(|(j, _)| j));
if !order.iter().any(|&j| lanes.get(j).is_some_and(|l| !l.points.is_empty())) {
order.clear();
}
order.sort_unstable_by(|&a, &b| name(a).cmp(name(b)).then(a.cmp(&b)));
order.dedup_by(|later, kept| name(*later) == name(*kept));
}
let mut next = order.iter().copied().peekable();
let automated = t.mixer.automation_mode.reads()
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
apply_values(slot, ins, |k, val| {
while next.next_if(|&j| name(j) < k).is_some() {}
if let Some(l) = next.peek().filter(|&&j| name(j) == k).and_then(|&j| lanes.get(j))
if automated
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
&& !l.points.is_empty()
{
return l.value_at(pos, val);
}
val
});
slot.lanes = order;
}
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, mut value: impl FnMut(&str, f32) -> f32) {
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
if slot.applied.len() != ins.params.len() {
slot.applied = vec![f32::NAN; ins.params.len()];
}
@@ -541,99 +517,3 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, mut value
}
}
}
#[cfg(test)]
mod apply_params_tests {
use super::*;
use soundcraft_model::{AutomationLane, Insert};
use std::sync::Mutex;
/// A built-in gain that logs every parameter set.
struct Recorder {
inner: Box<dyn Plugin>,
sets: Arc<Mutex<Vec<(String, f32)>>>,
}
impl Plugin for Recorder {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.inner.info()
}
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
self.inner.prepare(sr, mb, ch);
}
fn reset(&mut self) {
self.inner.reset();
}
fn set_param(&mut self, id: &str, v: f32) -> bool {
self.sets.lock().unwrap().push((id.to_string(), v));
true
}
fn param(&self, id: &str) -> Option<f32> {
self.inner.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
self.inner.process(io, frames);
}
}
fn recorder() -> (PluginSlot, Arc<Mutex<Vec<(String, f32)>>>) {
let sets = Arc::new(Mutex::new(Vec::new()));
let plugin = Box::new(Recorder { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) });
(PluginSlot { id: "gain".into(), ch: 2, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }, sets)
}
fn lane(slot: u8, param: &str, point: Option<f32>) -> AutomationLane {
let mut l = AutomationLane::new(AutoParam::Plugin { slot, param: param.into() });
if let Some(v) = point {
l.set_point(0, v);
}
l
}
#[test]
fn first_lane_per_param_of_this_slot_drives_it() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
let tr = s.track_mut(t).unwrap();
tr.automation = vec![
lane(0, "c", None),
lane(1, "a", Some(5.0)),
lane(0, "b", Some(9.0)),
lane(0, "c", Some(7.0)),
lane(0, "zz", Some(1.0)),
lane(0, "b", Some(8.0)),
];
let mut ins = Insert::new("gain");
for (k, v) in [("a", 1.0), ("b", 2.0), ("c", 3.0), ("d", 4.0)] {
ins.params.insert(k.into(), v);
}
let (mut slot, sets) = recorder();
apply_params(&mut slot, &ins, tr, 0, 0);
let got = sets.lock().unwrap().clone();
let want: Vec<(String, f32)> = [("a", 1.0), ("b", 9.0), ("c", 3.0), ("d", 4.0)].iter().map(|(k, v)| (k.to_string(), *v)).collect();
assert_eq!(got, want);
// Unchanged values are not sent again.
apply_params(&mut slot, &ins, tr, 0, 0);
assert_eq!(sets.lock().unwrap().len(), 4);
}
#[test]
fn many_params_and_lanes_apply_without_a_quadratic_search() {
let n = 40_000;
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
let tr = s.track_mut(t).unwrap();
tr.automation = (0..n).rev().map(|i| lane(0, &format!("p{i:06}"), Some(i as f32))).collect();
let mut ins = Insert::new("gain");
for i in 0..n {
ins.params.insert(format!("p{i:06}"), -1.0);
}
let (mut slot, sets) = recorder();
let start = std::time::Instant::now();
apply_params(&mut slot, &ins, tr, 0, 0);
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
let got = sets.lock().unwrap();
assert_eq!(got.len(), n);
assert!(got.iter().all(|(k, v)| k == &format!("p{:06}", *v as usize)));
}
}
+1 -277
View File
@@ -5,7 +5,7 @@ use crate::{
Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool,
TRACK_COLORS, Track, TrackId, TrackKind, VideoSource,
};
use soundcraft_time::{FrameRate, GridScale, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
/// Native session file extension.
pub const SESSION_EXTENSION: &str = "scraft";
@@ -147,7 +147,6 @@ pub struct ZoomState {
}
impl Default for ZoomState {
/// Starts with a practical overview zoom and five reusable preset levels.
fn default() -> Self {
ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] }
}
@@ -180,15 +179,6 @@ pub struct EditState {
pub playhead: Samples,
pub grid: GridValue,
pub nudge: GridValue,
/// Grid/Nudge time scales follow the main counter when enabled.
#[serde(default)]
pub grid_follow_main: bool,
#[serde(default)]
pub nudge_follow_main: bool,
/// Grid lines and snapping use visible clip boundaries and memory locations while the numeric
/// Grid value is retained for when this mode is left.
#[serde(default)]
pub grid_clips_markers: bool,
#[serde(default = "yes")]
pub grid_lines: bool,
pub main_counter: TimeFormat,
@@ -254,7 +244,6 @@ fn yes() -> bool {
}
impl Default for EditState {
/// Starts the Edit window in Slip mode with independent Grid and Nudge scales.
fn default() -> Self {
EditState {
tool: Tool::Smart,
@@ -266,9 +255,6 @@ impl Default for EditState {
playhead: 0,
grid: GridValue::default(),
nudge: GridValue::Seconds(1.0),
grid_follow_main: false,
nudge_follow_main: false,
grid_clips_markers: false,
grid_lines: true,
main_counter: TimeFormat::MinSecs,
sub_counter: Some(TimeFormat::Samples),
@@ -504,138 +490,6 @@ impl Session {
soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
}
/// Whether the Edit window shows a track: unhidden, with every parent folder open.
pub fn track_shown(&self, track: &Track) -> bool {
if track.hidden {
return false;
}
let mut folder = track.folder;
for _ in 0..64 {
let Some(parent) = folder.and_then(|id| self.track(id)) else { return true };
if !parent.folder_open {
return false;
}
folder = parent.folder;
}
false
}
/// Collects unique visible active-playlist clip boundaries and memory-location boundaries.
fn clip_marker_grid_points(&self, keep: impl Fn(Samples) -> bool, excluded: &[ClipId], include_zero: bool) -> Vec<Samples> {
let mut points = Vec::new();
let excluded: std::collections::HashSet<ClipId> = excluded.iter().copied().collect();
if include_zero && keep(0) {
points.push(0);
}
for clip in
self.tracks.iter().filter(|track| self.track_shown(track)).flat_map(|track| track.clips()).filter(|clip| !excluded.contains(&clip.id))
{
points.extend([clip.start, clip.end()].into_iter().filter(|point| keep(*point)));
}
for marker in &self.markers {
if keep(marker.start) {
points.push(marker.start);
}
if marker.end != marker.start && keep(marker.end) {
points.push(marker.end);
}
}
points.sort_unstable();
points.dedup();
points
}
/// Sample-domain Timecode reference; Feet+Frames applies its origin in frames separately.
fn grid_origin(&self) -> Samples {
match self.edit.grid.scale() {
GridScale::Timecode => self.timecode_start,
_ => 0,
}
}
/// Grid lines for the current Grid value, including clip and marker boundaries.
pub fn grid_lines(&self, start: Samples, end: Samples, max: usize) -> Vec<Samples> {
if !self.edit.grid_clips_markers {
if let GridValue::FeetFrames(value) = self.edit.grid {
return value.lines(start, end, self.sample_rate, self.frame_rate, self.feet_frames_start(), max);
}
let origin = self.grid_origin();
return self
.edit
.grid
.lines(start.saturating_add(origin), end.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate, max)
.into_iter()
.map(|point| point.saturating_sub(origin))
.collect();
}
let points = self.clip_marker_grid_points(|point| point >= start && point < end, &[], true);
if points.len() <= max {
return points;
}
match max {
0 => Vec::new(),
1 => points.first().copied().into_iter().collect(),
_ => {
let span = i128::from(end).saturating_sub(i128::from(start)).max(1);
let mut bucket = None;
points
.into_iter()
.filter(|point| {
let next = i128::from(*point).saturating_sub(i128::from(start)).saturating_mul(max as i128) / span;
if bucket == Some(next) {
false
} else {
bucket = Some(next);
true
}
})
.collect()
}
}
}
/// The current Grid line at or before `position`.
pub fn grid_line_at_or_before(&self, position: Samples) -> Samples {
self.grid_line_at_or_before_excluding(position, &[])
}
/// Find the current Grid line at or before `position` without boundaries of `excluded` clips.
pub fn grid_line_at_or_before_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
if !self.edit.grid_clips_markers {
if let GridValue::FeetFrames(value) = self.edit.grid {
return value.line_at_or_before(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
}
let origin = self.grid_origin();
return self
.edit
.grid
.line_at_or_before(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate)
.saturating_sub(origin);
}
self.clip_marker_grid_points(|_| true, excluded, true).into_iter().take_while(|point| *point <= position).last().unwrap_or(position)
}
/// Snap to the nearest line in the current Grid.
pub fn snap_to_grid(&self, position: Samples) -> Samples {
self.snap_to_grid_excluding(position, &[])
}
/// Snap to the nearest current Grid line without using boundaries of `excluded` clips.
pub fn snap_to_grid_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
if !self.edit.grid_clips_markers {
if let GridValue::FeetFrames(value) = self.edit.grid {
return value.snap(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
}
let origin = self.grid_origin();
return self.edit.grid.snap(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate).saturating_sub(origin);
}
let mut points = self.clip_marker_grid_points(|_| true, excluded, false);
if !points.is_empty() {
points.push(0);
}
points.into_iter().min_by_key(|point| i128::from(*point).saturating_sub(i128::from(position)).abs()).unwrap_or(position)
}
pub fn next_track_color(&self) -> [u8; 3] {
let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count();
TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128])
@@ -908,136 +762,6 @@ mod tests {
assert_eq!(back, s);
}
/// Clips/Markers mode snaps and draws at clip and memory-location boundaries.
#[test]
fn clips_markers_grid_uses_visible_clip_edges_and_memory_locations() {
let mut s = Session::default();
let track = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 2_000, soundcraft_midi::Sequence::default());
let clip_id = clip.id;
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
let hidden = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let hidden_clip = Clip::midi(s.new_clip_id(), "Hidden", 3_200, 100, soundcraft_midi::Sequence::default());
if let Some(track) = s.track_mut(hidden) {
track.hidden = true;
track.playlist_mut().unwrap().clips.push(hidden_clip);
}
s.add_marker("Cue", MarkerKind::Marker, 2_500, 2_500);
s.add_marker("Selection", MarkerKind::Selection, 4_000, 4_500);
s.edit.grid_clips_markers = true;
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 2_500, 3_000, 4_000, 4_500]);
assert_eq!(s.grid_line_at_or_before(2_800), 2_500);
assert_eq!(s.snap_to_grid(2_800), 3_000);
assert_eq!(s.snap_to_grid(3_700), 4_000);
assert_eq!(s.grid_lines(2_400, 4_100, 2), [2_500, 4_000]);
assert_eq!(s.snap_to_grid(3_200), 3_000);
assert_eq!(s.snap_to_grid_excluding(2_800, &[clip_id]), 2_500);
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
s.track_mut(track).unwrap().folder = Some(folder);
s.track_mut(folder).unwrap().folder_open = false;
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 2_500, 4_000, 4_500]);
}
/// Open hidden folders keep their visible descendants eligible for lines and snapping.
#[test]
fn clips_markers_grid_keeps_visible_children_of_hidden_open_folders() {
let mut s = Session::default();
let outer = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
let inner = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_000, 2_000, soundcraft_midi::Sequence::default());
s.track_mut(inner).unwrap().folder = Some(outer);
s.track_mut(inner).unwrap().hidden = true;
s.track_mut(child).unwrap().folder = Some(inner);
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
s.edit.grid_clips_markers = true;
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 3_000]);
assert_eq!(s.snap_to_grid(2_800), 3_000);
assert_eq!(s.grid_line_at_or_before(2_800), 1_000);
s.track_mut(child).unwrap().hidden = true;
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
assert_eq!(s.snap_to_grid(2_800), 2_800);
s.track_mut(child).unwrap().hidden = false;
s.track_mut(inner).unwrap().folder_open = false;
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
s.track_mut(inner).unwrap().folder_open = true;
s.track_mut(outer).unwrap().folder_open = false;
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
}
/// With no real Clips/Markers boundaries, snapping leaves the position alone while drawing
/// retains the session-start line.
#[test]
fn empty_clips_markers_grid_does_not_snap_everything_to_zero() {
let mut s = Session::default();
s.edit.grid_clips_markers = true;
assert_eq!(s.grid_lines(0, 1_000, 10), [0]);
assert_eq!(s.snap_to_grid(678), 678);
}
/// Frame-based grids share the same references as their displayed counters.
#[test]
fn frame_grids_apply_session_counter_origins() {
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps30, timecode_start: 1_600, ..Session::default() };
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
assert_eq!(s.grid_line_at_or_before(47_000), 46_400);
assert_eq!(s.snap_to_grid(46_400), 46_400);
s.edit.grid = GridValue::FeetFrames(soundcraft_time::FeetFramesValue::Foot);
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
assert_eq!(s.grid_line_at_or_before(25_000), 24_000);
assert_eq!(s.snap_to_grid(24_000), 24_000);
}
/// Every Feet+Frames increment shares the parser's single rounding at fractional frame rates.
#[test]
fn feet_frames_grid_matches_parsed_positions_with_signed_frame_origins() {
use soundcraft_time::FeetFramesValue;
for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: rate, ..Session::default() };
for origin in [-17, -1, 1, 17] {
s.edit.values.insert(FEET_FRAMES_START.into(), origin as f64);
for value in FeetFramesValue::ALL {
s.edit.grid = GridValue::FeetFrames(value);
for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] {
let at = s.parse_position(&format!("{feet}+00"), TimeFormat::FeetFrames).unwrap();
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?} {value:?} origin {origin}, foot {feet}");
assert_eq!(s.snap_to_grid(at), at);
assert_eq!(s.grid_lines(at - 1, at + 2, 4), [at]);
}
}
}
}
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps29_97, ..Session::default() };
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
s.edit.grid = GridValue::FeetFrames(FeetFramesValue::Foot);
assert_eq!(s.snap_to_grid(49_649), 49_650);
assert_eq!(s.format_position(49_650, TimeFormat::FeetFrames), "2+00");
}
/// Drop-frame minute boundaries keep their counter alignment with signed session origins.
#[test]
fn drop_frame_grids_apply_session_origins_at_minute_boundaries() {
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1_800), (FrameRate::Fps59_94Drop, 3_600)] {
for origin_frame in [-1, 1] {
let sr = SampleRate::HZ_48000;
let origin = rate.samples_at_frame(origin_frame, sr);
let mut s = Session { sample_rate: sr, frame_rate: rate, timecode_start: origin, ..Session::default() };
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
for boundary in [-minute_frame, minute_frame] {
let at = rate.samples_at_frame(boundary, sr) - origin;
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?}, origin {origin_frame}");
assert_eq!(s.snap_to_grid(at + 1), at, "{rate:?}, origin {origin_frame}");
assert_eq!(s.grid_lines(at - 1, at + 2, 10), [at]);
}
}
}
}
#[test]
fn session_with_legacy_fit_height_loads() {
let mut s = Session::default();
+20 -499
View File
@@ -1,12 +1,11 @@
//! 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};
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, 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,
@@ -16,10 +15,8 @@ pub enum NoteValue {
}
impl NoteValue {
/// Every musical increment in menu order, from largest to smallest.
pub const ALL: [NoteValue; 8] = [
pub const ALL: [NoteValue; 7] = [
NoteValue::Bar,
NoteValue::Whole,
NoteValue::Half,
NoteValue::Quarter,
NoteValue::Eighth,
@@ -27,11 +24,9 @@ impl NoteValue {
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,
@@ -40,11 +35,9 @@ impl NoteValue {
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",
@@ -55,257 +48,26 @@ impl NoteValue {
}
}
/// 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).
/// Absolute seconds (Min:Secs grids: 0.001 … 10 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 } => {
@@ -318,47 +80,15 @@ impl GridValue {
}
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::Seconds(s) => format!("{s:.3} sec"),
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" }),
GridValue::Samples(n) => format!("{n} samples"),
}
}
/// 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.
/// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their
/// dot or triplet; seconds, frames and samples step by tens.
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) }
}
@@ -368,11 +98,9 @@ impl GridValue {
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::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.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),
GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples),
}
}
@@ -398,17 +126,12 @@ impl GridValue {
}
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 }
@@ -417,7 +140,6 @@ impl GridValue {
/// 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;
@@ -426,14 +148,12 @@ impl GridValue {
} 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.
// Grid lines restart at each bar line so odd meters stay aligned.
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)
let step = Self::note_ticks(value, dotted, triplet, map, bar_tick);
let k = (t - bar_tick).div_euclid(step);
map.tick_to_samples(bar_tick + k * step, sr)
}
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
}
@@ -442,60 +162,21 @@ impl GridValue {
/// 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::Frames(f) => rate.samples_at_frame(k.saturating_mul(f.clamp(1, 1_000_000)), 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,
}
let mut k = match *self {
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
GridValue::Frames(f) => rate.frames_at(pos, sr).div_euclid(f.clamp(1, 1_000_000)),
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
GridValue::Note { .. } => 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 {
@@ -511,36 +192,8 @@ impl GridValue {
(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) };
@@ -552,9 +205,6 @@ impl GridValue {
/// 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 {
@@ -571,43 +221,10 @@ impl GridValue {
}
}
/// 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);
@@ -621,29 +238,6 @@ mod tests {
}
}
/// 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();
@@ -654,33 +248,14 @@ mod tests {
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);
@@ -693,7 +268,6 @@ mod tests {
}
}
/// 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;
@@ -717,55 +291,6 @@ mod tests {
}
}
/// 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);
@@ -781,7 +306,6 @@ mod tests {
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 };
@@ -791,12 +315,9 @@ mod tests {
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::Seconds(10.0).stepped(true), GridValue::Seconds(10.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));
}
+1 -1
View File
@@ -12,7 +12,7 @@ mod tempo;
mod timecode;
pub use format::{TimeFormat, format_length, format_position, parse_position};
pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue};
pub use grid::{GridValue, NoteValue};
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
pub use timecode::{FrameRate, Timecode};
+38 -211
View File
@@ -40,34 +40,14 @@ pub struct BarBeat {
/// 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 }])
TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] }
}
}
@@ -80,43 +60,6 @@ fn valid_meter(numerator: u32, denominator: u32) -> bool {
}
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)?;
@@ -148,7 +91,6 @@ impl TempoMap {
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`.
@@ -163,7 +105,6 @@ impl TempoMap {
}
Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }),
}
self.reindex();
Ok(())
}
@@ -174,7 +115,6 @@ impl TempoMap {
}
let before = self.tempos.len();
self.tempos.retain(|e| e.tick != tick);
self.reindex();
before != self.tempos.len()
}
@@ -213,52 +153,18 @@ impl TempoMap {
}
}
/// 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.
/// Remove the meters after `tick`, returning each with the bar it starts.
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;
}
}
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
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)
};
let tick = 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() };
}
}
@@ -298,20 +204,36 @@ impl TempoMap {
}
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)
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
for e in self.tempos.iter().skip(1) {
if e.tick >= tick {
break;
}
acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
seg_tick = e.tick;
bpm = e.bpm;
}
acc + (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)
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
for e in self.tempos.iter().skip(1) {
let seg = (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
if acc + seg > s {
break;
}
acc += seg;
seg_tick = e.tick;
bpm = e.bpm;
}
seg_tick as f64 + (s - acc) / Self::samples_per_tick(bpm, sr)
}
pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 {
@@ -397,7 +319,6 @@ impl TempoMap {
e.bpm = valid_bpm(e.bpm * factor)?;
}
self.tempos = out;
self.reindex();
Ok(())
}
}
@@ -475,36 +396,6 @@ mod tests {
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();
@@ -516,24 +407,24 @@ mod tests {
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)],
);
let mut m = TempoMap {
tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
meters: 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());
let mut empty = TempoMap { tempos: Vec::new(), meters: 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 }],
);
let mut m = TempoMap {
tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
meters: 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);
@@ -555,68 +446,4 @@ mod tests {
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
View File
@@ -24,7 +24,6 @@ soundcraft-video.workspace = true
# 0.37 makes the bundled faces opt-in; the app's own font loader starts from
# `FontDefinitions::default()`, and kittest never supplies fonts to this root.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_extras = { workspace = true }
web-time.workspace = true
serde.workspace = true
serde_json.workspace = true
+1
View File
@@ -46,6 +46,7 @@ impl SoundApp {
services,
native_menu_bar: false,
autosave_dir: None,
fallback_take_dir: None,
autosaver: None,
autosaved_rev: 0,
leftovers: Vec::new(),
+23 -4
View File
@@ -155,15 +155,21 @@ impl SoundApp {
/// autosave folder. Returns the takes it records.
fn arm_recorder(&mut self) -> Result<Vec<soundcraft_engine::io::Take>, String> {
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
// Without a settings folder the takes go under the temporary folder, in a folder of this
// run's own that nobody else could have made first.
let unsaved_dir = match &self.autosave_dir {
Some(d) => d.join(name),
None => self.fallback_take_dir()?,
};
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
if plan.is_empty() {
return Err("no audio track is record-enabled".into());
}
// The takes are unsaved work: the folder they go into under the autosave root is
// owner-only, like the autosaves, before the recorder makes anything in it.
if self.engine.path.is_none() && self.autosave_dir.is_some() {
// The takes are unsaved work: on Unix the folder they go into is owner-only, like the
// autosaves, before the recorder makes anything in it (other systems get the folder's
// inherited access: no ACLs are set, so the temporary folder's own per-user access applies).
if self.engine.path.is_none() {
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
@@ -176,6 +182,19 @@ impl SoundApp {
Ok(takes)
}
/// The folder for the takes of a session never saved when there is no autosave folder, made on
/// first use: exclusively and under a name nobody can guess, so another account cannot have
/// made it first, with open permissions or as a link.
fn fallback_take_dir(&mut self) -> Result<std::path::PathBuf, String> {
if let Some(dir) = &self.fallback_take_dir {
return Ok(dir.clone());
}
let dir = soundcraft_engine::autosave::create_unique_folder(&std::env::temp_dir(), "SoundCraft")
.map_err(|e| format!("temporary take folder: {e}"))?;
self.fallback_take_dir = Some(dir.clone());
Ok(dir)
}
fn finish_recording(&mut self) {
self.engine.transport.recording = false;
if let Some(p) = &self.player {
+51 -320
View File
@@ -262,213 +262,71 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator();
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(ui, &list, &mut v);
} else {
contributors_grab_bag(ui, &list, v.names);
});
}
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(k.label().1).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ⏶" } else { " ⏷" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
/// About › Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
let h = row_height(ui);
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
});
}
@@ -548,131 +406,4 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
+1 -7
View File
@@ -167,20 +167,14 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
}
app.edit_layout.content_h = content_h;
let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y));
let grid = clips::GridLines::new(app.engine.session(), tl);
let grid_revision = app.engine.revision;
for (id, r) in app.edit_layout.rows.clone() {
let row = rect(r);
if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row));
child.set_clip_rect(clip_rect);
track_row(app, &mut child, TrackId(id), row, tl, &cols, &grid);
track_row(app, &mut child, TrackId(id), row, tl, &cols);
}
}
// Keep one consistent grid snapshot during this pass, then refresh after a row edits it.
if app.engine.revision != grid_revision {
ui.ctx().request_repaint();
}
// Below the tracks a click clears the selection and a double-click opens New Tracks.
let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max);
if empty.height() > 4.0 {
+18 -33
View File
@@ -25,41 +25,26 @@ pub(super) fn draw_strip_silence(painter: &egui::Painter, s: &Session, tl: Rect,
}
}
/// Grid positions computed once for the current viewport and shared by all visible lanes.
#[derive(Default)]
pub(super) struct GridLines {
bars: Vec<f32>,
grid: Vec<f32>,
}
impl GridLines {
/// Collect and project visible lines before drawing any lanes in this frame.
pub(super) fn new(s: &Session, tl: Rect) -> Self {
let mut lines = Self::default();
let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x));
let sr = s.sample_rate;
// Bar lines always; grid lines when enabled and dense enough.
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if bar_px > 6.0 {
lines.bars = bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600).into_iter().map(|at| x_of(s, tl, at)).collect();
pub(super) fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) {
let t = Tokens::current();
let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x));
let sr = s.sample_rate;
// Bar lines always; grid lines when enabled and dense enough.
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if bar_px > 6.0 {
let col = grid_veil(t.bar_line, t.playlist_bg);
for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) {
let x = x_of(s, tl, at);
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
}
if s.edit.grid_lines {
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if s.edit.grid_clips_markers || step_px > 7.0 {
let visible_columns = tl.width().ceil().clamp(1.0, 16_384.0) as usize;
lines.grid = s.grid_lines(view.start, view.end, visible_columns).into_iter().map(|at| x_of(s, tl, at)).collect();
}
}
lines
}
/// Draw the prepared lines on one lane, preserving bar and grid colors and stacking order.
pub(super) fn draw(&self, painter: &egui::Painter, lane: Rect) {
let t = Tokens::current();
for (positions, color) in [(&self.bars, t.bar_line), (&self.grid, t.grid_line)] {
let col = grid_veil(color, t.playlist_bg);
for &x in positions {
if s.edit.grid_lines {
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if step_px > 7.0 {
let col = grid_veil(t.grid_line, t.playlist_bg);
for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) {
let x = x_of(s, tl, at);
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
}
}
+5 -23
View File
@@ -64,8 +64,7 @@ pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at);
*delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command));
}
Some(Gesture::TrimStart { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, true, ctx.input(|i| i.modifiers.command)),
Some(Gesture::TrimEnd { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, false, ctx.input(|i| i.modifiers.command)),
Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)),
Some(Gesture::MidiClip { to, .. }) => *to = at,
Some(Gesture::Fade { clip, fade_in, to, .. }) => {
if let Some(range) = s.find_clip(*clip).and_then(|(_, c)| fade_drag_range(c, *fade_in, at)) {
@@ -207,18 +206,7 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option<Pos2>) {
/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip.
pub(super) fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples {
drag_snap_excluding(s, at, flip, &[])
}
/// Grid snapping that ignores boundaries belonging to clips changed by the gesture.
pub(super) fn drag_snap_excluding(s: &Session, at: Samples, flip: bool, excluded: &[ClipId]) -> Samples {
if s.edit.edit_mode.is_grid() != flip { s.snap_to_grid_excluding(at, excluded) } else { at }
}
/// Snap a trim while preserving the clip edge when the pointer returns to its origin.
pub(super) fn trim_drag_snap(s: &Session, clip: ClipId, at: Samples, start: bool, flip: bool) -> Samples {
let origin = s.find_clip(clip).map(|(_, clip)| if start { clip.start } else { clip.end() });
if origin == Some(at) { at } else { drag_snap_excluding(s, at, flip, &[clip]) }
if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
}
/// What a clip drag works out once per target track rather than every frame: where the tracks go
@@ -228,7 +216,6 @@ pub(super) struct DragPlan {
pub(super) dest: Option<Vec<(TrackId, TrackId)>>,
pub(super) groups: Vec<(TrackId, Samples, Vec<Samples>)>,
pub(super) moving: Vec<(TrackId, Samples, Samples)>,
moving_ids: Vec<ClipId>,
grabbed: Option<Clip>,
}
@@ -259,11 +246,10 @@ pub(super) fn drag_plan(
};
let ids: HashSet<ClipId> = clips.iter().copied().collect();
let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c);
let moving: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
let moving_ids: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| c.id)).collect();
let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default();
let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone());
let plan = Arc::new(DragPlan { dest, groups, moving, moving_ids, grabbed });
let plan = Arc::new(DragPlan { dest, groups, moving, grabbed });
ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan))));
plan
}
@@ -272,10 +258,6 @@ pub(super) fn drag_plan(
/// Shuffle lands the earliest clip bound for the target track on a slot there, as
/// `edit.move_clips` does; Grid snaps the grabbed clip.
fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples {
let track_changed = plan.dest.as_ref().is_some_and(|dest| ops::lands(dest, from) != from);
if raw == 0 && !track_changed {
return 0;
}
let mode = s.edit.edit_mode;
if mode == EditMode::Shuffle {
let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from));
@@ -286,7 +268,7 @@ fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: b
} else if mode.is_grid() != flip
&& let Some(c) = &plan.grabbed
{
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative, &plan.moving_ids)
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative)
} else {
raw
}
+6 -42
View File
@@ -115,18 +115,8 @@ fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, select
track.name.clone()
};
let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) };
let font = bold(12.5);
let galley = ui.painter().layout_no_wrap(shown_name.clone(), font.clone(), name_color);
let overflows = galley.size().x > name_r.width() - 3.0;
let (text_pos, align) =
if overflows { (pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER) } else { (name_r.center(), Align2::CENTER_CENTER) };
ui.painter().with_clip_rect(name_r).text(text_pos, align, &shown_name, font, name_color);
let mut nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag());
if overflows {
nresp = nresp.on_hover_ui(|ui| {
ui.label(&shown_name);
});
}
ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color);
let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag());
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
} else if nresp.clicked() {
@@ -200,9 +190,8 @@ fn toggle_keys(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
let h = head.height();
let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h));
let m = &track.mixer;
let is_master = track.kind == TrackKind::Master;
if h >= 37.0 && !is_master {
let (records, mixes) = (track.kind.has_playlist(), true);
if h >= 37.0 {
let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master);
let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo);
let keys = [
(HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records),
@@ -224,13 +213,10 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
let id = track.id;
let h = head.height();
let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h));
let is_master = track.kind == TrackKind::Master;
let view_y = if is_master { 20.0 } else { 39.0 };
let view_min_h = if is_master { 37.0 } else { 58.0 };
if h >= view_min_h {
if h >= 58.0 {
let vresp = header_selector(
ui,
at(17.0, view_y, 105.0, 17.0),
at(17.0, 39.0, 105.0, 17.0),
ui.scope_id().with(("view", id.0)),
&view_label(&track.view),
t.text,
@@ -239,32 +225,10 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
);
egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track));
}
if is_master && h >= 58.0 {
let mode = track.mixer.automation_mode;
let col = match mode {
soundcraft_model::AutomationMode::Read => t.auto_read,
soundcraft_model::AutomationMode::Write => t.auto_write,
soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch,
soundcraft_model::AutomationMode::Latch => t.auto_latch,
_ => t.text_dim,
};
let mode_name = format!("Automation mode for {}", track.name);
let aresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.scope_id().with(("auto_mode", id.0)), mode.label(), col, true, &mode_name);
egui::Popup::menu(&aresp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()}));
}
}
});
}
}
/// Freeze, voice and automation mode.
fn voice_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
if track.kind == TrackKind::Master {
return;
}
let t = Tokens::current();
let id = track.id;
let h = head.height();
+1 -2
View File
@@ -9,10 +9,9 @@ use super::{
// ---- interaction -----------------------------------------------------------------------------
/// Apply snapping when the current edit mode or Snap to Grid option enables it.
pub(super) fn snap(s: &Session, at: Samples) -> Samples {
let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID);
if on { s.snap_to_grid(at) } else { at }
if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
}
pub(super) fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
+1 -2
View File
@@ -222,8 +222,7 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect,
} else if resp.clicked()
&& let Some(m) = hit
{
let command = if ui.input(|i| i.modifiers.alt) { "markers.delete" } else { "markers.recall" };
let _ = app.run(command, json!({"number": m.number}));
let _ = app.run("markers.recall", json!({"number": m.number}));
}
}
}
-66
View File
@@ -2,25 +2,6 @@ use super::*;
use egui::accesskit::Role;
use egui_kittest::{Harness, kittest::Queryable};
/// A trim cannot snap to the clip's own old boundary in Clips/Markers mode.
#[test]
fn clips_markers_trim_ignores_the_trimmed_clip() {
let mut s = Session::default();
let track = s.add_track(TrackKind::Midi, soundcraft_model::ChannelFormat::Mono, None);
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 1_000, soundcraft_midi::Sequence::default());
let clip_id = clip.id;
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
s.add_marker("Inside", soundcraft_model::MarkerKind::Marker, 1_500, 1_500);
s.add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
s.edit.edit_mode = soundcraft_model::EditMode::Grid;
s.edit.grid_clips_markers = true;
assert_eq!(super::drag::drag_snap(&s, 2_100, false), 2_000);
assert_eq!(super::drag::drag_snap_excluding(&s, 2_100, false, &[clip_id]), 2_500);
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 1_000, true, false), 1_000);
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 2_000, false, false), 2_000);
}
/// The painted header selectors are interactive controls; their accessible name is the field
/// they edit, not the short value text ("Read", "Waveform") painted inside them.
#[test]
@@ -1527,50 +1508,3 @@ fn alt_click_on_a_breakpoint_deletes_it() {
assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}");
}
}
#[test]
fn alt_click_on_a_marker_deletes_it() {
let mut engine = soundcraft_engine::demo::demo_engine();
engine.session_mut().edit.rulers = vec!["markers".into()];
engine.execute("markers.add", &json!({"at": 48_000, "name": "Alt-delete me"})).unwrap();
let mut h = harness_with(engine);
let s = h.state().engine.session_arc();
let marker = s.markers.iter().find(|m| m.name == "Alt-delete me").unwrap();
let at = pos2(x_of(&s, timeline(&h), marker.start), timeline(&h).min.y - 11.5);
let before = s.markers.len();
click_with(&mut h, at, egui::Modifiers::ALT);
assert_eq!(h.state().engine.session().markers.len(), before - 1);
assert!(!h.state().engine.session().markers.iter().any(|m| m.name == "Alt-delete me"));
}
#[test]
fn short_track_name_is_centered_and_long_name_is_left_aligned() {
let mut h = harness(2);
let s = h.state().engine.session_arc();
let first = s.tracks[0].id.0;
let second = s.tracks[1].id.0;
h.state_mut().run("track.rename", json!({"track": first, "name": "Vocal"})).unwrap();
h.state_mut().run("track.rename", json!({"track": second, "name": "A Very Long Track Name That Exceeds Header Width"})).unwrap();
h.step();
let r0 = row(&h, 0);
let r1 = row(&h, 1);
let first_text = h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.text() == "Vocal" && s.clip_rect.intersects(r0) => Some((t.pos, t.galley.size())),
_ => None,
});
let long_text = h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.text() == "A Very Long Track Name That Exceeds Header Width" && s.clip_rect.intersects(r1) => Some(t.pos),
_ => None,
});
let (first_pos, first_size) = first_text.expect("short track name should be rendered in edit header");
let long_pos = long_text.expect("long track name should be rendered in edit header");
assert_eq!(long_pos.x, r1.min.x + 17.5);
let first_center_x = first_pos.x + first_size.x * 0.5;
assert!((first_center_x - (r0.min.x + 64.5)).abs() <= 0.1, "short name should be centered (center was at {first_center_x})");
}
+4 -5
View File
@@ -1,5 +1,5 @@
use super::automation::draw_automation;
use super::clips::{ClipLane, GridLines, draw_clip, draw_live_take, draw_strip_silence};
use super::clips::{ClipLane, draw_clip, draw_live_take, draw_strip_silence, grid_lines};
use super::header::{header_column, track_header};
use super::input::lane_interaction;
use super::layout::{Column, HEADER_W, LANE_H, STRIP_W, lane_rect, sample_at, x_of};
@@ -58,8 +58,7 @@ pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers
// ---- track rows ------------------------------------------------------------------------------
/// Draw one track with grid positions shared by all lanes in the current frame.
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column], grid: &GridLines) {
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) {
let t = Tokens::current();
let s = app.engine.session_arc();
let Some(track) = s.track(id) else { return };
@@ -85,7 +84,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
let s = app.engine.session_arc();
// Shown grid lines go over the clips; otherwise the bar lines stay behind them.
if !s.edit.grid_lines {
grid.draw(&painter, lane);
grid_lines(&painter, &s, tl, lane);
}
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
let auto_view = AutoParam::parse(&track.view);
@@ -99,7 +98,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
draw_live_take(app, &painter, &s, track, tl, lane);
crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);
if s.edit.grid_lines {
grid.draw(&painter, lane);
grid_lines(&painter, &s, tl, lane);
}
if let Some(p) = &auto_view {
draw_automation(&painter, &s, track, p, tl, lane);
-13
View File
@@ -176,19 +176,6 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], s);
}
}
"link_track" => {
// A link chain below two track rows, so the track link reads apart from the timeline link's plain chain.
painter.line_segment([p(r, 0.12, 0.18), p(r, 0.88, 0.18)], s);
painter.line_segment([p(r, 0.12, 0.32), p(r, 0.88, 0.32)], s);
painter.circle_stroke(p(r, 0.38, 0.72), r.width() * 0.16, s);
painter.circle_stroke(p(r, 0.62, 0.72), r.width() * 0.16, s);
}
"insertion_follows" => {
// The insertion line, a lead, and the playhead arrow it follows.
painter.line_segment([p(r, 0.3, 0.14), p(r, 0.3, 0.86)], s);
painter.line_segment([p(r, 0.42, 0.5), p(r, 0.62, 0.5)], s);
painter.add(Shape::convex_polygon(vec![p(r, 0.84, 0.5), p(r, 0.64, 0.34), p(r, 0.64, 0.66)], c, Stroke::NONE));
}
_ => {
painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle);
}
+2
View File
@@ -278,6 +278,8 @@ pub struct SoundApp {
pub native_menu_bar: bool,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
/// Where the takes of a session never saved go without an autosave folder: made once per run, under the temporary folder.
fallback_take_dir: Option<std::path::PathBuf>,
/// Writes the open session's autosaves in the background, once there is something to write.
autosaver: Option<soundcraft_engine::autosave::Autosaver>,
/// The document revision last handed to the autosaver (0 once that autosave is stale).
+1 -22
View File
@@ -11,12 +11,7 @@ pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String {
}
pub(crate) fn input_name(app: &SoundApp, track: &Track) -> String {
if track.kind.has_playlist() && track.mixer.input == Route::None {
let channels = track.format.channels();
if channels == 1 { "In 1".to_string() } else { format!("In 1-{channels}") }
} else {
route_name(app, &track.mixer.input)
}
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }
}
/// The input or output menu under `resp`. It closes only on a click outside, so its bus search
@@ -64,19 +59,3 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
ui.close();
}
}
#[cfg(test)]
mod tests {
use super::input_name;
use crate::{Services, SoundApp};
use serde_json::json;
#[test]
fn stereo_track_defaults_to_a_stereo_input_pair() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.run("track.new", json!({"format": "stereo"})).unwrap();
let track = &app.engine.session().tracks[0];
assert_eq!(input_name(&app, track), "In 1-2");
}
}
+4 -90
View File
@@ -388,17 +388,8 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
},
);
let Some((picked, footer)) = shown else { return };
// As in Pro Tools, a new send opens its Send window (moving it from any other send), so its
// fader is on screen right away. A send that only changes destination leaves the window alone.
let show_new_send = |app: &mut SoundApp| {
if !has_send {
app.ui.send_window = Some((id, slot));
}
};
if let Some(bus) = picked
&& app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus})).is_ok()
{
show_new_send(app);
if let Some(bus) = picked {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus}));
}
match footer {
Some(SendFooter::NewBus) => {
@@ -408,10 +399,8 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
{
n += 1;
}
if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok()
&& app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})).is_ok()
{
show_new_send(app);
if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
}
}
Some(SendFooter::RemoveSend) => {
@@ -475,79 +464,4 @@ mod tests {
assert_eq!(field(&h), search, "the search field stays put");
assert!((footer(&h).top() - full.top()).abs() < 1.0, "cleared, the footer is back under the full list: {:?} vs {full:?}", footer(&h));
}
/// Kick's send A slot in a test harness, with buses "Aux" and "Other"; with `existing_send`, the
/// slot already sends to "Other".
fn kick_send_harness(existing_send: bool) -> (Harness<'static, (SoundApp, TrackId)>, TrackId) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("mix.new_bus", json!({"name": "Aux"})).unwrap();
app.run("mix.new_bus", json!({"name": "Other"})).unwrap();
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
if existing_send {
app.run("mix.send", json!({"track": kick.0, "slot": 0, "bus": "Other"})).unwrap();
}
let mut ready = false;
let h = Harness::builder().with_size(vec2(600.0, 800.0)).build_ui_state(
move |ui, (app, kick): &mut (SoundApp, TrackId)| {
if !ready {
crate::fonts::install(ui.ctx(), true);
crate::theme::apply(ui.ctx(), false);
ready = true;
return;
}
let track = app.engine.session().track(*kick).cloned().unwrap();
let r = Rect::from_min_size(ui.max_rect().min + vec2(20.0, 20.0), vec2(120.0, 24.0));
send_slot(app, ui, &track, 0, r);
},
(app, kick),
);
(h, kick)
}
/// Creating a send opens its Send window, so the new send's fader is on screen right away (#1269).
#[test]
fn creating_a_send_opens_its_send_window() {
let _palette = crate::theme::lock_test_palette();
let (mut h, kick) = kick_send_harness(false);
h.run();
h.get_by_label("Send A for Kick").click();
h.run();
h.get_by_label("Aux").click();
h.run();
let (app, _) = h.state();
let send = app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten());
assert!(send.is_some(), "the send was created");
assert_eq!(app.ui.send_window, Some((kick, 0)), "the new send's window is open");
}
/// So does a send made with "new bus...".
#[test]
fn a_send_to_a_new_bus_opens_its_send_window() {
let _palette = crate::theme::lock_test_palette();
let (mut h, kick) = kick_send_harness(false);
h.run();
h.get_by_label("Send A for Kick").click();
h.run();
h.get_by_label("new bus...").click();
h.run();
let (app, _) = h.state();
assert!(app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten()).is_some(), "the send was created");
assert_eq!(app.ui.send_window, Some((kick, 0)), "the new send's window is open");
}
/// Moving an existing send to another bus keeps the Send window as it was.
#[test]
fn changing_a_sends_destination_leaves_the_send_window_alone() {
let _palette = crate::theme::lock_test_palette();
let (mut h, kick) = kick_send_harness(true);
h.run();
h.get_by_label("Send A for Kick").click();
h.run();
h.get_by_label("Aux").click();
h.run();
let (app, _) = h.state();
let target = app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten()).map(|s| route_name(app, &s.target));
assert_eq!(target.as_deref(), Some("Aux"), "the send moved");
assert_eq!(app.ui.send_window, None, "no window opened");
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub(super) fn about(app: &mut SoundApp, ctx: &egui::Context) {
return;
}
let tab_id = egui::Id::unique("about_tab");
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(820.0, 420.0)).collapsible(false).show(ctx, |ui| {
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
for (i, l) in (0u8..).zip(["About", "Contributors", "Models"]) {
+4 -24
View File
@@ -46,28 +46,9 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
if crate::widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(plus)), plus.size(), "plus", false, &tip).clicked() {
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
}
let tracks: Vec<(u64, String, bool, [u8; 3], bool, usize)> = {
let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
s.tracks
.iter()
.map(|x| {
let mut current = x.folder;
let mut depth = 0;
let mut seen = Vec::new();
while let Some(folder_id) = current {
if seen.contains(&folder_id) {
break;
}
seen.push(folder_id);
let Some(folder) = s.track(folder_id) else {
break;
};
depth += 1;
current = folder.folder;
}
(x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id), depth)
})
.collect()
s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect()
};
let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden;
let lists = with_pending_offset(egui::ScrollArea::vertical().id_salt("tracks_scroll"), ui, "tracks_scroll")
@@ -75,7 +56,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
.auto_shrink([false, false])
.scroll_bar_visibility(hidden);
let out = lists.show(ui, |ui| {
for (id, name, shown, color, sel, depth) in tracks {
for (id, name, shown, color, sel) in tracks {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, &name));
if sel {
@@ -89,8 +70,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().with_clip_rect(r).text(
// Indent stops growing at eight levels so a deep name keeps room in the column.
pos2(r.min.x + 30.0 + depth.min(8) as f32 * 10.0, r.center().y),
pos2(r.min.x + 30.0, r.center().y),
Align2::LEFT_CENTER,
&name,
regular(11.5),
+34 -369
View File
@@ -3,11 +3,11 @@
use crate::theme::{Tokens, bold, mono, regular};
use crate::widgets::{icon_button, key, key_fill};
use crate::{SoundApp, icons};
use egui::{Align2, Color32, CornerRadius, Painter, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID};
use soundcraft_model::{EditMode, EditState, Session, Tool};
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, NoteValue, TimeFormat, TimecodeValue, format_length};
use soundcraft_model::{EditMode, Tool};
use soundcraft_time::{TimeFormat, format_length};
pub const HEIGHT: f32 = 78.0;
@@ -205,7 +205,14 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let e = app.engine.session().edit.clone();
for (id, icon, on, tip) in option_toggles(&e) {
let toggles: [(&str, &str, bool, &str); 5] = [
("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"),
("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),
("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"),
("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),
("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"),
];
for (id, icon, on, tip) in toggles {
if icon_button(ui, vec2(36.0, 18.0), icon, on, tip).clicked() {
let _ = app.run(id, json!({}));
}
@@ -220,18 +227,6 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
});
}
/// The Edit-window toolbar's option toggles: (command id, icon, on, tooltip).
fn option_toggles(e: &EditState) -> [(&'static str, &'static str, bool, &'static str); 6] {
[
("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"),
("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),
("options.link_track_edit", "link_track", e.link_track_edit, "Link Track and Edit Selection"),
("options.insertion_follows_playback", "insertion_follows", e.insertion_follows_playback, "Insertion Follows Playback"),
("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),
("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"),
]
}
fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
let s = app.engine.session();
s.format_position(at, f)
@@ -281,12 +276,12 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
}
/// Draws the compact Grid and Nudge readouts and their scale menus.
/// Grid-line toggle, grid value menu and nudge value menu.
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (grid, nudge, lines, grid_follow, nudge_follow, clips_markers) = {
let (grid, nudge, lines) = {
let e = &app.engine.session().edit;
(e.grid, e.nudge, e.grid_lines, e.grid_follow_main, e.nudge_follow_main, e.grid_clips_markers)
(e.grid, e.nudge, e.grid_lines)
};
let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
@@ -298,243 +293,39 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let _ = app.run("view.grid_lines", json!({}));
}
let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
let grid_label = if clips_markers { GridScale::ClipsMarkers.label().to_string() } else { compact_grid_label(app.engine.session(), grid) };
if !clips_markers {
draw_compact_note_icon(ui.painter(), gr, grid, t.counter_text);
}
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid_label, mono(11.0), t.counter_text);
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text);
let gresp = ui.interact(gr, ui.scope_id().with("grid_value"), Sense::click());
value_menu(app, &gresp, "view.grid", grid, grid_follow, clips_markers, true);
value_menu(app, &gresp, "view.grid");
ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label);
let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0));
draw_compact_note_icon(ui.painter(), nr, nudge, t.counter_text);
ui.painter().text(
pos2(nr.max.x - 14.0, nr.center().y),
Align2::RIGHT_CENTER,
compact_grid_label(app.engine.session(), nudge),
mono(11.0),
t.counter_text,
);
ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text);
let nresp = ui.interact(nr, ui.scope_id().with("nudge_value"), Sense::click());
value_menu(app, &nresp, "view.nudge", nudge, nudge_follow, false, false);
value_menu(app, &nresp, "view.nudge");
for rr in [gr, nr] {
icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
}
}
/// Formats a Grid/Nudge increment in the compact counter style for its time scale.
fn compact_grid_label(session: &Session, value: GridValue) -> String {
match value {
GridValue::Note { value, dotted, triplet } => {
let at_tick = session.tempo.samples_to_ticks(session.edit.selection.start, session.sample_rate);
let meter = session.tempo.meter_at_tick(at_tick);
let mut ticks = if value == NoteValue::Bar { meter.ticks_per_bar() } else { value.ticks() };
if dotted {
ticks = ticks * 3 / 2;
}
if triplet {
ticks = ticks * 2 / 3;
}
let per_bar = meter.ticks_per_bar().max(1);
let per_beat = meter.ticks_per_beat().max(1);
let bars = ticks / per_bar;
let remaining = ticks % per_bar;
format!("{bars}|{}|{:03}", remaining / per_beat, remaining % per_beat)
}
GridValue::Seconds(seconds) => {
let samples = session.sample_rate.samples(seconds);
format_length(session.edit.selection.start, samples, TimeFormat::MinSecs, session.sample_rate, &session.tempo, session.frame_rate)
}
GridValue::Frames(frames) => {
let samples = session.frame_rate.samples_at_frame(frames, session.sample_rate);
format!(
"{}.00",
format_length(session.edit.selection.start, samples, TimeFormat::Timecode, session.sample_rate, &session.tempo, session.frame_rate)
)
}
GridValue::Timecode(value) => match value {
TimecodeValue::Second => "00:00:01:00.00",
TimecodeValue::FiveFrames => "00:00:00:05.00",
TimecodeValue::Frame => "00:00:00:01.00",
TimecodeValue::HalfFrame => "00:00:00:00.50",
TimecodeValue::QuarterFrame => "00:00:00:00.25",
TimecodeValue::SubFrame => "00:00:00:00.01",
}
.to_string(),
GridValue::FeetFrames(value) => match value {
FeetFramesValue::Foot => "1+00",
FeetFramesValue::Frame => "0+01",
FeetFramesValue::QuarterFrame => "0+00.25",
FeetFramesValue::SubFrame => "0+00.01",
}
.to_string(),
GridValue::Samples(samples) => samples.to_string(),
}
}
/// Draws the musical symbol beside a compact Bars|Beats value.
fn draw_compact_note_icon(painter: &Painter, rect: Rect, value: GridValue, color: Color32) {
let GridValue::Note { value, dotted, triplet } = value else { return };
draw_note_value_icon(painter, Rect::from_min_size(pos2(rect.min.x + 1.0, rect.min.y + 2.0), vec2(14.0, 16.0)), value, dotted, triplet, color);
}
/// Draws a musical duration and its optional dotted or triplet modifier.
fn draw_note_value_icon(painter: &Painter, rect: Rect, value: NoteValue, dotted: bool, triplet: bool, color: Color32) {
let stroke = Stroke::new(1.2, color);
let head = pos2(rect.min.x + 4.0, rect.max.y - 3.5);
if value == NoteValue::Bar {
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 2.0), pos2(rect.min.x + 2.0, rect.max.y - 2.0)], stroke);
painter.line_segment([pos2(rect.min.x + 5.0, rect.min.y + 2.0), pos2(rect.min.x + 5.0, rect.max.y - 2.0)], stroke);
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 3.0), pos2(rect.min.x + 10.0, rect.min.y + 3.0)], stroke);
} else {
let hollow = matches!(value, NoteValue::Whole | NoteValue::Half);
if hollow {
painter.circle_stroke(head, 2.2, stroke);
} else {
painter.circle_filled(head, 2.2, color);
}
if value != NoteValue::Whole {
let stem_x = head.x + 2.0;
painter.line_segment([pos2(stem_x, head.y), pos2(stem_x, rect.min.y + 2.0)], stroke);
let flags = match value {
NoteValue::Eighth => 1,
NoteValue::Sixteenth => 2,
NoteValue::ThirtySecond => 3,
NoteValue::SixtyFourth => 4,
_ => 0,
};
for flag in 0..flags {
let y = rect.min.y + 2.0 + flag as f32 * 2.0;
painter.line_segment([pos2(stem_x, y), pos2(stem_x + 4.0, y + 2.5)], stroke);
}
}
}
if dotted {
painter.circle_filled(pos2(rect.max.x - 1.5, head.y), 1.0, color);
}
if triplet {
painter.text(pos2(rect.min.x, rect.min.y), Align2::LEFT_TOP, "3", regular(7.0), color);
}
}
/// Shows the complete Grid or Nudge increment, scale, and follow-mode menu.
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str, current: GridValue, follow_main: bool, clips_markers: bool, allow_clips: bool) {
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) {
egui::Popup::menu(resp).show(|ui| {
ui.set_min_width(210.0);
match current.scale() {
GridScale::BarsBeats => {
let selected = match current {
GridValue::Note { value, .. } => value,
_ => NoteValue::Sixteenth,
};
let suffix = match current {
GridValue::Note { dotted: true, .. } => ".",
GridValue::Note { triplet: true, .. } => "t",
_ => "",
};
for value in NoteValue::ALL {
let id = match value {
NoteValue::Bar => "1 bar",
NoteValue::Whole => "whole",
NoteValue::Half => "1/2",
NoteValue::Quarter => "1/4",
NoteValue::Eighth => "1/8",
NoteValue::Sixteenth => "1/16",
NoteValue::ThirtySecond => "1/32",
NoteValue::SixtyFourth => "1/64",
};
menu_note_value(ui, app, cmd, selected == value, value, json!(format!("{id}{suffix}")));
}
ui.separator();
let (value, dotted, triplet) = match current {
GridValue::Note { value, dotted, triplet } => (value, dotted, triplet),
_ => (NoteValue::Sixteenth, false, false),
};
let base = match value {
NoteValue::Bar => "1 bar",
NoteValue::Whole => "whole",
NoteValue::Half => "1/2",
NoteValue::Quarter => "1/4",
NoteValue::Eighth => "1/8",
NoteValue::Sixteenth => "1/16",
NoteValue::ThirtySecond => "1/32",
NoteValue::SixtyFourth => "1/64",
};
menu_modifier(ui, app, cmd, dotted, "Dotted", "·", json!(if dotted { base.to_string() } else { format!("{base}.") }));
menu_modifier(ui, app, cmd, triplet, "Triplet", "3", json!(if triplet { base.to_string() } else { format!("{base}t") }));
for v in ["1 bar", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/8t", "1/16t", "1/4."] {
if ui.button(v).clicked() {
let _ = app.run(cmd, json!({"value": v}));
}
GridScale::MinSecs => {
for seconds in [1.0, 0.5, 0.1, 0.01, 0.001] {
let value = GridValue::Seconds(seconds);
menu_value(ui, app, cmd, current == value, &value.label(), json!({"seconds": seconds}));
}
}
GridScale::Timecode => {
for value in TimecodeValue::ALL {
let selected = current == GridValue::Timecode(value)
|| matches!(
(current, value),
(GridValue::Frames(5), TimecodeValue::FiveFrames) | (GridValue::Frames(1), TimecodeValue::Frame)
);
menu_value(ui, app, cmd, selected, value.label(), json!({"timecode": value.id()}));
}
}
GridScale::FeetFrames => {
for value in FeetFramesValue::ALL {
menu_value(ui, app, cmd, current == GridValue::FeetFrames(value), value.label(), json!({"feet_frames": value.id()}));
}
}
GridScale::Samples => {
for samples in [10_000, 1_000, 100, 10, 2, 1] {
let value = GridValue::Samples(samples);
menu_value(ui, app, cmd, current == value, &value.label(), json!({"samples": samples}));
}
}
GridScale::ClipsMarkers => {}
}
ui.separator();
for scale in GridScale::TIME {
let selected = !clips_markers && current.scale() == scale;
if ui.selectable_label(selected, scale.label()).clicked() {
let _ = app.run(cmd, json!({"format": scale.id(), "follow_main": false}));
ui.close();
for (label, v) in [
("1 msec", json!({"seconds": 0.001})),
("10 msec", json!({"seconds": 0.01})),
("100 msec", json!({"seconds": 0.1})),
("1 second", json!({"seconds": 1.0})),
("1 frame", json!({"frames": 1})),
("100 samples", json!({"samples": 100})),
] {
if ui.button(label).clicked() {
let _ = app.run(cmd, json!({"value": v}));
}
}
if allow_clips && ui.selectable_label(clips_markers, GridScale::ClipsMarkers.label()).clicked() {
let _ = app.run(cmd, json!({"format": GridScale::ClipsMarkers.id(), "follow_main": false}));
ui.close();
}
ui.separator();
if ui.selectable_label(follow_main, "Follow Main Time Scale").clicked() {
let _ = app.run(cmd, json!({"follow_main": !follow_main}));
ui.close();
}
});
}
/// Adds one text menu value and dispatches its command when selected.
fn menu_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, value: serde_json::Value) {
if ui.selectable_label(selected, label).clicked() {
let _ = app.run(cmd, json!({"value": value}));
ui.close();
}
}
/// Adds one musical value with its duration symbol.
fn menu_note_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, note: NoteValue, value: serde_json::Value) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
draw_note_value_icon(ui.painter(), rect, note, false, false, ui.visuals().text_color());
menu_value(ui, app, cmd, selected, note.label(), value);
});
}
/// Adds a dotted or triplet modifier with its compact symbol.
fn menu_modifier(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, symbol: &str, value: serde_json::Value) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
ui.painter().text(rect.center(), Align2::CENTER_CENTER, symbol, regular(11.0), ui.visuals().text_color());
menu_value(ui, app, cmd, selected, label, value);
});
}
@@ -594,7 +385,8 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
});
}
/// Count-off, meter and tempo readouts; the tempo value edits the event at or before the playhead.
/// Count-off, meter and tempo readouts; the tempo value is editable in place for the tempo event at or before the
/// playhead.
fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (bpm, meter, countoff, click, bars) = {
@@ -656,7 +448,6 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) {
resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it");
}
/// Draws the compact stereo main-output meter.
fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover());
let m = app.main_meter;
@@ -666,129 +457,3 @@ fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use egui_kittest::{Harness, kittest::Queryable};
/// Builds a compact Grid/Nudge toolbar test harness.
fn harness() -> Harness<'static, SoundApp> {
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let mut harness = Harness::builder().with_size(vec2(700.0, 700.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let edit = &app.engine.session().edit;
let (grid, grid_follow, clips, nudge, nudge_follow) =
(edit.grid, edit.grid_follow_main, edit.grid_clips_markers, edit.nudge, edit.nudge_follow_main);
let grid_anchor = ui.button("Grid menu");
value_menu(app, &grid_anchor, "view.grid", grid, grid_follow, clips, true);
let nudge_anchor = ui.button("Nudge menu");
value_menu(app, &nudge_anchor, "view.nudge", nudge, nudge_follow, false, false);
},
app,
);
harness.run_steps(3);
harness
}
/// Opens a toolbar value menu and lets the popup settle.
fn open(harness: &mut Harness<SoundApp>, label: &str) {
harness.get_by_label(label).click();
harness.run_steps(3);
}
/// Compact values use their corresponding counter formats and symbols.
#[test]
fn compact_grid_values_use_their_counter_formats() {
let session = Session::default();
let note = |value, dotted, triplet| GridValue::Note { value, dotted, triplet };
assert_eq!(compact_grid_label(&session, note(NoteValue::Quarter, false, false)), "0|1|000");
assert_eq!(compact_grid_label(&session, note(NoteValue::Eighth, false, true)), "0|0|320");
assert_eq!(compact_grid_label(&session, GridValue::Seconds(0.001)), "0:00.001");
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::Second)), "00:00:01:00.00");
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::QuarterFrame)), "00:00:00:00.25");
assert_eq!(compact_grid_label(&session, GridValue::FeetFrames(FeetFramesValue::Foot)), "1+00");
assert_eq!(compact_grid_label(&session, GridValue::Samples(10_000)), "10000");
}
/// The Grid menu exposes every scale and retains its numeric value in Clips/Markers mode.
#[test]
fn grid_menu_exposes_each_scale_and_keeps_the_increment_in_clips_markers_mode() {
let mut harness = harness();
open(&mut harness, "Grid menu");
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Clips/Markers", "Follow Main Time Scale"] {
let _ = harness.get_by_label(label);
}
for label in ["1 bar", "Whole note", "1/2 note", "1/4 note", "1/8 note", "1/16 note", "1/32 note", "1/64 note", "Dotted", "Triplet"] {
let _ = harness.get_by_label(label);
}
harness.get_by_label("Timecode").click();
harness.run_steps(3);
open(&mut harness, "Grid menu");
for label in ["1 second", "5 frames", "1 frame", "1/2 frame", "1/4 frame", "1 subframe"] {
let _ = harness.get_by_label(label);
}
harness.get_by_label("1/4 frame").click();
harness.run_steps(3);
open(&mut harness, "Grid menu");
harness.get_by_label("Clips/Markers").click();
harness.run_steps(3);
let edit = &harness.state().engine.session().edit;
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
assert!(edit.grid_clips_markers);
open(&mut harness, "Grid menu");
harness.get_by_label("1/2 frame").click();
harness.run_steps(3);
let edit = &harness.state().engine.session().edit;
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::HalfFrame));
assert!(!edit.grid_clips_markers);
}
/// Selecting another musical increment preserves the active dotted or triplet modifier.
#[test]
fn note_menu_keeps_the_current_modifier_when_changing_value() {
let mut harness = harness();
harness.state_mut().run("view.grid", json!({"value": "1/8t"})).unwrap();
harness.run_steps(3);
open(&mut harness, "Grid menu");
harness.get_by_label("1/16 note").click();
harness.run_steps(3);
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: true });
open(&mut harness, "Grid menu");
harness.get_by_label("Whole note").click();
harness.run_steps(3);
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: true });
}
/// The Nudge menu exposes every numeric scale without the Grid-only Clips/Markers mode.
#[test]
fn nudge_menu_has_every_numeric_scale_but_no_clips_markers_mode() {
let mut harness = harness();
open(&mut harness, "Nudge menu");
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Follow Main Time Scale"] {
let _ = harness.get_by_label(label);
}
assert_eq!(harness.query_all_by_label("Clips/Markers").count(), 0);
harness.get_by_label("Samples").click();
harness.run_steps(3);
open(&mut harness, "Nudge menu");
for label in ["10000 samples", "1000 samples", "100 samples", "10 samples", "2 samples", "1 sample"] {
let _ = harness.get_by_label(label);
}
}
/// #1262: the two link toggles differ only by their icon, so each option toggle needs its own.
#[test]
fn option_toggles_have_distinct_icons() {
let toggles = option_toggles(&EditState::default());
let timeline = toggles.iter().find(|t| t.0 == "options.link_timeline_edit").unwrap();
let track = toggles.iter().find(|t| t.0 == "options.link_track_edit").unwrap();
assert_ne!(timeline.1, track.1, "the timeline and track links need different icons");
let mut icons: Vec<&str> = toggles.iter().map(|t| t.1).collect();
icons.sort_unstable();
icons.dedup();
assert_eq!(icons.len(), toggles.len(), "every option toggle needs its own icon");
}
}
+7
View File
@@ -90,6 +90,13 @@ impl Worker {
}
fn open_movie(path: &str) -> Result<Movie, String> {
// The last check before the file system: the path came from a session file, so the read
// roots apply, and only a regular file is opened (a pipe would hold the video worker
// forever).
soundcraft_engine::file_access::check_read(path)?;
if !std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.is_file() {
return Err(format!("{path}: not a regular file"));
}
#[cfg(not(target_arch = "wasm32"))]
{
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
-93
View File
@@ -1,6 +1,5 @@
use egui::{Color32, FullOutput, Pos2, RawInput, Rect, Shape, pos2, vec2};
use serde_json::json;
use soundcraft_model::{ChannelFormat, Clip, TrackId, TrackKind};
use soundcraft_ui_egui::theme::Tokens;
use soundcraft_ui_egui::{Services, SoundApp, edit_window};
@@ -23,7 +22,6 @@ fn near(a: [i32; 3], b: Color32) -> bool {
a.iter().zip([b.r(), b.g(), b.b()]).all(|(x, y)| (x - i32::from(y)).abs() <= 6)
}
/// Numeric Grid lines retain their stacking order over clips in both themes.
#[test]
fn grid_lines_are_drawn_over_clips_while_shown() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -70,94 +68,3 @@ fn grid_lines_are_drawn_over_clips_while_shown() {
}
}
}
/// Clips/Markers grid lines reach the timeline at memory-location boundaries.
#[test]
fn clips_markers_grid_draws_a_line_at_a_memory_location() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
let kick = app.engine.session().track_by_name("Kick").unwrap();
let clip = &kick.clips()[0];
let marker = clip.start + clip.length / 3;
app.run("markers.add", json!({"at": marker, "name": "Grid target"})).unwrap();
app.run("view.grid", json!({"format": "clips_markers", "lines": true})).unwrap();
frame(&ctx, &mut app);
let out = frame(&ctx, &mut app);
let session = app.engine.session();
let kick = session.track_by_name("Kick").unwrap();
let [x0, y0, x1, y1] = app.edit_layout.timeline;
let timeline = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1;
let lane = Rect::from_min_max(pos2(timeline.min.x, row[1]), pos2(timeline.max.x, row[1] + kick.height.points()));
let expected_x = edit_window::x_of(session, timeline, marker);
assert!(out.shapes.iter().any(|shape| {
matches!(
&shape.shape,
Shape::LineSegment { points: [a, b], .. }
if a.x == expected_x && b.x == expected_x && a.y == lane.min.y && b.y == lane.max.y
)
}));
}
/// Whether a vertical grid line spans the main lane of a visible track at the requested sample.
fn line_on_track(out: &FullOutput, app: &SoundApp, track: TrackId, at: i64) -> bool {
let s = app.engine.session();
let [x0, y0, x1, y1] = app.edit_layout.timeline;
let timeline = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == track.0).unwrap().1;
let bottom = row[1] + s.track(track).unwrap().height.points();
let x = edit_window::x_of(s, timeline, at);
out.shapes.iter().any(|shape| {
matches!(&shape.shape, Shape::LineSegment { points: [a, b], .. }
if a.x == x && b.x == x && a.y == row[1] && b.y == bottom)
})
}
/// Shared frame lines reflect visible children, edited boundaries, scrolling and zoom on every lane.
#[test]
fn clips_markers_lines_refresh_across_lanes_and_hidden_open_folders() {
let mut engine = soundcraft_engine::Engine::default();
let s = engine.session_mut();
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let other = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
s.track_mut(folder).unwrap().hidden = true;
s.track_mut(child).unwrap().folder = Some(folder);
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_200, 5_000, Default::default());
let clip_id = clip.id;
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, 9_000, 9_000);
s.edit.grid_clips_markers = true;
s.edit.grid_lines = true;
let mut app = SoundApp::new(engine, None, Services::default());
let ctx = egui::Context::default();
for (start, marker, scroll, zoom) in [(1_200, 9_000, 0, 20.0), (3_000, 11_000, 0, 20.0), (3_000, 11_000, 1_000, 40.0)] {
let s = app.engine.session_mut();
s.find_clip_mut(clip_id).unwrap().start = start;
s.markers[0].start = marker;
s.markers[0].end = marker;
s.edit.zoom.scroll = scroll;
s.edit.zoom.samples_per_px = zoom;
frame(&ctx, &mut app);
let out = frame(&ctx, &mut app);
assert!(!app.edit_layout.rows.iter().any(|(id, _)| *id == folder.0));
for track in [child, other] {
assert!(line_on_track(&out, &app, track, start));
assert!(line_on_track(&out, &app, track, marker));
if start != 1_200 {
assert!(!line_on_track(&out, &app, track, 1_200));
assert!(!line_on_track(&out, &app, track, 9_000));
}
}
}
app.engine.session_mut().track_mut(folder).unwrap().folder_open = false;
frame(&ctx, &mut app);
let out = frame(&ctx, &mut app);
assert!(!app.edit_layout.rows.iter().any(|(id, _)| *id == child.0));
assert!(!line_on_track(&out, &app, other, 3_000));
assert!(line_on_track(&out, &app, other, 11_000));
}
+1 -29
View File
@@ -1,6 +1,5 @@
//! A send's level opens its Send window, as in Pro Tools: a fader with Alt-click for 0 dB, moved to
//! another send by clicking that send's level, closed by clicking the same one again. Creating a send
//! opens it too.
//! another send by clicking that send's level, closed by clicking the same one again.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
@@ -76,33 +75,6 @@ fn a_sends_level_opens_its_send_window() {
assert!(h.query_by_label_contains("Vox · Send").is_none());
}
/// Creating a send opens its Send window, so the new send's fader is on screen right away (#1269). The
/// window moves there from the send it was showing, and undoing the new send closes it.
#[test]
fn a_new_send_shows_its_fader_at_once() {
let (mut h, vox) = harness();
h.get_by_label("Send A level for Vox").click();
h.run_steps(3);
assert!(h.query_by_label_contains("Vox · Send A").is_some());
h.get_by_label("Send C for Vox").click();
h.run_steps(3);
// Send A's own "Verb" label is on screen too, so pick the menu's button.
h.get_by_role_and_label(egui::accesskit::Role::Button, "Verb").click();
h.run_steps(3);
assert!(h.state().engine.session().tracks[0].mixer.sends[2].is_some(), "send C was created");
assert_eq!(h.state().ui.send_window, Some((vox, 2)));
assert!(h.query_by_label_contains("Vox · Send C").is_some(), "the window shows the new send");
assert!(h.query_by_label_contains("Vox · Send A").is_none(), "and no longer send A");
assert!(h.query_by_label("Send level").is_some(), "the new send's fader is on screen");
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
h.run_steps(3);
assert!(h.state().engine.session().tracks[0].mixer.sends[2].is_none(), "the send was undone");
assert_eq!(h.state().ui.send_window, None, "undoing the send closes its window");
assert!(h.query_by_label_contains("Vox · Send").is_none());
}
/// Escape first closes a send menu or cancels level typing, then a further Escape closes the send window.
#[test]
fn escape_closes_the_send_window_after_menus_and_typing() {
-42
View File
@@ -50,16 +50,6 @@ impl Rig {
.expect("row not in the Tracks list")
}
fn text_x(&self, name: &str) -> f32 {
self.shapes
.iter()
.find_map(|c| match &c.shape {
Shape::Text(t) if t.galley.text() == name && t.pos.x < 150.0 => Some(t.pos.x),
_ => None,
})
.expect("text not in the Tracks list")
}
fn press(&mut self, at: Pos2, pressed: bool) {
self.frame(vec![
Event::PointerMoved(at),
@@ -100,35 +90,3 @@ fn clicking_a_track_list_row_still_selects_the_track() {
r.press(at, false);
assert_eq!(r.app.engine.session().edit.selected_tracks, vec![r.order()[2]]);
}
#[test]
fn a_track_inside_a_folder_is_indented_in_the_track_list() {
let mut r = Rig::new();
let child = r.order()[1];
let child_name = r.app.engine.session().track(child).unwrap().name.clone();
r.app.run("track.move_to_new_folder", json!({"tracks": [child.0], "name": "Folder"})).unwrap();
r.frame(vec![]);
let folder = r.app.engine.session().tracks.iter().find(|track| track.is_folder()).unwrap();
assert!(r.text_x(&child_name) > r.text_x(&folder.name), "child is not indented under the folder");
}
#[test]
fn deeply_nested_tracks_keep_indenting_past_six_folders() {
let mut r = Rig::new();
let child = r.order()[1];
let child_name = r.app.engine.session().track(child).unwrap().name.clone();
r.app.run("track.move_to_new_folder", json!({"tracks": [child.0], "name": "Folder 1"})).unwrap();
let mut parent = r.app.engine.session().tracks.iter().find(|track| track.name == "Folder 1").unwrap().id;
for depth in 2..=7 {
r.app.run("track.move_to_new_folder", json!({"tracks": [parent.0], "name": format!("Folder {depth}")})).unwrap();
parent = r.app.engine.session().tracks.iter().find(|track| track.name == format!("Folder {depth}")).unwrap().id;
}
r.frame(vec![]);
// "Folder 1" is the child's own folder and the seventh level; "Folder 7" is the outermost.
assert_eq!(r.app.engine.session().track(parent).unwrap().name, "Folder 7");
assert!(r.text_x(&child_name) > r.text_x("Folder 1"), "deeply nested child is not indented under its own folder");
assert!(r.text_x("Folder 1") > r.text_x("Folder 2"), "each level indents further");
}
+14 -135
View File
@@ -72,22 +72,6 @@ impl Dpb {
self.entries.retain(|e| e.needed_for_output || e.mark != RefMark::Unused);
}
/// Make room for a picture: output one (C.4.5.3), or, when none waits for output and
/// references alone fill the DPB (marking that keeps more than it holds), drop the reference
/// decoded longest ago. False when the DPB is empty.
fn make_room(&mut self, out: &mut Vec<Output>) -> bool {
if self.bump(out) {
return true;
}
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.mark != RefMark::Unused).min_by_key(|(_, e)| e.frame.id).map(|(i, _)| i)
else {
return false;
};
self.entries[i].mark = RefMark::Unused;
self.prune();
true
}
/// Output the picture with the smallest POC (C.4.5.3 "bumping"). Returns false if none waits.
fn bump(&mut self, out: &mut Vec<Output>) -> bool {
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.needed_for_output).min_by_key(|(_, e)| e.poc).map(|(i, _)| i) else {
@@ -150,29 +134,21 @@ impl Dpb {
poc_state: &mut dyn FnMut(u32),
) {
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
// Only the last `max_num_ref_frames` missing frames can still be references when the gap
// ends: the sliding window drops the earlier ones first, so building their pictures would
// be work (up to 65 535 frame copies from one slice header) thrown straight away. They
// advance the POC state only.
let gap = (frame_num + max_frame_num - unused) % max_frame_num;
let skip = gap.saturating_sub(u32::try_from(max_num_ref_frames.max(1)).unwrap_or(u32::MAX));
let mut guard = 0;
while unused != frame_num && guard < max_frame_num {
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
let (frame, meta) = make_frame(unused);
poc_state(unused);
if guard >= skip {
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
let (frame, meta) = make_frame(unused);
self.entries.push(DpbEntry {
poc: frame.poc,
frame,
frame_num: unused,
long_term_frame_idx: 0,
mark: RefMark::Short,
needed_for_output: false,
non_existing: true,
meta,
});
}
self.entries.push(DpbEntry {
poc: frame.poc,
frame,
frame_num: unused,
long_term_frame_idx: 0,
mark: RefMark::Short,
needed_for_output: false,
non_existing: true,
meta,
});
unused = (unused + 1) % max_frame_num;
guard += 1;
}
@@ -301,11 +277,6 @@ impl Dpb {
}
}
}
3 | 6 if self.max_long_term_frame_idx.is_none_or(|max| m.long_term_frame_idx > max) => {
// A long-term index outside what MMCO 4 (or the IDR) allowed (8.2.5.4.3): a
// conforming stream never sends one, and honouring it would let distinct
// indices retain references past the DPB's capacity.
}
3 => {
let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1);
for e in &mut self.entries {
@@ -413,13 +384,13 @@ impl Dpb {
out.push(Output { frame, poc, meta });
return;
}
if !self.make_room(out) {
if !self.bump(out) {
break;
}
}
} else {
while self.entries.len() >= self.capacity {
if !self.make_room(out) {
if !self.bump(out) {
break;
}
}
@@ -458,98 +429,6 @@ mod tests {
Mmco { op, difference_of_pic_nums_minus1: diff, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }
}
fn frame(id: u32) -> (FrameRef, Arc<OutputMeta>) {
let meta = OutputMeta {
pts: 0,
key: false,
crop: (0, 0, 0, 0),
full_range: false,
colour_primaries: 0,
transfer_characteristics: 0,
matrix_coefficients: 0,
sar: (1, 1),
draft: false,
};
(Arc::new(crate::h264::picture::Frame::new(id, 0, 1, 1)), Arc::new(meta))
}
/// A P slice header of a reference picture, with nothing else set.
fn reference_slice() -> SliceHeader {
SliceHeader {
nal_ref_idc: 1,
nal_unit_type: 1,
idr: false,
first_mb_in_slice: 0,
slice_type_raw: 0,
slice_type: SliceType::P,
pps_id: 0,
colour_plane_id: 0,
frame_num: 0,
field_pic: false,
bottom_field: false,
idr_pic_id: 0,
pic_order_cnt_lsb: 0,
delta_pic_order_cnt_bottom: 0,
delta_pic_order_cnt: [0; 2],
redundant_pic_cnt: 0,
direct_spatial_mv_pred: false,
num_ref_idx_active: [1, 0],
ref_pic_list_mod: [Vec::new(), Vec::new()],
pred_weight_table: None,
no_output_of_prior_pics: false,
long_term_reference: false,
adaptive_ref_pic_marking: false,
mmcos: Vec::new(),
cabac_init_idc: 0,
slice_qp_delta: 0,
sp_for_switch: false,
slice_qs_delta: 0,
disable_deblocking_filter_idc: 0,
slice_alpha_c0_offset_div2: 0,
slice_beta_offset_div2: 0,
slice_group_change_cycle: 0,
header_bits: 0,
}
}
#[test]
fn a_frame_num_gap_builds_only_the_frames_that_stay_references() {
let mut dpb = Dpb::new();
let (mut built, mut poc_updates) = (Vec::new(), 0u32);
// frame_num 0 was the last reference; the next slice says 60_000: 59 999 missing frames.
dpb.fill_frame_num_gap(0, 60_000, 1 << 16, 4, &mut |n| (built.push(n), frame(n)).1, &mut |_| poc_updates += 1);
assert_eq!(poc_updates, 59_999, "every missing frame advances the POC state");
assert_eq!(built, vec![59_996, 59_997, 59_998, 59_999], "only the ones the sliding window would keep are built");
assert_eq!(dpb.entries.iter().map(|e| e.frame_num).collect::<Vec<_>>(), built);
// A gap shorter than the window builds every frame, as before.
let mut dpb = Dpb::new();
let mut built = Vec::new();
dpb.fill_frame_num_gap(7, 10, 16, 4, &mut |n| (built.push(n), frame(n)).1, &mut |_| {});
assert_eq!(built, vec![8, 9]);
}
#[test]
fn long_term_marking_cannot_grow_the_dpb_past_its_capacity() {
let mut dpb = Dpb::new();
dpb.capacity = 4;
let mut out = Vec::new();
let mut sh = SliceHeader { adaptive_ref_pic_marking: true, ..reference_slice() };
// Each picture takes a distinct long-term index, after MMCO 4 allowed 1000 of them.
for n in 0..40u32 {
sh.frame_num = n;
sh.mmcos = vec![Mmco { op: 4, max_long_term_frame_idx_plus1: 1000, ..mmco(0, 0) }, Mmco { op: 6, long_term_frame_idx: n, ..mmco(0, 0) }];
let (f, meta) = frame(n);
dpb.store_picture(&sh, f, n as i32, 1 << 16, 16, meta, &mut out);
assert!(dpb.entries.len() <= 4, "{} entries after picture {n}", dpb.entries.len());
}
// An index past what MMCO 4 allowed is ignored: the picture is a short-term reference.
sh.frame_num = 40;
sh.mmcos = vec![Mmco { op: 6, long_term_frame_idx: 5000, ..mmco(0, 0) }];
let (f, meta) = frame(40);
dpb.store_picture(&sh, f, 40, 1 << 16, 16, meta, &mut out);
assert_eq!(dpb.entries.iter().find(|e| e.frame_num == 40).map(|e| e.mark), Some(RefMark::Short));
}
#[test]
fn mmco_huge_difference_does_not_panic() {
for op in [1, 3] {
-35
View File
@@ -397,10 +397,6 @@ impl Sps {
let pic_height_in_map_units = plus(r.read_ue()?, 1, "picture too large")?;
ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large");
let frame_mbs_only = r.read_flag()?;
// Picture state is allocated from these before any macroblock is read: the movie's pixel
// budget applies here too, so a 16 384-square SPS can't claim its planes.
let coded_mbs = u64::from(pic_width_in_mbs) * u64::from(pic_height_in_map_units) * if frame_mbs_only { 1 } else { 2 };
ensure!(coded_mbs * 256 <= crate::movie::MAX_PIXELS, "picture too large");
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
let direct_8x8_inference = r.read_flag()?;
let frame_crop = if r.read_flag()? { Some((r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?)) } else { None };
@@ -703,37 +699,6 @@ mod tests {
assert_eq!(l, [10; 16]);
}
#[test]
fn an_sps_past_the_pixel_budget_is_refused_before_allocation() {
// As `parse_minimal_baseline_sps`, with `mbs` × `mbs` macroblocks.
let sps = |mbs: u32, frame_mbs_only: bool| {
let mut w = BitWriter::new();
w.write_bits(66, 8);
w.write_bits(0xC0, 8);
w.write_bits(30, 8);
w.write_ue(0);
w.write_ue(0);
w.write_ue(2);
w.write_ue(1);
w.write_bit(false);
w.write_ue(mbs - 1);
w.write_ue(mbs - 1);
w.write_bit(frame_mbs_only);
if !frame_mbs_only {
w.write_bit(false); // mb_adaptive_frame_field
}
w.write_bit(true);
w.write_bit(false);
w.write_bit(false);
w.rbsp_trailing();
Sps::parse(&w.finish())
};
assert!(sps(512, true).is_ok(), "8192 × 8192 is the budget");
assert!(sps(513, true).is_err(), "one macroblock column more is past it");
assert!(sps(512, false).is_err(), "field pictures double the map units");
assert!(sps(362, false).is_ok());
}
#[test]
fn parse_minimal_baseline_sps() {
// profile 66, level 30, sps_id 0, log2_max_frame_num_minus4 0, poc type 2, 1 ref, 11x9 MBs.
+7 -11
View File
@@ -75,8 +75,6 @@ pub(crate) struct Instance {
pub(super) active: bool,
pub(super) processing: bool,
pub(super) max_frames: usize,
/// `kRealtime` or `kOffline`, for `setupProcessing` and every `process` call.
pub(super) process_mode: i32,
pub(super) in_params: ComWrapper<ParamChanges>,
pub(super) out_params: ComWrapper<ParamChanges>,
pub(super) in_events: ComWrapper<EventList>,
@@ -253,12 +251,6 @@ impl Instance {
self.active
}
/// Offline (`kOffline`: no realtime pressure, e.g. a bounce) or realtime processing, from the
/// next [`Instance::configure`] on.
pub fn set_offline(&mut self, offline: bool) {
self.process_mode = if offline { sv::ProcessModes_::kOffline } else { sv::ProcessModes_::kRealtime } as i32;
}
fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 {
// SAFETY: valid component.
unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.max(0)
@@ -367,8 +359,12 @@ impl Instance {
return Err(Vst3Error::Process("plugin cannot process 32-bit float".into()));
}
let max = i32::try_from(max_frames).unwrap_or(i32::MAX);
let mut setup =
sv::ProcessSetup { processMode: self.process_mode, symbolicSampleSize: sample32, maxSamplesPerBlock: max, sampleRate: sample_rate };
let mut setup = sv::ProcessSetup {
processMode: sv::ProcessModes_::kRealtime as i32,
symbolicSampleSize: sample32,
maxSamplesPerBlock: max,
sampleRate: sample_rate,
};
// SAFETY: valid processor, inactive; `setup` is a valid struct for the call.
if !ok(unsafe { p.setupProcessing(&mut setup) }) {
return Err(Vst3Error::Process("setupProcessing failed".into()));
@@ -454,7 +450,7 @@ impl Instance {
None => self.context.projectTimeSamples = project_time,
}
let mut data = sv::ProcessData {
processMode: self.process_mode,
processMode: sv::ProcessModes_::kRealtime as i32,
symbolicSampleSize: sv::SymbolicSampleSizes_::kSample32 as i32,
numSamples: i32::try_from(frames).unwrap_or(0),
numInputs: i32::try_from(inputs.bufs.len()).unwrap_or(0),
+3 -14
View File
@@ -137,16 +137,6 @@ mod cf {
}
}
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
/// source.
fn with_reason(e: &libloading::Error) -> String {
match std::error::Error::source(e) {
Some(why) => format!("{e}: {why}"),
None => e.to_string(),
}
}
/// Opens the shared library and runs the platform module entry (`bundleEntry` with a
/// `CFBundleRef` on macOS, `InitDll` on Windows, `ModuleEntry` with the `dlopen` handle on Linux).
/// Also returns the matching module exit, when the entry ran and the module exports one.
@@ -156,7 +146,7 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<(libloading::Library, Optio
use libloading::os::unix::{Library as UnixLib, RTLD_LOCAL, RTLD_NOW};
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in VST3 plugin folders or named explicitly.
let lib = unsafe { UnixLib::open(Some(exe), RTLD_NOW | RTLD_LOCAL) }.map_err(|e| with_reason(&e))?;
let lib = unsafe { UnixLib::open(Some(exe), RTLD_NOW | RTLD_LOCAL) }.map_err(|e| e.to_string())?;
type ModuleEntry = unsafe extern "C" fn(*mut c_void) -> bool;
// SAFETY: `ModuleEntry` is the VST3-mandated Linux entry with this signature.
let entry = unsafe { lib.get::<ModuleEntry>(b"ModuleEntry\0") }.ok().map(|s| *s);
@@ -180,7 +170,7 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<(libloading::Library, Optio
{
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in VST3 plugin folders or named explicitly.
let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| with_reason(&e))?;
let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| e.to_string())?;
let mut exit: Option<ModuleExit> = None;
#[cfg(windows)]
{
@@ -345,7 +335,7 @@ impl Bundle {
// SAFETY: `GetPluginFactory` is the VST3-mandated export with this signature.
let get = match unsafe { lib.get::<GetFactory>(b"GetPluginFactory\0") } {
Ok(sym) => *sym,
Err(e) => return Err(fail(format!("not a VST3 plugin (no GetPluginFactory): {}", with_reason(&e)))),
Err(e) => return Err(fail(format!("not a VST3 plugin: {e}"))),
};
// SAFETY: the module is loaded and its entry function (if any) has run.
let raw = unsafe { get() };
@@ -459,7 +449,6 @@ impl Bundle {
active: false,
processing: false,
max_frames: 0,
process_mode: sv::ProcessModes_::kRealtime as i32,
in_params: ComWrapper::new(ParamChanges::new(PARAM_QUEUE_CAP)),
out_params: ComWrapper::new(ParamChanges::new(PARAM_QUEUE_CAP)),
in_events: ComWrapper::new(EventList::new(EVENT_CAP)),
-16
View File
@@ -307,21 +307,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// A binary the system refuses to load reports the system's reason (missing library, wrong
/// glibc, not an ELF file), not just "dlopen failed".
#[test]
fn a_binary_that_does_not_load_says_why() {
let d = std::env::temp_dir().join(format!("soundcraft-vst3-reason-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let f = d.join("garbage.vst3");
std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default();
let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim());
assert!(!reason.is_empty(), "no reason in: {msg}");
let _ = std::fs::remove_dir_all(&d);
}
// Unix only: the test points a ".vst3" at a system library through a symlink.
#[cfg(unix)]
#[test]
@@ -341,7 +326,6 @@ mod tests {
std::os::unix::fs::symlink(&lib, d.join("z.vst3")).unwrap();
let e = load_bundle(&d.join("z.vst3")).unwrap_err();
assert!(e.to_string().contains("not a VST3 plugin"), "{e}");
assert!(e.to_string().contains("GetPluginFactory"), "the system's reason names the missing export: {e}");
let _ = std::fs::remove_dir_all(&d);
}
}
-5
View File
@@ -259,11 +259,6 @@ impl Plugin for Vst3Plugin {
}
}
fn set_offline(&mut self, offline: bool) {
// `processMode`, set up by the next `prepare`.
self.inst.set_offline(offline);
}
fn reset(&mut self) {
// Keep pending releases and release whatever is still held, so the processor's voices stop
// on the next block instead of sounding on while the host forgets them.
+7 -12
View File
@@ -14,9 +14,8 @@
//! - "Fixture Wide 16x64", "Fixture Wide 32 buses", "Fixture Wide 65 buses", "Fixture Wide 65
//! channels" (`Fx`): effects advertising wide layouts (16 buses of 64 channels, 32 and 65 buses
//! of 2 channels, one bus of 65 channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
//! of output bus 0 when the host's buffers match the advertised layout (2.0 when the host set it
//! up and runs it with `kOffline`), -1.0 when they do not or when `process` gets another
//! `processMode` than `setupProcessing` did. Report 3 samples of latency.
//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not.
//! Report 3 samples of latency.
//!
//! The module checks the host's module lifecycle the way real plugins depend on it (see "module
//! lifecycle" below) and aborts the process when it is broken.
@@ -26,7 +25,7 @@
#![allow(non_snake_case, clippy::missing_safety_doc)]
use std::ffi::{c_char, c_int, c_void};
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use vst3::Steinberg::Vst::*;
use vst3::Steinberg::*;
use vst3::{Class, ComRef, ComWrapper, uid};
@@ -641,8 +640,6 @@ impl IEditControllerTrait for Synth {
struct Wide {
buses: i32,
channels: i32,
/// The `processMode` of the last `setupProcessing`.
mode: AtomicI32,
_live: Live,
}
@@ -707,8 +704,7 @@ impl IAudioProcessorTrait for Wide {
unsafe fn getLatencySamples(&self) -> u32 {
3
}
unsafe fn setupProcessing(&self, setup: *mut ProcessSetup) -> tresult {
self.mode.store(unsafe { &*setup }.processMode, Ordering::Relaxed);
unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult {
kResultOk
}
unsafe fn setProcessing(&self, _state: TBool) -> tresult {
@@ -718,11 +714,10 @@ impl IAudioProcessorTrait for Wide {
let d = unsafe { &*data };
let fits =
|bufs: *mut AudioBusBuffers, n: i32| n == self.buses && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.numChannels == self.channels);
let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs) && d.processMode == self.mode.load(Ordering::Relaxed);
let fit = if d.processMode == ProcessModes_::kOffline as i32 { 2.0 } else { 1.0 };
let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs);
if d.numOutputs > 0 {
for ch in unsafe { channels(&*d.outputs, d.numSamples.max(0) as usize) } {
ch.fill(if ok { fit } else { -1.0 });
ch.fill(if ok { 1.0 } else { -1.0 });
}
}
kResultOk
@@ -796,7 +791,7 @@ impl IPluginFactoryTrait for Factory {
.into_iter()
.find_map(|(c, layout)| (c == cid).then_some(layout))
{
ComWrapper::new(Wide { buses, channels, mode: AtomicI32::new(-1), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else {
None
};
-12
View File
@@ -331,18 +331,6 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
}
}
#[test]
fn offline_renders_set_up_and_process_in_offline_mode() {
for (offline, want) in [(false, 1.0), (true, 2.0)] {
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 16x64")).unwrap();
p.set_offline(offline);
p.prepare(44_100.0, 64, 2);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]);
}
}
#[test]
fn many_buses_like_multi_out_instruments_are_activated() {
// Multi-out instruments declare dozens of buses (SSD Sampler 5: 32); only the main one is
+1 -1
View File
@@ -38,7 +38,7 @@ Results go to standard output, as JSON where a program is meant to read them. `i
file), `describe`, `app` and the `--inspect` output of `run` and `script` pretty-print it; `--compact`
prints the same JSON on one line. The result lines of `run` and `script` (`ID: JSON`) and the
`--bounce` and `--save` results are always one line each. `commands`, `plugins`, `parity` and the
`.scraft` summary of `info` are text. Errors go to standard error as `error: <message>`, and so do the warnings and errors SoundCraft and the plugin hosts log (`warning: <message>`, `error: <message>`), such as a plugin that fails to load. `mcp` writes only the protocol
`.scraft` summary of `info` are text. Errors go to standard error as `error: <message>`. `mcp` writes only the protocol
to standard output.
| Exit code | Meaning |
+2 -3
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-02 · **Change:** trivial (bounces carry a BWF time reference, #1296; previously trivial: Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types
its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its
@@ -33,7 +33,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
| Format | Pro Tools | Read | Write | Tests / notes |
|---|---|---|---|---|
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). WAV bounces (mix and stems) carry a `bext` TimeReference: the session's timecode start plus the bounce start (#1296). |
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). |
| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` |
| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) |
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native; `fl32` retains finite headroom (#791) |
@@ -80,7 +80,6 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | trivial | WAV bounces (mix and stems) write a `bext` TimeReference: session timecode start plus bounce start (#1296) |
| 2026-10-19 | trivial | WAV and AIFF-C Float32 encoders retain finite headroom (#791); whole-file, streaming and bounce regression tests |
| 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) |
| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged |
+2 -5
View File
@@ -197,11 +197,8 @@ mod tests {
#[test]
fn a_failed_update_restores_the_manifest() {
// Created exclusively: a folder already there (planted, or left by a failed run) would be
// someone else's, and a link inside it could redirect the writes below.
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
let root = std::env::temp_dir().join(format!("xtask-version-restore-{}-{nanos}", std::process::id()));
std::fs::create_dir(&root).unwrap();
let root = std::env::temp_dir().join(format!("xtask-version-restore-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let path = root.join("Cargo.toml");
std::fs::write(&path, MANIFEST).unwrap();
let err = set(&root, &path, MANIFEST, "2.0.0", || Err("cargo update failed".into())).unwrap_err();