diff --git a/Cargo.lock b/Cargo.lock index 45f00211..144046af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4374,6 +4374,7 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "log", "same-file", "serde", "serde_json", diff --git a/apps/soundcraft-cli/Cargo.toml b/apps/soundcraft-cli/Cargo.toml index 9316b5b8..37ba7649 100644 --- a/apps/soundcraft-cli/Cargo.toml +++ b/apps/soundcraft-cli/Cargo.toml @@ -25,6 +25,7 @@ soundcraft-dsp.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +log.workspace = true clap.workspace = true same-file.workspace = true diff --git a/apps/soundcraft-cli/src/convert.rs b/apps/soundcraft-cli/src/convert.rs index 91c606b8..2cd35baa 100644 --- a/apps/soundcraft-cli/src/convert.rs +++ b/apps/soundcraft-cli/src/convert.rs @@ -15,6 +15,7 @@ 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; @@ -30,13 +31,28 @@ 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()); }; - let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None }; + // 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 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. diff --git a/apps/soundcraft-cli/src/lib.rs b/apps/soundcraft-cli/src/lib.rs index fe351e2e..f72c42a8 100644 --- a/apps/soundcraft-cli/src/lib.rs +++ b/apps/soundcraft-cli/src/lib.rs @@ -11,6 +11,7 @@ mod catalog; mod convert; mod error; mod info; +pub mod logger; mod mcp; mod session; diff --git a/apps/soundcraft-cli/src/logger.rs b/apps/soundcraft-cli/src/logger.rs new file mode 100644 index 00000000..317b44a0 --- /dev/null +++ b/apps/soundcraft-cli/src/logger.rs @@ -0,0 +1,49 @@ +//! 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: ` and +//! `error: `, 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"); + } +} diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 7d381be7..e29c38f4 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -3,6 +3,7 @@ #![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, diff --git a/apps/soundcraft-cli/tests/convert.rs b/apps/soundcraft-cli/tests/convert.rs index eb62ac77..7d962691 100644 --- a/apps/soundcraft-cli/tests/convert.rs +++ b/apps/soundcraft-cli/tests/convert.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat}; +use soundcraft_audio_io::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat}; fn tmpdir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id())); @@ -93,3 +93,31 @@ 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); +} diff --git a/apps/soundcraft-cli/tests/it/behaviour.rs b/apps/soundcraft-cli/tests/it/behaviour.rs index 69bdffa9..52c5eaeb 100644 --- a/apps/soundcraft-cli/tests/it/behaviour.rs +++ b/apps/soundcraft-cli/tests/it/behaviour.rs @@ -88,6 +88,25 @@ 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] diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 8ae25c52..1cbbfac7 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -27,6 +27,7 @@ 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}; diff --git a/crates/clap-host/src/ffi/bundle.rs b/crates/clap-host/src/ffi/bundle.rs index 503b6391..b537abb1 100644 --- a/crates/clap-host/src/ffi/bundle.rs +++ b/crates/clap-host/src/ffi/bundle.rs @@ -7,6 +7,7 @@ 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 ----------------------------------------------------------------------- @@ -42,6 +43,16 @@ fn loaded() -> &'static Mutex>> { 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, ClapError> { @@ -59,12 +70,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(e.to_string()))?; + let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?; // 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: {e}"))), + Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))), }; if entry.is_null() { return Err(err("null clap_entry".into())); @@ -178,6 +189,7 @@ 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), @@ -205,6 +217,7 @@ impl Bundle { inst.latency = inst.extension::(CLAP_EXT_LATENCY); inst.notes = inst.extension::(CLAP_EXT_NOTE_PORTS); inst.state_ext = inst.extension::(CLAP_EXT_STATE); + inst.render = inst.extension::(CLAP_EXT_RENDER); let gui = inst.extension::(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 }), diff --git a/crates/clap-host/src/ffi/instance.rs b/crates/clap-host/src/ffi/instance.rs index 3a537fb6..3ecd3a95 100644 --- a/crates/clap-host/src/ffi/instance.rs +++ b/crates/clap-host/src/ffi/instance.rs @@ -8,6 +8,7 @@ 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 ------------------------------------------------------------------------- @@ -99,6 +100,7 @@ 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, pub(super) raw: Vec, /// Parameter values the plugin reported through output events in the current `process`. @@ -158,6 +160,16 @@ 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) diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index f6b93d13..5e99eb04 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -262,4 +262,17 @@ 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); + } } diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 8e048e2a..31fbc20e 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -178,6 +178,11 @@ 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 { .. })); diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs index 10611670..b31028cc 100644 --- a/crates/clap-host/tests/fixture/src/lib.rs +++ b/crates/clap-host/tests/fixture/src/lib.rs @@ -9,7 +9,8 @@ //! - `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, -1.0 when they do not. +//! advertised layout (2.0 once the host set the offline render mode through `clap.render`), -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, @@ -27,6 +28,7 @@ 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; @@ -102,6 +104,8 @@ 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 { @@ -189,8 +193,8 @@ fn apply(f: &mut Fx, h: &clap_event_header) { } } -/// 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) { +/// 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) { let fits = |bufs: *const clap_audio_buffer, n: u32| { n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels) }; @@ -200,7 +204,7 @@ unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) { 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 { 1.0 } else { -1.0 } }; + unsafe { *o.add(i) = if ok { fit } else { -1.0 } }; } } } @@ -252,7 +256,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) }; + unsafe { fill_wide(pr, ports, channels, n, if f.offline { 2.0 } else { 1.0 }) }; return CLAP_PROCESS_CONTINUE; } if pr.audio_outputs_count != 1 { @@ -543,6 +547,17 @@ 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; @@ -558,6 +573,8 @@ 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() } @@ -623,6 +640,7 @@ 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 { diff --git a/crates/clap-host/tests/it/fixture_e2e.rs b/crates/clap-host/tests/it/fixture_e2e.rs index f531db54..1c2fcfea 100644 --- a/crates/clap-host/tests/it/fixture_e2e.rs +++ b/crates/clap-host/tests/it/fixture_e2e.rs @@ -130,6 +130,19 @@ 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(); diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 5b17a590..3c073ec0 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -195,6 +195,11 @@ 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; diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 0f7ba25f..2d6c446e 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -14,7 +14,7 @@ pub fn specs() -> Vec { "AudioSuite Process", [], None, - "{process: | 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: | 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,6 +55,9 @@ fn process(e: &mut Engine, p: &Value) -> Result { | "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}`"))); } @@ -228,6 +231,17 @@ 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(" Result { } /// 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 { @@ -612,6 +613,38 @@ 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, +} + +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; @@ -621,39 +654,41 @@ 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)> = - c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); + let mut pts: Vec<(i64, f32)> = old_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(&c.gain_env, rel0 - 1))); + pts.push((rel0 - 1, env_db(rel0 - 1))); } - pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); + pts.push((rel0, env_db(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(&c.gain_env, at) + delta(pt.at))); + pts.push((at, env_db(at) + delta(pt.at))); } // The clip's own gain points inside the selection keep their shape, offset by the lane. - 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)))); + for &(at, _) in old_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { + pts.push((at, env_db(at) + delta(c.start.saturating_add(at)))); } if sel1 - 1 > sel0 { let at = rel(sel1 - 1); - pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); + pts.push((at, env_db(at) + delta(sel1 - 1))); } if sel1 < c.end() { - pts.push((rel1, env_db(&c.gain_env, rel1))); + pts.push((rel1, env_db(rel1))); } pts.sort_by_key(|p| p.0); - let mut env = Vec::new(); + let mut baked = Vec::new(); for p in pts { - if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { - env.pop(); + if baked.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { + baked.pop(); } - env.push(p); + baked.push(p); } - c.gain_env = env; + c.gain_env = baked; } /// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there. @@ -726,8 +761,9 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result { 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(&c.gain_env, at - c.start))); + pts.push((at, level(at) + c.gain_db + env.db(at - c.start))); } c.gain_db = 0.0; c.gain_env.clear(); diff --git a/crates/engine/src/cmd/mix/tests.rs b/crates/engine/src/cmd/mix/tests.rs index d134e4da..77087a4e 100644 --- a/crates/engine/src/cmd/mix/tests.rs +++ b/crates/engine/src/cmd/mix/tests.rs @@ -345,3 +345,36 @@ 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); +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index b214a81f..84f01c72 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -96,14 +96,22 @@ pub fn specs() -> Vec { 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) -> Result { 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); - if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) { + // 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 { 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})) @@ -186,6 +194,17 @@ 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(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 60d9a323..67d6e4a9 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -178,6 +178,13 @@ pub struct Bounce { enc: Option>, } +/// 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 { @@ -208,6 +215,9 @@ 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))?; @@ -734,6 +744,12 @@ 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)?; diff --git a/crates/engine/src/io/bundle.rs b/crates/engine/src/io/bundle.rs index cfe6e0af..b906333d 100644 --- a/crates/engine/src/io/bundle.rs +++ b/crates/engine/src/io/bundle.rs @@ -194,6 +194,23 @@ 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. diff --git a/crates/engine/tests/it/bounce.rs b/crates/engine/tests/it/bounce.rs index 36630319..9460cea6 100644 --- a/crates/engine/tests/it/bounce.rs +++ b/crates/engine/tests/it/bounce.rs @@ -123,6 +123,38 @@ 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(); diff --git a/crates/mix/src/instances.rs b/crates/mix/src/instances.rs index 8bb5dbf9..fdf67d86 100644 --- a/crates/mix/src/instances.rs +++ b/crates/mix/src/instances.rs @@ -68,7 +68,23 @@ 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 { + 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 { 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"); diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index a671d0b6..7d6442aa 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -144,6 +144,8 @@ 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`]). @@ -177,6 +179,7 @@ impl MixEngine { recording: false, metronome: false, external: false, + offline: false, adopt: HashMap::new(), retired: Vec::new(), work: Vec::new(), @@ -189,6 +192,11 @@ 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) { @@ -271,7 +279,8 @@ 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, adopt: &mut self.adopt, retired: &mut self.retired }; + let mut cx = + SlotCtx { sr: self.sample_rate, mb, external: self.external, offline: self.offline, adopt: &mut self.adopt, retired: &mut self.retired }; let gone: Vec = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect(); for id in gone { self.meters.remove(&id); diff --git a/crates/mix/src/midi.rs b/crates/mix/src/midi.rs index 3b182956..e6efcd51 100644 --- a/crates/mix/src/midi.rs +++ b/crates/mix/src/midi.rs @@ -46,12 +46,20 @@ 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() { @@ -68,8 +76,6 @@ 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); diff --git a/crates/mix/src/offline.rs b/crates/mix/src/offline.rs index d544b0fc..fd80f097 100644 --- a/crates/mix/src/offline.rs +++ b/crates/mix/src/offline.rs @@ -57,7 +57,8 @@ impl RangeRender { /// Renders the main mix over `range`, or `bus` when one is named. pub fn new(s: &Session, bus: Option, range: Range, block: usize) -> Self { let nch = source_channels(s, bus); - let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + eng.set_offline(true); 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 } diff --git a/crates/mix/src/routing.rs b/crates/mix/src/routing.rs index fcebca85..2f4788fa 100644 --- a/crates/mix/src/routing.rs +++ b/crates/mix/src/routing.rs @@ -46,7 +46,8 @@ pub(crate) fn processing_order(s: &Session) -> Vec { } 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. - auxes.sort_by_key(|&i| bounded_aux_depth(s, i)); + let depths = bounded_aux_depths(s); + auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0)); } order.extend(auxes); order @@ -115,32 +116,38 @@ fn aux_output_busses(t: &Track) -> impl Iterator + '_ { .filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None }) } -/// 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 = auxes_feeding(s, &inputs); - if feeders.is_empty() { +/// 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 { + 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 = s.tracks.iter().map(is_aux).collect(); + let mut depths = vec![0usize; s.tracks.len()]; + let mut fed = std::collections::HashSet::::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 { break; } - d += 1; - cur = feeders; } - d -} - -fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec { - 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() + depths } /// Implicit solo: an aux is not muted when a soloed track feeds its input bus. @@ -204,4 +211,81 @@ 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 = 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::>()); + } } diff --git a/crates/mix/src/strip.rs b/crates/mix/src/strip.rs index 01f4a11a..ea875d54 100644 --- a/crates/mix/src/strip.rs +++ b/crates/mix/src/strip.rs @@ -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, is_third_party}; +use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, 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,6 +24,9 @@ pub(crate) struct PluginSlot { pub(crate) plugin: Box, /// Last values pushed to the plugin, in the insert's parameter-map order. applied: Vec, + /// Scratch for `apply_params`: indexes of the track's automation lanes for this slot, kept + /// so the audio thread reuses its allocation. + lanes: Vec, pub(crate) failed: bool, } @@ -157,6 +160,8 @@ 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>, } @@ -189,9 +194,9 @@ impl SlotCtx<'_> { } } else { let state = if third { ins.state_bytes() } else { None }; - create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin) + create_instance_for(&ins.plugin, state.as_deref(), self.sr, self.mb, ch, self.offline).map(|n| n.plugin) }?; - Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false }) + Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }) } } @@ -491,21 +496,40 @@ 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 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()); + 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(); apply_values(slot, ins, |k, val| { - if automated - && let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k)) + 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)) && !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, value: impl Fn(&str, f32) -> f32) { +fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, mut value: impl FnMut(&str, f32) -> f32) { if slot.applied.len() != ins.params.len() { slot.applied = vec![f32::NAN; ins.params.len()]; } @@ -517,3 +541,99 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im } } } + +#[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, + sets: Arc>>, + } + + 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 { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.inner.process(io, frames); + } + } + + fn recorder() -> (PluginSlot, Arc>>) { + 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) -> 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))); + } +} diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 23bb2be0..873034bb 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -40,14 +40,34 @@ 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, meters: Vec, + /// 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, +} + +/// The serialized form of [`TempoMap`], without the derived lookup table. +#[derive(serde::Deserialize)] +struct TempoMapData { + tempos: Vec, + meters: Vec, +} + +impl From for TempoMap { + fn from(d: TempoMapData) -> Self { + TempoMap::from_parts(d.tempos, d.meters) + } } impl Default for TempoMap { fn default() -> Self { - TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] } + TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]) } } @@ -60,6 +80,43 @@ fn valid_meter(numerator: u32, denominator: u32) -> bool { } impl TempoMap { + fn from_parts(tempos: Vec, meters: Vec) -> 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 { let mut m = TempoMap::default(); m.set_tempo(0, bpm)?; @@ -91,6 +148,7 @@ 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`. @@ -105,6 +163,7 @@ impl TempoMap { } Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }), } + self.reindex(); Ok(()) } @@ -115,6 +174,7 @@ impl TempoMap { } let before = self.tempos.len(); self.tempos.retain(|e| e.tick != tick); + self.reindex(); before != self.tempos.len() } @@ -204,36 +264,20 @@ impl TempoMap { } fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 { - 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) + // Tempo events after the first that start before `tick` (the segments it has passed). + let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr) } /// Samples → ticks (fractional). pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 { let s = s as f64; - let 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) + let scale = Self::samples_per_elapsed(sr); + // Tempo events after the first that start at or before `s`. + let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr) } pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 { @@ -319,6 +363,7 @@ impl TempoMap { e.bpm = valid_bpm(e.bpm * factor)?; } self.tempos = out; + self.reindex(); Ok(()) } } @@ -407,24 +452,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 { - 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)], - }; + let mut m = TempoMap::from_parts( + vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)], + vec![meter(0, 0, 4), meter(3840, 3, 4)], + ); m.sanitize(); assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]); assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]); - let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() }; + let mut empty = TempoMap::from_parts(Vec::new(), Vec::new()); empty.sanitize(); assert_eq!(empty, TempoMap::default()); } #[test] fn sanitize_drops_events_beyond_the_position_range() { - let mut m = TempoMap { - 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 }], - }; + let mut m = TempoMap::from_parts( + vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }], + vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }], + ); m.sanitize(); assert_eq!(m, TempoMap::default()); assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4); @@ -446,4 +491,68 @@ 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()); + } } diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index f8a002a2..447671d8 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -115,8 +115,18 @@ 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) }; - 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()); + 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); + }); + } if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } else if nresp.clicked() { @@ -190,8 +200,9 @@ 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; - if h >= 37.0 { - let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master); + let is_master = track.kind == TrackKind::Master; + if h >= 37.0 && !is_master { + let (records, mixes) = (track.kind.has_playlist(), true); 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), @@ -213,10 +224,13 @@ 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)); - if h >= 58.0 { + 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 { let vresp = header_selector( ui, - at(17.0, 39.0, 105.0, 17.0), + at(17.0, view_y, 105.0, 17.0), ui.scope_id().with(("view", id.0)), &view_label(&track.view), t.text, @@ -225,10 +239,32 @@ 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(); diff --git a/crates/ui-egui/src/edit_window/rulers.rs b/crates/ui-egui/src/edit_window/rulers.rs index c665f907..5d47fca7 100644 --- a/crates/ui-egui/src/edit_window/rulers.rs +++ b/crates/ui-egui/src/edit_window/rulers.rs @@ -222,7 +222,8 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, } else if resp.clicked() && let Some(m) = hit { - let _ = app.run("markers.recall", json!({"number": m.number})); + let command = if ui.input(|i| i.modifiers.alt) { "markers.delete" } else { "markers.recall" }; + let _ = app.run(command, json!({"number": m.number})); } } } diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index 99d7dc45..48e63773 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -1508,3 +1508,50 @@ 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})"); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1571e8f8..4dc06c59 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -176,6 +176,19 @@ 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); } diff --git a/crates/ui-egui/src/mix_window/routing.rs b/crates/ui-egui/src/mix_window/routing.rs index 176a8cba..a343a021 100644 --- a/crates/ui-egui/src/mix_window/routing.rs +++ b/crates/ui-egui/src/mix_window/routing.rs @@ -11,7 +11,12 @@ 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 { "In 1".to_string() } else { route_name(app, &track.mixer.input) } + 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) + } } /// The input or output menu under `resp`. It closes only on a click outside, so its bus search @@ -59,3 +64,19 @@ 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"); + } +} diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index 87cac59d..90aca765 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -388,8 +388,17 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac }, ); let Some((picked, footer)) = shown else { return }; - if let Some(bus) = picked { - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus})); + // 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); } match footer { Some(SendFooter::NewBus) => { @@ -399,8 +408,10 @@ 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() { - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + 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); } } Some(SendFooter::RemoveSend) => { @@ -464,4 +475,79 @@ 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"); + } } diff --git a/crates/ui-egui/src/panels/tracks.rs b/crates/ui-egui/src/panels/tracks.rs index b4fd9bb8..1be81dd0 100644 --- a/crates/ui-egui/src/panels/tracks.rs +++ b/crates/ui-egui/src/panels/tracks.rs @@ -46,9 +46,28 @@ 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)> = { + let tracks: Vec<(u64, String, bool, [u8; 3], bool, usize)> = { let s = app.engine.session(); - s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect() + 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() }; let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden; let lists = with_pending_offset(egui::ScrollArea::vertical().id_salt("tracks_scroll"), ui, "tracks_scroll") @@ -56,7 +75,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) in tracks { + for (id, name, shown, color, sel, depth) 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 { @@ -70,7 +89,8 @@ 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( - pos2(r.min.x + 30.0, r.center().y), + // 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), Align2::LEFT_CENTER, &name, regular(11.5), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index cc063c68..3b01aa83 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -6,7 +6,7 @@ use crate::{SoundApp, icons}; 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, Tool}; +use soundcraft_model::{EditMode, EditState, Tool}; use soundcraft_time::{TimeFormat, format_length}; pub const HEIGHT: f32 = 78.0; @@ -205,14 +205,7 @@ 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(); - 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 { + for (id, icon, on, tip) in option_toggles(&e) { if icon_button(ui, vec2(36.0, 18.0), icon, on, tip).clicked() { let _ = app.run(id, json!({})); } @@ -227,6 +220,18 @@ 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) @@ -457,3 +462,21 @@ 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::*; + + /// #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"); + } +} diff --git a/crates/ui-egui/tests/it/send_window.rs b/crates/ui-egui/tests/it/send_window.rs index ac3a6dad..30a2d7bd 100644 --- a/crates/ui-egui/tests/it/send_window.rs +++ b/crates/ui-egui/tests/it/send_window.rs @@ -1,5 +1,6 @@ //! 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. +//! another send by clicking that send's level, closed by clicking the same one again. Creating a send +//! opens it too. use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::Harness; @@ -75,6 +76,33 @@ 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() { diff --git a/crates/ui-egui/tests/it/track_list.rs b/crates/ui-egui/tests/it/track_list.rs index cd8f758d..b0407b29 100644 --- a/crates/ui-egui/tests/it/track_list.rs +++ b/crates/ui-egui/tests/it/track_list.rs @@ -50,6 +50,16 @@ 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), @@ -90,3 +100,35 @@ 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"); +} diff --git a/crates/vst3-host/src/ffi/instance.rs b/crates/vst3-host/src/ffi/instance.rs index c7d783e0..4bbad925 100644 --- a/crates/vst3-host/src/ffi/instance.rs +++ b/crates/vst3-host/src/ffi/instance.rs @@ -75,6 +75,8 @@ 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, pub(super) out_params: ComWrapper, pub(super) in_events: ComWrapper, @@ -251,6 +253,12 @@ 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) @@ -359,12 +367,8 @@ 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: sv::ProcessModes_::kRealtime as i32, - symbolicSampleSize: sample32, - maxSamplesPerBlock: max, - sampleRate: sample_rate, - }; + let mut setup = + sv::ProcessSetup { processMode: self.process_mode, 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())); @@ -450,7 +454,7 @@ impl Instance { None => self.context.projectTimeSamples = project_time, } let mut data = sv::ProcessData { - processMode: sv::ProcessModes_::kRealtime as i32, + processMode: self.process_mode, symbolicSampleSize: sv::SymbolicSampleSizes_::kSample32 as i32, numSamples: i32::try_from(frames).unwrap_or(0), numInputs: i32::try_from(inputs.bufs.len()).unwrap_or(0), diff --git a/crates/vst3-host/src/ffi/module.rs b/crates/vst3-host/src/ffi/module.rs index 61e415b4..988a24fd 100644 --- a/crates/vst3-host/src/ffi/module.rs +++ b/crates/vst3-host/src/ffi/module.rs @@ -137,6 +137,16 @@ 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. @@ -146,7 +156,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| e.to_string())?; + let lib = unsafe { UnixLib::open(Some(exe), RTLD_NOW | RTLD_LOCAL) }.map_err(|e| with_reason(&e))?; 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::(b"ModuleEntry\0") }.ok().map(|s| *s); @@ -170,7 +180,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| e.to_string())?; + let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| with_reason(&e))?; let mut exit: Option = None; #[cfg(windows)] { @@ -335,7 +345,7 @@ impl Bundle { // SAFETY: `GetPluginFactory` is the VST3-mandated export with this signature. let get = match unsafe { lib.get::(b"GetPluginFactory\0") } { Ok(sym) => *sym, - Err(e) => return Err(fail(format!("not a VST3 plugin: {e}"))), + Err(e) => return Err(fail(format!("not a VST3 plugin (no GetPluginFactory): {}", with_reason(&e)))), }; // SAFETY: the module is loaded and its entry function (if any) has run. let raw = unsafe { get() }; @@ -449,6 +459,7 @@ 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)), diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index a0324f88..a320dedf 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -307,6 +307,21 @@ 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] @@ -326,6 +341,7 @@ 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); } } diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 726ad98b..2999c1ab 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -259,6 +259,11 @@ 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. diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index d6af5332..9d9323cb 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -14,8 +14,9 @@ //! - "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, -1.0 when they do not. -//! Report 3 samples of latency. +//! 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. //! //! 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. @@ -25,7 +26,7 @@ #![allow(non_snake_case, clippy::missing_safety_doc)] use std::ffi::{c_char, c_int, c_void}; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use vst3::Steinberg::Vst::*; use vst3::Steinberg::*; use vst3::{Class, ComRef, ComWrapper, uid}; @@ -640,6 +641,8 @@ impl IEditControllerTrait for Synth { struct Wide { buses: i32, channels: i32, + /// The `processMode` of the last `setupProcessing`. + mode: AtomicI32, _live: Live, } @@ -704,7 +707,8 @@ impl IAudioProcessorTrait for Wide { unsafe fn getLatencySamples(&self) -> u32 { 3 } - unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult { + unsafe fn setupProcessing(&self, setup: *mut ProcessSetup) -> tresult { + self.mode.store(unsafe { &*setup }.processMode, Ordering::Relaxed); kResultOk } unsafe fn setProcessing(&self, _state: TBool) -> tresult { @@ -714,10 +718,11 @@ 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); + 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 }; if d.numOutputs > 0 { for ch in unsafe { channels(&*d.outputs, d.numSamples.max(0) as usize) } { - ch.fill(if ok { 1.0 } else { -1.0 }); + ch.fill(if ok { fit } else { -1.0 }); } } kResultOk @@ -791,7 +796,7 @@ impl IPluginFactoryTrait for Factory { .into_iter() .find_map(|(c, layout)| (c == cid).then_some(layout)) { - ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::() + ComWrapper::new(Wide { buses, channels, mode: AtomicI32::new(-1), _live: Live::new(&OBJECTS) }).to_com_ptr::() } else { None }; diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs index e24d6001..3be404fe 100644 --- a/crates/vst3-host/tests/it/fixture_e2e.rs +++ b/crates/vst3-host/tests/it/fixture_e2e.rs @@ -331,6 +331,18 @@ 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 diff --git a/docs/cli.md b/docs/cli.md index eb94ab74..28d36fcd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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: `. `mcp` writes only the protocol +`.scraft` summary of `info` are text. Errors go to standard error as `error: `, and so do the warnings and errors SoundCraft and the plugin hosts log (`warning: `, `error: `), such as a plugin that fails to load. `mcp` writes only the protocol to standard output. | Exit code | Meaning | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4d9009be..6f9a7b9d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **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 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 / 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). | | 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,6 +80,7 @@ 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 |