mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-03 17:08:17 -06:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d78c7e9e | ||
|
|
b26ab2b25c | ||
|
|
76f58fcb81 | ||
|
|
6a4c055094 | ||
|
|
d5564bce05 | ||
|
|
87ad11cccc | ||
|
|
b11ffe73ae | ||
|
|
f91b4173b6 | ||
|
|
98c3a307c3 | ||
|
|
6492b5f27e | ||
|
|
9c202d6f17 | ||
|
|
44b864ffaf | ||
|
|
035de82c84 | ||
|
|
181f329ec8 | ||
|
|
5ba9f9d7b8 | ||
|
|
c15682a088 | ||
|
|
0d253b6d8c | ||
|
|
c0f2f7dc87 | ||
|
|
f99407fb1f | ||
|
|
636454e639 | ||
|
|
7546ab992b | ||
|
|
f86b077cdf | ||
|
|
aa250cc1d7 | ||
|
|
351b9d3a55 | ||
|
|
da02c19fcc | ||
|
|
64540f109b | ||
|
|
0af1ad909e | ||
|
|
7587130d59 | ||
|
|
e4f0599a2a | ||
|
|
f46ea7d70c | ||
|
|
1403bbc4d3 | ||
|
|
b0793ca875 | ||
|
|
36d42e0136 | ||
|
|
04dab602c7 | ||
|
|
576ff36f61 | ||
|
|
4b06407dae | ||
|
|
1a95692f50 | ||
|
|
7dfa7e470f | ||
|
|
8f7eb4d777 | ||
|
|
ffc035ba29 |
Generated
+1
@@ -4374,6 +4374,7 @@ dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"log",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,6 +11,7 @@ mod catalog;
|
||||
mod convert;
|
||||
mod error;
|
||||
mod info;
|
||||
pub mod logger;
|
||||
mod mcp;
|
||||
mod session;
|
||||
|
||||
|
||||
@@ -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: <message>` and
|
||||
//! `error: <message>`, like the CLI's own errors; standard output keeps only the results.
|
||||
//! `RUST_LOG` stays a desktop app variable (docs/cli.md), so the level is fixed.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
struct StderrLogger;
|
||||
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if self.enabled(record.metadata()) {
|
||||
// A closed standard error is not worth failing a command over.
|
||||
let _ = std::io::stderr().write_all(line(record.level(), &record.args().to_string()).as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// One record as the CLI prints it.
|
||||
fn line(level: log::Level, message: &str) -> String {
|
||||
let label = if level == log::Level::Error { "error" } else { "warning" };
|
||||
format!("{label}: {}\n", message.strip_suffix('\n').unwrap_or(message))
|
||||
}
|
||||
|
||||
/// Installs the logger. Once per process: `main` calls it, not `run`, which tests call many times.
|
||||
pub fn install() {
|
||||
static LOGGER: StderrLogger = StderrLogger;
|
||||
if log::set_logger(&LOGGER).is_ok() {
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_print_like_the_cli_errors() {
|
||||
assert_eq!(line(log::Level::Warn, "cannot load CLAP plugin x.clap: bad file\n"), "warning: cannot load CLAP plugin x.clap: bad file\n");
|
||||
assert_eq!(line(log::Level::Error, "panic: boom"), "error: panic: boom\n");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<HashMap<PathBuf, Arc<Bundle>>> {
|
||||
L.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
|
||||
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
|
||||
/// source.
|
||||
fn with_reason(e: &libloading::Error) -> String {
|
||||
match std::error::Error::source(e) {
|
||||
Some(why) => format!("{e}: {why}"),
|
||||
None => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
|
||||
@@ -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_plugin_latency>(CLAP_EXT_LATENCY);
|
||||
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
|
||||
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
|
||||
inst.render = inst.extension::<clap_plugin_render>(CLAP_EXT_RENDER);
|
||||
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
|
||||
inst.gui = Arc::new(GuiLink {
|
||||
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
|
||||
|
||||
@@ -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<GuiLink>,
|
||||
pub(super) raw: Vec<RawEvent>,
|
||||
/// 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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { .. }));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AudioSuite Process",
|
||||
[],
|
||||
None,
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
"{process: <built-in plugin id (engine.plugins)> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
has_selection,
|
||||
process
|
||||
)]
|
||||
@@ -55,6 +55,9 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
| "speed_ramp"
|
||||
| "dc_offset_removal"
|
||||
);
|
||||
if !known && soundcraft_mix::is_third_party(&name) {
|
||||
return Err(bad("audiosuite.process", format!("`{name}` is a hosted plugin; AudioSuite runs built-in plugins only (engine.plugins)")));
|
||||
}
|
||||
if !known {
|
||||
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
|
||||
}
|
||||
@@ -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("<built-in plugin id"), "the help says which ids it takes: {}", spec.params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_and_duplicate_keep_clip_gain() {
|
||||
let (mut e, id) = gained_clip();
|
||||
|
||||
@@ -598,6 +598,7 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
|
||||
#[cfg(test)]
|
||||
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
|
||||
let mut prev: Option<(i64, f32)> = None;
|
||||
for &(t, v) in env {
|
||||
@@ -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<i64>,
|
||||
}
|
||||
|
||||
impl<'a> EnvLookup<'a> {
|
||||
fn new(env: &'a [(i64, f32)]) -> Self {
|
||||
let reach = env
|
||||
.iter()
|
||||
.scan(i64::MIN, |m, &(t, _)| {
|
||||
*m = (*m).max(t);
|
||||
Some(*m)
|
||||
})
|
||||
.collect();
|
||||
EnvLookup { env, reach }
|
||||
}
|
||||
|
||||
/// Value in dB at `at` (see `env_db`).
|
||||
fn db(&self, at: i64) -> f32 {
|
||||
let i = self.reach.partition_point(|&m| m < at);
|
||||
let Some(&(t, v)) = self.env.get(i) else { return self.env.last().map_or(0.0, |&(_, v)| v) };
|
||||
match i.checked_sub(1).and_then(|p| self.env.get(p)) {
|
||||
Some(&(pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
|
||||
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
|
||||
let delta = |at: i64| lane.value_at(at, base) - base;
|
||||
@@ -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<Value> {
|
||||
if let Some(l) = &old {
|
||||
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
|
||||
}
|
||||
let env = EnvLookup::new(&c.gain_env);
|
||||
for at in ats {
|
||||
pts.push((at, level(at) + c.gain_db + env_db(&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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -96,14 +96,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
const MIN_SPP: f64 = 0.25;
|
||||
const MAX_SPP: f64 = 1_000_000.0;
|
||||
/// Pixel column an off-screen edit cursor is brought to when zooming (the engine does not know the viewport width).
|
||||
const OFFSCREEN_CURSOR_PX: f64 = 500.0;
|
||||
|
||||
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
|
||||
let insertion = e.session().edit.selection.start as f64;
|
||||
let z = e.zoom_mut();
|
||||
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
|
||||
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
|
||||
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();
|
||||
|
||||
@@ -178,6 +178,13 @@ pub struct Bounce {
|
||||
enc: Option<soundcraft_audio_io::FileEncoder<Out>>,
|
||||
}
|
||||
|
||||
/// The Broadcast Wave stamp of timeline position `at`: the session's timecode start plus `at`, in
|
||||
/// samples since midnight (`bext` TimeReference; 0 before midnight).
|
||||
fn time_stamp(s: &Session, at: Samples) -> soundcraft_audio_io::BwfInfo {
|
||||
let time_reference = u64::try_from(s.timecode_start.saturating_add(at)).unwrap_or(0);
|
||||
soundcraft_audio_io::BwfInfo { time_reference, ..Default::default() }
|
||||
}
|
||||
|
||||
/// A finished bounce: the size of its file and its levels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Bounced {
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<NewInstance> {
|
||||
create_instance_for(id, state, sample_rate, max_block, ch, false)
|
||||
}
|
||||
|
||||
/// [`create_instance`] for an engine that renders offline (`offline`) or in realtime: the plugin
|
||||
/// is told which before it is prepared ([`Plugin::set_offline`]).
|
||||
pub(crate) fn create_instance_for(
|
||||
id: &str,
|
||||
state: Option<&[u8]>,
|
||||
sample_rate: f32,
|
||||
max_block: usize,
|
||||
ch: usize,
|
||||
offline: bool,
|
||||
) -> Option<NewInstance> {
|
||||
let mut plugin = create_plugin(id)?;
|
||||
if offline {
|
||||
plugin.set_offline(true);
|
||||
}
|
||||
let refused_state = state.is_some_and(|st| !plugin.load_state(st));
|
||||
if refused_state {
|
||||
log::warn!("{id}: the plugin did not accept its stored state");
|
||||
|
||||
+10
-1
@@ -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<PreparedInstance>) {
|
||||
@@ -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<TrackId> = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect();
|
||||
for id in gone {
|
||||
self.meters.remove(&id);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<BusId>, 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 }
|
||||
|
||||
+108
-24
@@ -46,7 +46,8 @@ pub(crate) fn processing_order(s: &Session) -> Vec<usize> {
|
||||
} else {
|
||||
// A cycle keeps the existing bounded ordering for the entire graph, including any
|
||||
// independent acyclic branches. Feedback does not have a finite longest-path depth.
|
||||
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<Item = BusId> + '_ {
|
||||
.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<usize> = 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<usize> {
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Tracks with a feeder chain of the current length ending at them (length 0: every aux).
|
||||
let mut reached: Vec<bool> = s.tracks.iter().map(is_aux).collect();
|
||||
let mut depths = vec![0usize; s.tracks.len()];
|
||||
let mut fed = std::collections::HashSet::<BusId>::new();
|
||||
for hop in 1..=MAX_BOUNDED_DEPTH {
|
||||
fed.clear();
|
||||
for (t, _) in s.tracks.iter().zip(&reached).filter(|(t, r)| **r && is_aux(t)) {
|
||||
fed.extend(aux_output_busses(t));
|
||||
}
|
||||
let mut any = false;
|
||||
for ((t, r), d) in s.tracks.iter().zip(reached.iter_mut()).zip(depths.iter_mut()) {
|
||||
*r = matches!(&t.mixer.input, Route::Bus(b) if fed.contains(b));
|
||||
if *r {
|
||||
*d = hop;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
|
||||
s.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||||
.filter(|(_, t)| {
|
||||
inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
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<usize> = s
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||||
.filter(|(_, t)| {
|
||||
inputs
|
||||
.iter()
|
||||
.any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if feeders.is_empty() {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_depths_match_the_per_track_feeder_search() {
|
||||
// A small pseudo-random graph of auxes, folders and audio tracks with cycles, joins,
|
||||
// sends and unrouted inputs.
|
||||
let mut s = Session::default();
|
||||
let busses: Vec<_> = (0..6).map(|i| s.add_bus(&format!("B{i}"), ChannelFormat::Stereo)).collect();
|
||||
let mut x = 12345u32;
|
||||
let mut next = |m: usize| {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
(x >> 16) as usize % m
|
||||
};
|
||||
for i in 0..30 {
|
||||
let kind = [TrackKind::Aux, TrackKind::Folder, TrackKind::Audio][next(3)];
|
||||
let id = s.add_track(kind, ChannelFormat::Stereo, None);
|
||||
let pick = |n: usize| if n < busses.len() { Route::Bus(busses[n]) } else { Route::Main };
|
||||
let (input, output, send) = (pick(next(8)), pick(next(8)), pick(next(8)));
|
||||
let t = s.track_mut(id).unwrap();
|
||||
t.mixer.input = input;
|
||||
t.mixer.output = output;
|
||||
if i % 2 == 0 {
|
||||
t.mixer.sends[0] = Some(SendSlot::new(send));
|
||||
}
|
||||
}
|
||||
let depths = bounded_aux_depths(&s);
|
||||
for (i, (t, depth)) in s.tracks.iter().zip(&depths).enumerate() {
|
||||
if matches!(t.kind, TrackKind::Aux | TrackKind::Folder) {
|
||||
assert_eq!(*depth, reference_depth(&s, i), "track {i}");
|
||||
}
|
||||
}
|
||||
assert!(depths.contains(&MAX_BOUNDED_DEPTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_cyclic_graph_orders_quickly() {
|
||||
// Thousands of auxes all reading and writing one bus: every aux feeds every other.
|
||||
let mut s = Session::default();
|
||||
let bus = s.add_bus("Loop", ChannelFormat::Stereo);
|
||||
for _ in 0..3000 {
|
||||
let id = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||||
let t = s.track_mut(id).unwrap();
|
||||
t.mixer.input = Route::Bus(bus);
|
||||
t.mixer.output = Route::Bus(bus);
|
||||
t.mixer.sends[0] = Some(SendSlot::new(Route::Bus(bus)));
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let order = processing_order(&s);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
assert_eq!(order, (0..3000).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
+128
-8
@@ -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<dyn Plugin>,
|
||||
/// Last values pushed to the plugin, in the insert's parameter-map order.
|
||||
applied: Vec<f32>,
|
||||
/// Scratch for `apply_params`: indexes of the track's automation lanes for this slot, kept
|
||||
/// so the audio thread reuses its allocation.
|
||||
lanes: Vec<usize>,
|
||||
pub(crate) failed: bool,
|
||||
}
|
||||
|
||||
@@ -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<Box<dyn Plugin>>,
|
||||
}
|
||||
@@ -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<dyn Plugin>,
|
||||
sets: Arc<Mutex<Vec<(String, f32)>>>,
|
||||
}
|
||||
|
||||
impl Plugin for Recorder {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.sets.lock().unwrap().push((id.to_string(), v));
|
||||
true
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.inner.process(io, frames);
|
||||
}
|
||||
}
|
||||
|
||||
fn recorder() -> (PluginSlot, Arc<Mutex<Vec<(String, f32)>>>) {
|
||||
let sets = Arc::new(Mutex::new(Vec::new()));
|
||||
let plugin = Box::new(Recorder { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) });
|
||||
(PluginSlot { id: "gain".into(), ch: 2, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }, sets)
|
||||
}
|
||||
|
||||
fn lane(slot: u8, param: &str, point: Option<f32>) -> AutomationLane {
|
||||
let mut l = AutomationLane::new(AutoParam::Plugin { slot, param: param.into() });
|
||||
if let Some(v) = point {
|
||||
l.set_point(0, v);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_lane_per_param_of_this_slot_drives_it() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.automation = vec![
|
||||
lane(0, "c", None),
|
||||
lane(1, "a", Some(5.0)),
|
||||
lane(0, "b", Some(9.0)),
|
||||
lane(0, "c", Some(7.0)),
|
||||
lane(0, "zz", Some(1.0)),
|
||||
lane(0, "b", Some(8.0)),
|
||||
];
|
||||
let mut ins = Insert::new("gain");
|
||||
for (k, v) in [("a", 1.0), ("b", 2.0), ("c", 3.0), ("d", 4.0)] {
|
||||
ins.params.insert(k.into(), v);
|
||||
}
|
||||
let (mut slot, sets) = recorder();
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
let got = sets.lock().unwrap().clone();
|
||||
let want: Vec<(String, f32)> = [("a", 1.0), ("b", 9.0), ("c", 3.0), ("d", 4.0)].iter().map(|(k, v)| (k.to_string(), *v)).collect();
|
||||
assert_eq!(got, want);
|
||||
// Unchanged values are not sent again.
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
assert_eq!(sets.lock().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_params_and_lanes_apply_without_a_quadratic_search() {
|
||||
let n = 40_000;
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.automation = (0..n).rev().map(|i| lane(0, &format!("p{i:06}"), Some(i as f32))).collect();
|
||||
let mut ins = Insert::new("gain");
|
||||
for i in 0..n {
|
||||
ins.params.insert(format!("p{i:06}"), -1.0);
|
||||
}
|
||||
let (mut slot, sets) = recorder();
|
||||
let start = std::time::Instant::now();
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
let got = sets.lock().unwrap();
|
||||
assert_eq!(got.len(), n);
|
||||
assert!(got.iter().all(|(k, v)| k == &format!("p{:06}", *v as usize)));
|
||||
}
|
||||
}
|
||||
|
||||
+211
-38
@@ -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<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
/// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm,
|
||||
/// summed over the earlier segments). Lets tick/sample conversions binary-search the segment
|
||||
/// instead of walking every earlier tempo change, so converting many positions against a large
|
||||
/// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes.
|
||||
#[serde(skip)]
|
||||
elapsed: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The serialized form of [`TempoMap`], without the derived lookup table.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TempoMapData {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
}
|
||||
|
||||
impl From<TempoMapData> for TempoMap {
|
||||
fn from(d: TempoMapData) -> Self {
|
||||
TempoMap::from_parts(d.tempos, d.meters)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TempoMap {
|
||||
fn default() -> Self {
|
||||
TempoMap { 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<TempoEvent>, meters: Vec<MeterEvent>) -> Self {
|
||||
let mut m = TempoMap { tempos, meters, elapsed: Vec::new() };
|
||||
m.reindex();
|
||||
m
|
||||
}
|
||||
|
||||
/// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo.
|
||||
fn reindex(&mut self) {
|
||||
let mut elapsed = Vec::with_capacity(self.tempos.len());
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for (i, e) in self.tempos.iter().enumerate() {
|
||||
if i > 0 {
|
||||
// Saturating: a file's ticks are indexed before `sanitize` orders them.
|
||||
acc += e.tick.saturating_sub(seg_tick) as f64 / bpm;
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
elapsed.push(acc);
|
||||
}
|
||||
self.elapsed = elapsed;
|
||||
}
|
||||
|
||||
/// Samples per quarter-note minute (the unit of `elapsed`) at `sr`.
|
||||
fn samples_per_elapsed(sr: SampleRate) -> f64 {
|
||||
sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64
|
||||
}
|
||||
|
||||
/// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for
|
||||
/// the first), bpm and elapsed time at its start.
|
||||
fn segment(&self, i: usize) -> (i64, f64, f64) {
|
||||
let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm);
|
||||
let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) };
|
||||
(tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_tempo(0, bpm)?;
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -153,18 +213,52 @@ impl TempoMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the meters after `tick`, returning each with the bar it starts.
|
||||
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
|
||||
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
|
||||
/// walk per meter, so a session with many meter changes edits in linear time.
|
||||
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
|
||||
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
|
||||
let mut later = Vec::new();
|
||||
let (mut bar, mut seg_tick) = (1i64, 0i64);
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let tpb = m.ticks_per_bar();
|
||||
if m.tick > tick {
|
||||
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
|
||||
}
|
||||
if let Some(n) = self.meters.get(i + 1) {
|
||||
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
|
||||
seg_tick = n.tick;
|
||||
}
|
||||
}
|
||||
self.meters.retain(|e| e.tick <= tick);
|
||||
later
|
||||
}
|
||||
|
||||
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
|
||||
fn last_segment(&self) -> (i64, i64) {
|
||||
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
if let Some(n) = self.meters.get(i + 1) {
|
||||
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
|
||||
cur_tick = n.tick;
|
||||
}
|
||||
}
|
||||
(cur_bar, cur_tick)
|
||||
}
|
||||
|
||||
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
|
||||
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
|
||||
/// before it (meters off their bar lines) takes the full walk.
|
||||
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
|
||||
let (mut cur_bar, mut cur_tick) = self.last_segment();
|
||||
for (bar, ev) in later {
|
||||
let tick = self.bar_start_tick(bar);
|
||||
let tick = if bar >= cur_bar {
|
||||
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
|
||||
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
|
||||
} else {
|
||||
self.bar_start_tick(bar)
|
||||
};
|
||||
self.put_meter(MeterEvent { tick, ..ev });
|
||||
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,36 +298,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 +397,7 @@ impl TempoMap {
|
||||
e.bpm = valid_bpm(e.bpm * factor)?;
|
||||
}
|
||||
self.tempos = out;
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -396,6 +475,36 @@ mod tests {
|
||||
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
|
||||
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
|
||||
// minutes to edit here, and even to build through `set_meter`.
|
||||
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
|
||||
let mut tick = 0;
|
||||
for bar in 1..200_000i64 {
|
||||
let numerator = 3 + (bar % 2) as u32;
|
||||
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
|
||||
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
|
||||
}
|
||||
let sample = [1usize, 1_000, 199_998];
|
||||
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
|
||||
assert_eq!(bars(&m), [2, 1_001, 199_999]);
|
||||
m.set_meter(0, 7, 8).unwrap();
|
||||
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
|
||||
assert_eq!(m.meters.len(), 200_000);
|
||||
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
|
||||
assert!(m.remove_meter(m.meters[1].tick));
|
||||
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
|
||||
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
|
||||
// bar 2 of the new meter, then two bars of their own later.
|
||||
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
|
||||
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
|
||||
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
|
||||
off.set_meter(0, 2, 4).unwrap();
|
||||
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
|
||||
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eighth_note_meters() {
|
||||
let m = TempoMap::new(120.0, 6, 8).unwrap();
|
||||
@@ -407,24 +516,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 +555,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<ParamChanges>,
|
||||
pub(super) out_params: ComWrapper<ParamChanges>,
|
||||
pub(super) in_events: ComWrapper<EventList>,
|
||||
@@ -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),
|
||||
|
||||
@@ -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::<ModuleEntry>(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<ModuleExit> = 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::<GetFactory>(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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<FUnknown>()
|
||||
ComWrapper::new(Wide { buses, channels, mode: AtomicI32::new(-1), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ Results go to standard output, as JSON where a program is meant to read them. `i
|
||||
file), `describe`, `app` and the `--inspect` output of `run` and `script` pretty-print it; `--compact`
|
||||
prints the same JSON on one line. The result lines of `run` and `script` (`ID: JSON`) and the
|
||||
`--bounce` and `--save` results are always one line each. `commands`, `plugins`, `parity` and the
|
||||
`.scraft` summary of `info` are text. Errors go to standard error as `error: <message>`. `mcp` writes only the protocol
|
||||
`.scraft` summary of `info` are text. Errors go to standard error as `error: <message>`, and so do the warnings and errors SoundCraft and the plugin hosts log (`warning: <message>`, `error: <message>`), such as a plugin that fails to load. `mcp` writes only the protocol
|
||||
to standard output.
|
||||
|
||||
| Exit code | Meaning |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user