mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-05 01:40:14 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db9563379 | ||
|
|
341a112757 | ||
|
|
594d0ade4f | ||
|
|
92e6973640 | ||
|
|
2b03df3ad4 | ||
|
|
5fabf28c31 | ||
|
|
fa34e0c182 | ||
|
|
ccebb8ff93 | ||
|
|
b7d78c7e9e | ||
|
|
b26ab2b25c | ||
|
|
76f58fcb81 | ||
|
|
6a4c055094 | ||
|
|
d5564bce05 | ||
|
|
87ad11cccc | ||
|
|
b11ffe73ae | ||
|
|
f91b4173b6 | ||
|
|
98c3a307c3 | ||
|
|
6492b5f27e | ||
|
|
9c202d6f17 | ||
|
|
44b864ffaf | ||
|
|
035de82c84 | ||
|
|
181f329ec8 | ||
|
|
5ba9f9d7b8 | ||
|
|
c15682a088 | ||
|
|
0d253b6d8c | ||
|
|
c0f2f7dc87 | ||
|
|
f99407fb1f | ||
|
|
636454e639 | ||
|
|
7546ab992b | ||
|
|
f86b077cdf | ||
|
|
aa250cc1d7 | ||
|
|
dbcbcbac00 | ||
|
|
351b9d3a55 | ||
|
|
da02c19fcc | ||
|
|
64540f109b | ||
|
|
0af1ad909e | ||
|
|
7587130d59 | ||
|
|
e4f0599a2a | ||
|
|
17d3f00700 | ||
|
|
f46ea7d70c | ||
|
|
1403bbc4d3 | ||
|
|
b0793ca875 | ||
|
|
1c350101bc | ||
|
|
36d42e0136 | ||
|
|
c692648200 | ||
|
|
bf274560ae | ||
|
|
087978a4a0 | ||
|
|
9b5554e312 | ||
|
|
04dab602c7 | ||
|
|
576ff36f61 | ||
|
|
4b06407dae | ||
|
|
1a95692f50 | ||
|
|
7dfa7e470f | ||
|
|
8f7eb4d777 | ||
|
|
ffc035ba29 | ||
|
|
e1f10ccdc8 | ||
|
|
627bbbf9cc | ||
|
|
84baa5debc | ||
|
|
468bec487c | ||
|
|
71f899baac | ||
|
|
ccbeec6680 | ||
|
|
381832447c | ||
|
|
d7c194d728 | ||
|
|
f92150c64e | ||
|
|
66f305b747 | ||
|
|
b9accf92a8 | ||
|
|
d33cac774a | ||
|
|
b48a939d64 | ||
|
|
7849e8cf51 |
@@ -41,3 +41,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
Generated
+35
-5
@@ -1422,6 +1422,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1475,6 +1488,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -4374,6 +4407,7 @@ dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"log",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4489,6 +4523,7 @@ dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
@@ -6509,11 +6544,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
@@ -60,6 +60,8 @@ craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
web-time = "1.1"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -222,10 +222,33 @@ mod tests {
|
||||
assert!(plugin_info("reverb").is_none());
|
||||
}
|
||||
|
||||
/// A new directory that this test alone created, readable only by its user on Unix. Creation
|
||||
/// fails if the path already exists: the test then loads native libraries from it, so it must
|
||||
/// never reuse a directory someone else could have put a library in. It lives next to the test
|
||||
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
|
||||
/// directory without a sticky bit or with inheritable write access, where another user could
|
||||
/// rename or populate it. Anyone who can write to the build directory can already replace the
|
||||
/// test binary itself, so this parent adds no new trust.
|
||||
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let parent = exe.parent().expect("test binary has a parent directory");
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
|
||||
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let builder = {
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_files_are_errors_not_crashes() {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let d = fresh_private_dir("soundcraft-clap-garbage");
|
||||
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
std::fs::write(d.join("empty.clap"), b"").unwrap();
|
||||
@@ -239,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 { .. }));
|
||||
|
||||
@@ -150,7 +150,9 @@ mod tests {
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
// Created exclusively: a folder still there after the cleanup (someone else's) would
|
||||
// let a link inside it redirect the test's writes.
|
||||
std::fs::create_dir(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -212,6 +212,9 @@ impl Autosave {
|
||||
let audio_dir = self.dir.join("Audio Files");
|
||||
let mut s = session.clone();
|
||||
let mut copies = BTreeMap::new();
|
||||
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
|
||||
// (a session may name one file under many ids) share one file.
|
||||
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
|
||||
for src in &mut s.sources {
|
||||
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
|
||||
let audio = s.pool.get(src.id);
|
||||
@@ -224,6 +227,7 @@ impl Autosave {
|
||||
let Some(audio) = audio else { continue };
|
||||
let name = match self.copies.remove(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
|
||||
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
|
||||
_ => {
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
|
||||
self.next += 1;
|
||||
@@ -235,13 +239,16 @@ impl Autosave {
|
||||
};
|
||||
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
|
||||
src.unsaved = true;
|
||||
written.insert(Arc::as_ptr(audio), name.clone());
|
||||
copies.insert(src.id, (Arc::downgrade(audio), name));
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
|
||||
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
|
||||
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
|
||||
let _ = std::fs::remove_file(audio_dir.join(name));
|
||||
if !self.copies.values().any(|(_, kept)| *kept == name) {
|
||||
let _ = std::fs::remove_file(audio_dir.join(name));
|
||||
}
|
||||
}
|
||||
if let Some(r) = recording {
|
||||
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
@@ -507,6 +514,25 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_sharing_one_buffer_share_one_copy() {
|
||||
let dir = folder("autosave-aliases");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
// Nine more source ids over the same decoded audio, as a session file may declare.
|
||||
let s = e.session_mut();
|
||||
let (src, audio) = (s.sources[0].clone(), s.pool.get(s.sources[0].id).cloned().unwrap());
|
||||
for _ in 0..9 {
|
||||
let id = SourceId(s.alloc());
|
||||
s.sources.push(soundcraft_model::Source { id, ..src.clone() });
|
||||
s.pool.insert(id, audio.clone());
|
||||
}
|
||||
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1, "one file for the ten ids");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_recorded_after_undo_gets_its_own_copy() {
|
||||
let dir = folder("autosave-undo");
|
||||
|
||||
@@ -347,7 +347,10 @@ pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
|
||||
|
||||
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
|
||||
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
|
||||
// Each clip once, in the order given: a repeated id would otherwise be processed (and,
|
||||
// for a render, re-rendered into another source) once per repeat.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).filter(|id| seen.insert(*id)).collect();
|
||||
}
|
||||
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
|
||||
return vec![ClipId(n)];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -425,7 +425,7 @@ fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
let moves: Vec<(soundcraft_model::ClipId, i64)> =
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
|
||||
let s = e.session_mut();
|
||||
for (id, d) in &moves {
|
||||
crate::edit::move_clips(s, &[*id], *d, &[]);
|
||||
|
||||
@@ -301,6 +301,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
cut_track(s, *t, range, sh, follow);
|
||||
}
|
||||
s.edit.selection = soundcraft_time::Range::point(range.start);
|
||||
cb.cut_from = Some(range.start);
|
||||
}
|
||||
let n: usize = cb.tracks.iter().map(Vec::len).sum();
|
||||
e.clipboard = cb;
|
||||
@@ -328,6 +329,7 @@ pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
|
||||
cb
|
||||
}
|
||||
|
||||
/// Pastes the clipboard at an explicit position or at the current edit insertion point.
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let given = position_param(e, "edit.paste", p, "at")?;
|
||||
@@ -335,9 +337,10 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let at = match given {
|
||||
Some(at) => at,
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => {
|
||||
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
|
||||
None if s.edit.edit_mode == EditMode::GridRelative && s.edit.selection.is_empty() && cb.cut_from == Some(s.edit.selection.start) => {
|
||||
s.edit.selection.start
|
||||
}
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => s.grid_line_at_or_before(s.edit.selection.start).saturating_add(cb.grid_offset),
|
||||
None => s.edit.selection.start,
|
||||
};
|
||||
let sh = shuffle(s) && !cb.automation_only;
|
||||
@@ -668,11 +671,16 @@ fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"separated": n}))
|
||||
}
|
||||
|
||||
/// Separates selected clips at every active Grid boundary in the requested range.
|
||||
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
|
||||
let r = range_param(e, "edit.separate_on_grid", p)?;
|
||||
let s = e.session_mut();
|
||||
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
|
||||
let max = if s.edit.grid_clips_markers { usize::MAX } else { 10_001 };
|
||||
let lines = s.grid_lines(r.start, r.end, max);
|
||||
if lines.len() > 10_000 {
|
||||
return Err(bad("edit.separate_on_grid", "the range contains more than 10,000 grid boundaries"));
|
||||
}
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
for at in &lines {
|
||||
@@ -956,6 +964,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"moved": n, "by": step}))
|
||||
}
|
||||
|
||||
/// Same-track placement at the existing start is a no-op; quantization is a separate command.
|
||||
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
@@ -978,17 +987,21 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mode = s.edit.edit_mode;
|
||||
let delta = match first {
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
|
||||
Some(_) if mode.is_grid() && p.get("to").is_some() && delta == 0 && dest.iter().all(|(from, to)| from == to) => 0,
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative, &ids),
|
||||
_ => delta,
|
||||
};
|
||||
let effective_delta = first.map_or(delta, |clip| delta.max(clip.start.saturating_neg()));
|
||||
let s = e.session_mut();
|
||||
let n = if mode == EditMode::Shuffle {
|
||||
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
|
||||
} else if effective_delta == 0 && dest.iter().all(|(from, to)| from == to) {
|
||||
0
|
||||
} else {
|
||||
ops::move_clips(s, &ids, delta, &dest)
|
||||
ops::move_clips(s, &ids, effective_delta, &dest)
|
||||
};
|
||||
s.edit.selected_clips = ids;
|
||||
Ok(json!({"moved": n, "by": delta}))
|
||||
Ok(json!({"moved": n, "by": if mode == EditMode::Shuffle { delta } else { effective_delta }}))
|
||||
}
|
||||
|
||||
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+186
-14
@@ -3,9 +3,9 @@ use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_time::TimeFormat;
|
||||
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
|
||||
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, MAX_POSITION, NoteValue, TimeFormat, TimecodeValue};
|
||||
|
||||
/// Returns commands that control saved view, counter, Grid, and Nudge state.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
|
||||
@@ -65,7 +65,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
|
||||
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
|
||||
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
|
||||
e.session_mut().edit.main_counter = f;
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.main_counter = f;
|
||||
let scale = grid_scale(f);
|
||||
if edit.grid_follow_main && edit.grid.scale() != scale {
|
||||
edit.grid = GridValue::for_scale(scale);
|
||||
}
|
||||
if edit.grid_follow_main {
|
||||
edit.grid_clips_markers = false;
|
||||
}
|
||||
if edit.nudge_follow_main && edit.nudge.scale() != scale {
|
||||
edit.nudge = GridValue::for_scale(scale);
|
||||
}
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
|
||||
@@ -73,37 +84,80 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
e.session_mut().edit.sub_counter = f;
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?;
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples|clips_markers, follow_main?: bool, lines?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.grid;
|
||||
let requested_scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.grid", "unknown grid value or format"))?;
|
||||
let lines = p.get("lines").and_then(Value::as_bool);
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let s = e.session_mut();
|
||||
s.edit.grid = g;
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
if let Some(scale) = requested_scale {
|
||||
s.edit.grid_clips_markers = scale == GridScale::ClipsMarkers;
|
||||
}
|
||||
if follow == Some(true) {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
s.edit.grid_follow_main = follow.unwrap_or(
|
||||
s.edit.grid_follow_main && !s.edit.grid_clips_markers && g.scale() == grid_scale(s.edit.main_counter),
|
||||
);
|
||||
if let Some(l) = lines { s.edit.grid_lines = l }
|
||||
Ok(json!({"grid": g.label()}))
|
||||
let format = if s.edit.grid_clips_markers { GridScale::ClipsMarkers } else { g.scale() };
|
||||
Ok(json!({"grid": g.label(), "format": format.id(), "follow_main": s.edit.grid_follow_main}))
|
||||
}),
|
||||
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
|
||||
Ok(json!({"value": s.edit.grid_lines}))
|
||||
}),
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
|
||||
e.session_mut().edit.nudge = g;
|
||||
Ok(json!({"nudge": g.label()}))
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples, follow_main?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.nudge;
|
||||
if str_param(p, "format").and_then(GridScale::from_id) == Some(GridScale::ClipsMarkers) {
|
||||
return Err(bad("view.nudge", "Clips/Markers is available only for Grid"));
|
||||
}
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.nudge", "unknown nudge value or format"))?;
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.nudge = g;
|
||||
edit.nudge_follow_main = follow.unwrap_or(edit.nudge_follow_main && g.scale() == grid_scale(edit.main_counter));
|
||||
Ok(json!({"nudge": g.label(), "format": g.scale().id(), "follow_main": edit.nudge_follow_main}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
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}))
|
||||
@@ -129,7 +183,33 @@ fn value_or_step(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0)))
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values.
|
||||
/// Maps a counter format to the corresponding Grid/Nudge scale.
|
||||
fn grid_scale(format: TimeFormat) -> GridScale {
|
||||
match format {
|
||||
TimeFormat::BarsBeats => GridScale::BarsBeats,
|
||||
TimeFormat::MinSecs => GridScale::MinSecs,
|
||||
TimeFormat::Timecode => GridScale::Timecode,
|
||||
TimeFormat::FeetFrames => GridScale::FeetFrames,
|
||||
TimeFormat::Samples => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an explicit value, a step, or a scale change while preserving the current increment.
|
||||
fn requested_grid_value(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
return value_or_step(p, current);
|
||||
}
|
||||
let scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
match scale {
|
||||
Some(GridScale::ClipsMarkers) => Some(current),
|
||||
Some(scale) if current.scale() == scale => Some(current),
|
||||
Some(scale) => Some(GridValue::for_scale(scale)),
|
||||
None if p.get("follow_main").is_some() || p.get("lines").is_some() => Some(current),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values; legacy `1` and `1/1` mean a bar, while `whole` means four quarter notes.
|
||||
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
let v = p.get(key)?;
|
||||
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
|
||||
@@ -138,15 +218,23 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Frames(f.clamp(1, 100_000)));
|
||||
}
|
||||
if let Some(value) = v.get("timecode").and_then(Value::as_str).and_then(TimecodeValue::from_id) {
|
||||
return Some(GridValue::Timecode(value));
|
||||
}
|
||||
if let Some(value) = v.get("feet_frames").and_then(Value::as_str).and_then(FeetFramesValue::from_id) {
|
||||
return Some(GridValue::FeetFrames(value));
|
||||
}
|
||||
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
|
||||
}
|
||||
let s = v.as_str()?.trim().to_ascii_lowercase();
|
||||
let triplet = s.ends_with('t') || s.contains("triplet");
|
||||
let dotted = s.ends_with('.') || s.contains("dotted");
|
||||
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
|
||||
let core = s.replace("triplet", "").replace("dotted", "");
|
||||
let core = core.trim().trim_end_matches(['t', '.']);
|
||||
let value = match core.trim() {
|
||||
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
|
||||
"1" | "1/1" | "1 bar" | "bar" => NoteValue::Bar,
|
||||
"whole" | "whole note" => NoteValue::Whole,
|
||||
"1/2" => NoteValue::Half,
|
||||
"1/4" => NoteValue::Quarter,
|
||||
"1/8" => NoteValue::Eighth,
|
||||
@@ -162,15 +250,59 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_time::Samples;
|
||||
/// Grid values accept compact and word-form musical modifiers plus every absolute scale.
|
||||
#[test]
|
||||
fn grid_values_parse() {
|
||||
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8 triplet"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
|
||||
for alias in ["1", "1/1"] {
|
||||
assert_eq!(grid_param(&json!({"v": alias}), "v"), Some(GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }));
|
||||
}
|
||||
assert_eq!(grid_param(&json!({"v": "whole"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "whole note"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
|
||||
assert_eq!(grid_param(&json!({"v": {"timecode": "quarter_frame"}}), "v"), Some(GridValue::Timecode(TimecodeValue::QuarterFrame)));
|
||||
assert_eq!(grid_param(&json!({"v": {"feet_frames": "foot"}}), "v"), Some(GridValue::FeetFrames(FeetFramesValue::Foot)));
|
||||
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
|
||||
}
|
||||
|
||||
/// Explicit values and formats retain Follow Main only when the selected scale still matches.
|
||||
#[test]
|
||||
fn explicit_grid_and_nudge_scales_keep_follow_state_consistent() {
|
||||
for command in ["view.grid", "view.nudge"] {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.main_counter", &json!({"format": "min_secs"})).unwrap();
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"value": {"seconds": 0.1}})).unwrap();
|
||||
assert_eq!(result["follow_main"], true);
|
||||
let result = e.execute(command, &json!({"value": {"feet_frames": "foot"}})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"format": "bars_beats"})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
}
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers"})).unwrap();
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.grid", &json!({"value": {"samples": 100}})).unwrap();
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
assert_eq!(e.session().edit.grid, GridValue::Samples(100));
|
||||
}
|
||||
|
||||
/// Historical bar aliases retain meter-dependent duration; the explicit whole note remains distinct.
|
||||
#[test]
|
||||
fn legacy_bar_aliases_and_whole_notes_remain_distinct_in_odd_meter() {
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().tempo = soundcraft_time::TempoMap::new(120.0, 3, 4).unwrap();
|
||||
for (value, expected) in [("1", 72_000), ("1/1", 72_000), ("whole", 96_000)] {
|
||||
e.execute("view.grid", &json!({"value": value, "follow_main": false})).unwrap();
|
||||
let s = e.session();
|
||||
assert_eq!(s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate), expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn view(e: &Engine) -> (f64, Samples) {
|
||||
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
|
||||
}
|
||||
@@ -186,6 +318,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();
|
||||
@@ -222,6 +365,7 @@ mod tests {
|
||||
assert_eq!(state(&e), before);
|
||||
}
|
||||
|
||||
/// Grid and Nudge steps drive edit movement while preserving their selected increments.
|
||||
#[test]
|
||||
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
|
||||
let mut e = Engine::default();
|
||||
@@ -243,4 +387,32 @@ mod tests {
|
||||
assert_eq!(e.session().edit.selection.start, 6_000);
|
||||
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
|
||||
}
|
||||
|
||||
/// Grid and Nudge expose independent scale-following state, while Clips/Markers remains Grid-only.
|
||||
#[test]
|
||||
fn grid_and_nudge_choose_scales_follow_the_main_counter_and_keep_clips_markers_grid_only() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "timecode", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::Frame));
|
||||
e.execute("view.grid", &json!({"value": {"timecode": "quarter_frame"}})).unwrap();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
|
||||
assert!(e.session().edit.grid_clips_markers);
|
||||
assert!(e.execute("view.nudge", &json!({"format": "clips_markers"})).is_err());
|
||||
|
||||
e.execute("view.grid", &json!({"follow_main": true})).unwrap();
|
||||
e.execute("view.nudge", &json!({"follow_main": true})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Seconds(1.0), GridValue::Seconds(1.0)));
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
e.execute("view.main_counter", &json!({"format": "samples"})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Samples(100), GridValue::Samples(100)));
|
||||
assert!(e.session().edit.grid_follow_main && e.session().edit.nudge_follow_main);
|
||||
|
||||
e.execute("view.grid", &json!({"format": "bars_beats", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert_eq!(e.session().edit.nudge, GridValue::Timecode(TimecodeValue::Frame));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
zoom: _,
|
||||
@@ -130,7 +133,17 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
scrolling,
|
||||
solo_mode,
|
||||
),
|
||||
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
|
||||
(
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
pre_fader_metering,
|
||||
delay_compensation,
|
||||
keyboard_focus,
|
||||
&navigation.timeline_selection,
|
||||
flags,
|
||||
values,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,6 +199,9 @@ mod tests {
|
||||
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("grid_lines", |e| e.grid_lines = !e.grid_lines),
|
||||
("grid_follow_main", |e| e.grid_follow_main = !e.grid_follow_main),
|
||||
("nudge_follow_main", |e| e.nudge_follow_main = !e.nudge_follow_main),
|
||||
("grid_clips_markers", |e| e.grid_clips_markers = !e.grid_clips_markers),
|
||||
("main_counter", |e| e.main_counter = TimeFormat::Samples),
|
||||
("sub_counter", |e| e.sub_counter = None),
|
||||
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
|
||||
|
||||
+11
-21
@@ -528,9 +528,15 @@ fn join_gain_env(l: &mut Clip, r: &Clip) {
|
||||
}
|
||||
|
||||
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
|
||||
/// elsewhere (see [`destinations`]).
|
||||
/// elsewhere (see [`destinations`]). Same-track moves clamped to zero leave all material intact.
|
||||
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
|
||||
let ids: HashSet<ClipId> = ids.iter().copied().collect();
|
||||
// Limit the shift before removing any clips, so a stationary edit cannot overwrite overlaps.
|
||||
let limit = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| ids.contains(&c.id) && !locked(c)).map(|c| c.start.saturating_neg()).max();
|
||||
let delta = delta.max(limit.unwrap_or(0));
|
||||
if delta == 0 && dest.iter().all(|(from, to)| from == to) {
|
||||
return 0;
|
||||
}
|
||||
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
|
||||
for t in &mut s.tracks {
|
||||
let tid = t.id;
|
||||
@@ -546,8 +552,6 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
|
||||
}
|
||||
}
|
||||
let n = moving.len();
|
||||
// Limit the shift once, so the group stops at zero together and keeps its spacing.
|
||||
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
|
||||
let placed = moving.into_iter().map(|(tid, mut c)| {
|
||||
c.start = c.start.saturating_add(delta);
|
||||
(lands(dest, tid), c)
|
||||
@@ -572,20 +576,7 @@ fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
|
||||
|
||||
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
|
||||
pub fn shown(s: &Session, t: &Track) -> bool {
|
||||
!t.hidden && !folder_collapsed(s, t)
|
||||
}
|
||||
|
||||
/// Hidden because a folder at any level above this track is closed.
|
||||
fn folder_collapsed(s: &Session, track: &Track) -> bool {
|
||||
let mut current = track.folder;
|
||||
for _ in 0..64 {
|
||||
let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false };
|
||||
if !folder.folder_open {
|
||||
return true;
|
||||
}
|
||||
current = folder.folder;
|
||||
}
|
||||
false
|
||||
s.track_shown(t)
|
||||
}
|
||||
|
||||
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
|
||||
@@ -790,12 +781,11 @@ pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(T
|
||||
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
|
||||
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
|
||||
/// grid steps, keeping its offset from the grid.
|
||||
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
|
||||
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool, moving: &[ClipId]) -> Samples {
|
||||
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
|
||||
let anchor = clip.start.saturating_add(sync);
|
||||
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
|
||||
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
|
||||
let from = if relative { s.grid_line_at_or_before_excluding(anchor, moving) } else { anchor };
|
||||
s.snap_to_grid_excluding(from.saturating_add(raw), moving).saturating_sub(from)
|
||||
}
|
||||
|
||||
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
|
||||
|
||||
@@ -330,9 +330,9 @@ fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
|
||||
let (s, t) = session_with_clip();
|
||||
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
let c = s.track(t).unwrap().clips()[0].clone();
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
|
||||
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false, &[c.id]), step);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true, &[c.id]), step + 1000);
|
||||
assert_eq!(grid_delta(&s, &c, step / 3, true, &[c.id]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -119,6 +119,8 @@ pub struct Clipboard {
|
||||
pub markers: Vec<soundcraft_model::MemoryLocation>,
|
||||
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
|
||||
pub grid_offset: Samples,
|
||||
/// Original insertion point when the clipboard was populated by Cut.
|
||||
pub cut_from: Option<Samples>,
|
||||
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
|
||||
pub automation_only: bool,
|
||||
/// The Auto Rename schemes of the copied clips, keyed as in their `auto_name`.
|
||||
@@ -128,7 +130,7 @@ pub struct Clipboard {
|
||||
impl Clipboard {
|
||||
/// An empty clipboard for material copied from `range`.
|
||||
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
|
||||
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
let line = s.grid_line_at_or_before(range.start);
|
||||
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +418,9 @@ fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<Str
|
||||
const STAFF_GAP: f64 = 70.0;
|
||||
const SYSTEM_GAP: f64 = 40.0;
|
||||
let n_measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
|
||||
let per = setup.bars_per_system.max(1) as usize;
|
||||
// The layout range the setup offers (1 to 16): a caller's own value can't multiply the
|
||||
// barlines drawn per system beyond it.
|
||||
let per = setup.bars_per_system.clamp(1, 16) as usize;
|
||||
let systems = n_measures.div_ceil(per).max(1);
|
||||
let label_w = if setup.show_track_names { 70.0 } else { 0.0 };
|
||||
let staff_x0 = MARGIN + label_w + 34.0;
|
||||
@@ -782,6 +784,14 @@ mod tests {
|
||||
assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_per_system_is_held_to_the_supported_range() {
|
||||
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: u32::MAX, show_track_names: false };
|
||||
let part = Part { name: "p".into(), bass: false, measures: vec![Measure { numerator: 4, denominator: 4, items: Vec::new() }] };
|
||||
let out = svg_budgeted(std::slice::from_ref(&part), &setup, 1 << 20).unwrap();
|
||||
assert_eq!(out.matches("stroke-width=\"1\"").count(), 16, "sixteen barlines, not billions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_title_is_refused_by_the_header_check() {
|
||||
// An oversized part name/title must be caught by the header check even with no measures:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -220,6 +220,129 @@ fn relative_grid_pastes_keep_the_offset_from_the_grid() {
|
||||
assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000)));
|
||||
}
|
||||
|
||||
/// A relative paste restores the insertion point even when Cut creates a new boundary there.
|
||||
#[test]
|
||||
fn relative_grid_cut_pastes_back_at_its_insertion_boundary() {
|
||||
let (mut e, t, _) = long("relative_grid", &[(1_000, 2_000, 0)]);
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 1_500, "end": 2_000}));
|
||||
run(&mut e, "edit.paste", json!({"tracks": [t.0]}));
|
||||
assert_eq!(e.session().edit.selection.start, 1_500);
|
||||
assert!(clips(&e, t).contains(&(1_500, 500)), "{:?}", clips(&e, t));
|
||||
}
|
||||
|
||||
/// Moving a group excludes all its clips from Clips/Markers snap candidates.
|
||||
#[test]
|
||||
fn clips_markers_grid_ignores_every_clip_being_moved() {
|
||||
let (mut e, _, ids) = long("grid", &[(1_000, 1_000, 0), (4_000, 500, 2_000)]);
|
||||
e.session_mut().add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 1_000}));
|
||||
assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000, 4_000]);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 2_200}));
|
||||
assert_eq!(start(&e, ids[0]), 2_500);
|
||||
assert_eq!(start(&e, ids[1]), 5_500);
|
||||
}
|
||||
|
||||
/// Zero and clamped moves preserve overlaps while off-grid quantization still changes position.
|
||||
#[test]
|
||||
fn zero_distance_move_keeps_overlapping_material_and_quantize_still_moves() {
|
||||
let (mut e, t, ids) = long("slip", &[(1_000, 1_000, 0), (1_500, 1_000, 2_000)]);
|
||||
let before = material(&e, t);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 0}));
|
||||
assert_eq!(material(&e, t), before);
|
||||
|
||||
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
|
||||
run(&mut e, "clip.quantize_to_grid", json!({}));
|
||||
assert_eq!(start(&e, ids[0]), 0);
|
||||
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
|
||||
let before = material(&e, t);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": -1_000}));
|
||||
assert_eq!(material(&e, t), before);
|
||||
}
|
||||
|
||||
/// A vertical move remains a real edit and snaps its off-grid start on the destination track.
|
||||
#[test]
|
||||
fn zero_horizontal_grid_move_to_another_track_still_snaps() {
|
||||
let (mut e, _, [_, clip]) = engine("grid");
|
||||
let destination = new_track(&mut e);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [clip.0], "to": 2_000, "track": destination.0}));
|
||||
let (track, moved) = e.session().find_clip(clip).unwrap();
|
||||
assert_eq!(track, destination);
|
||||
assert_eq!(moved.start, 0);
|
||||
}
|
||||
|
||||
/// Nudge and quantization must preserve overlapping material when the clip cannot move.
|
||||
#[test]
|
||||
fn stationary_nudges_and_quantization_preserve_overlaps() {
|
||||
for (command, params) in [("edit.nudge", json!({"direction": -1})), ("clip.quantize_to_grid", json!({}))] {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
|
||||
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
|
||||
let before = e.session().track(t).unwrap().clips().to_vec();
|
||||
let result = e.execute(command, ¶ms).unwrap();
|
||||
assert_eq!(e.session().track(t).unwrap().clips(), before, "{command}");
|
||||
if command == "edit.nudge" {
|
||||
assert_eq!(result["moved"], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every Nudge scale shifts clips and insertion across a tempo change and drop-frame rates.
|
||||
#[test]
|
||||
fn every_nudge_scale_moves_by_its_selected_increment() {
|
||||
use soundcraft_time::{FrameRate, SampleRate, TICKS_PER_QUARTER};
|
||||
for rate in [FrameRate::Fps30, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
for at in [48_000, 144_000] {
|
||||
let cases = [
|
||||
(json!("1/4"), if at < 96_000 { 24_000 } else { 32_000 }),
|
||||
(json!({"seconds": 0.5}), 24_000),
|
||||
(json!({"timecode": "5_frames"}), rate.samples_at_frame(5, SampleRate::HZ_48000)),
|
||||
(json!({"feet_frames": "foot"}), rate.samples_at_frame(16, SampleRate::HZ_48000)),
|
||||
(json!({"samples": 100}), 100),
|
||||
];
|
||||
for (value, expected) in cases {
|
||||
let (mut e, track, ids) = long("slip", &[(at, 1_000, 0)]);
|
||||
e.session_mut().frame_rate = rate;
|
||||
e.session_mut().tempo.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
|
||||
run(&mut e, "edit.select", json!({"tracks": [track.0], "clips": [ids[0].0], "start": at, "end": at}));
|
||||
run(&mut e, "view.nudge", json!({"value": value, "follow_main": false}));
|
||||
let result = e.execute("edit.nudge", &json!({"direction": 1})).unwrap();
|
||||
assert_eq!(result["by"], expected, "{rate:?} at {at}");
|
||||
assert_eq!(result["moved"], 1);
|
||||
assert_eq!(start(&e, ids[0]), at + expected);
|
||||
assert_eq!(e.session().edit.selection.start, at + expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Separation refuses oversized numeric grids before splitting any material.
|
||||
#[test]
|
||||
fn separate_on_grid_refuses_a_truncated_boundary_set() {
|
||||
let (mut e, t, _) = long("grid", &[(0, 20_000, 0)]);
|
||||
run(&mut e, "view.grid", json!({"value": {"samples": 1}, "follow_main": false}));
|
||||
assert!(e.execute("edit.separate_on_grid", &json!({"tracks": [t.0], "start": 0, "end": 10_002})).is_err());
|
||||
assert_eq!(clips(&e, t), [(0, 20_000)]);
|
||||
}
|
||||
|
||||
/// Display thinning must never hide an oversized Clips/Markers separation request.
|
||||
#[test]
|
||||
fn separate_on_grid_refuses_dense_markers_before_changing_clips() {
|
||||
let (mut e, track, _) = long("grid", &[(0, 100_000, 0)]);
|
||||
let s = e.session_mut();
|
||||
for at in 1..=10_001 {
|
||||
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, at, at);
|
||||
}
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
let before = e.session().clone();
|
||||
let undo_len = e.undo_history().len();
|
||||
let error = e.execute("edit.separate_on_grid", &json!({"tracks": [track.0], "start": 0, "end": 100_000})).unwrap_err();
|
||||
assert!(error.to_string().contains("10,000"), "{error}");
|
||||
assert_eq!(e.session(), &before);
|
||||
assert_eq!(e.undo_history().len(), undo_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffle_moves_keep_crossfade_partners_whole() {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -108,14 +110,28 @@ impl Sequence {
|
||||
|
||||
self.ctrls.sort_by_key(|c| c.tick);
|
||||
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
|
||||
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
|
||||
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
|
||||
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
|
||||
// map per tick keeps the work per event constant after a dense tick.
|
||||
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
|
||||
for c in self.ctrls.drain(..) {
|
||||
// Within a tick, replace an earlier event of the same channel+kind.
|
||||
// Only events at the same tick (the tail of `kept`) can match.
|
||||
let slot =
|
||||
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
|
||||
match slot {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
match last_at.entry((c.channel, kind_key(c.kind))) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let (tick, idx) = *e.get();
|
||||
match kept.get_mut(idx) {
|
||||
Some(slot) if tick == c.tick => *slot = c,
|
||||
_ => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ctrls = kept;
|
||||
|
||||
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
|
||||
let json = serde_json::to_string(&seq).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
|
||||
// Every channel x controller number at one tick, twice: the second copy of each wins. A
|
||||
// rescan of the tick's kept events per incoming event would make this quadratic.
|
||||
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
|
||||
let ctrls = [0, 1]
|
||||
.iter()
|
||||
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
|
||||
.collect();
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls };
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(seq.remove_duplicates(), keys.len());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert_eq!(seq.ctrls.len(), keys.len());
|
||||
assert!(seq.ctrls.iter().all(|c| c.value == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
|
||||
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
|
||||
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
|
||||
ctrls.sort_by_key(|c| c.tick);
|
||||
let key = |k: CtrlKind| match k {
|
||||
CtrlKind::Cc(n) => (0, n),
|
||||
CtrlKind::PitchBend => (1, 0),
|
||||
CtrlKind::ChannelPressure => (2, 0),
|
||||
CtrlKind::PolyPressure(p) => (3, p),
|
||||
CtrlKind::Program => (4, 0),
|
||||
};
|
||||
let mut kept: Vec<CtrlEvent> = Vec::new();
|
||||
for c in ctrls {
|
||||
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
|
||||
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let kinds = |r: u64| match r % 5 {
|
||||
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
|
||||
1 => CtrlKind::PitchBend,
|
||||
2 => CtrlKind::ChannelPressure,
|
||||
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
|
||||
_ => CtrlKind::Program,
|
||||
};
|
||||
for round in 0..200 {
|
||||
let mut ctrls = Vec::new();
|
||||
for i in 0..(next() % 64) {
|
||||
let r = next();
|
||||
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
|
||||
}
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
|
||||
let removed = seq.remove_duplicates();
|
||||
let expected = reference(ctrls.clone());
|
||||
assert_eq!(seq.ctrls, expected, "round {round}");
|
||||
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
+277
-1
@@ -5,7 +5,7 @@ use crate::{
|
||||
Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool,
|
||||
TRACK_COLORS, Track, TrackId, TrackKind, VideoSource,
|
||||
};
|
||||
use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
|
||||
use soundcraft_time::{FrameRate, GridScale, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
|
||||
|
||||
/// Native session file extension.
|
||||
pub const SESSION_EXTENSION: &str = "scraft";
|
||||
@@ -147,6 +147,7 @@ pub struct ZoomState {
|
||||
}
|
||||
|
||||
impl Default for ZoomState {
|
||||
/// Starts with a practical overview zoom and five reusable preset levels.
|
||||
fn default() -> Self {
|
||||
ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] }
|
||||
}
|
||||
@@ -179,6 +180,15 @@ pub struct EditState {
|
||||
pub playhead: Samples,
|
||||
pub grid: GridValue,
|
||||
pub nudge: GridValue,
|
||||
/// Grid/Nudge time scales follow the main counter when enabled.
|
||||
#[serde(default)]
|
||||
pub grid_follow_main: bool,
|
||||
#[serde(default)]
|
||||
pub nudge_follow_main: bool,
|
||||
/// Grid lines and snapping use visible clip boundaries and memory locations while the numeric
|
||||
/// Grid value is retained for when this mode is left.
|
||||
#[serde(default)]
|
||||
pub grid_clips_markers: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub grid_lines: bool,
|
||||
pub main_counter: TimeFormat,
|
||||
@@ -244,6 +254,7 @@ fn yes() -> bool {
|
||||
}
|
||||
|
||||
impl Default for EditState {
|
||||
/// Starts the Edit window in Slip mode with independent Grid and Nudge scales.
|
||||
fn default() -> Self {
|
||||
EditState {
|
||||
tool: Tool::Smart,
|
||||
@@ -255,6 +266,9 @@ impl Default for EditState {
|
||||
playhead: 0,
|
||||
grid: GridValue::default(),
|
||||
nudge: GridValue::Seconds(1.0),
|
||||
grid_follow_main: false,
|
||||
nudge_follow_main: false,
|
||||
grid_clips_markers: false,
|
||||
grid_lines: true,
|
||||
main_counter: TimeFormat::MinSecs,
|
||||
sub_counter: Some(TimeFormat::Samples),
|
||||
@@ -490,6 +504,138 @@ impl Session {
|
||||
soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
|
||||
}
|
||||
|
||||
/// Whether the Edit window shows a track: unhidden, with every parent folder open.
|
||||
pub fn track_shown(&self, track: &Track) -> bool {
|
||||
if track.hidden {
|
||||
return false;
|
||||
}
|
||||
let mut folder = track.folder;
|
||||
for _ in 0..64 {
|
||||
let Some(parent) = folder.and_then(|id| self.track(id)) else { return true };
|
||||
if !parent.folder_open {
|
||||
return false;
|
||||
}
|
||||
folder = parent.folder;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Collects unique visible active-playlist clip boundaries and memory-location boundaries.
|
||||
fn clip_marker_grid_points(&self, keep: impl Fn(Samples) -> bool, excluded: &[ClipId], include_zero: bool) -> Vec<Samples> {
|
||||
let mut points = Vec::new();
|
||||
let excluded: std::collections::HashSet<ClipId> = excluded.iter().copied().collect();
|
||||
if include_zero && keep(0) {
|
||||
points.push(0);
|
||||
}
|
||||
for clip in
|
||||
self.tracks.iter().filter(|track| self.track_shown(track)).flat_map(|track| track.clips()).filter(|clip| !excluded.contains(&clip.id))
|
||||
{
|
||||
points.extend([clip.start, clip.end()].into_iter().filter(|point| keep(*point)));
|
||||
}
|
||||
for marker in &self.markers {
|
||||
if keep(marker.start) {
|
||||
points.push(marker.start);
|
||||
}
|
||||
if marker.end != marker.start && keep(marker.end) {
|
||||
points.push(marker.end);
|
||||
}
|
||||
}
|
||||
points.sort_unstable();
|
||||
points.dedup();
|
||||
points
|
||||
}
|
||||
|
||||
/// Sample-domain Timecode reference; Feet+Frames applies its origin in frames separately.
|
||||
fn grid_origin(&self) -> Samples {
|
||||
match self.edit.grid.scale() {
|
||||
GridScale::Timecode => self.timecode_start,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid lines for the current Grid value, including clip and marker boundaries.
|
||||
pub fn grid_lines(&self, start: Samples, end: Samples, max: usize) -> Vec<Samples> {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.lines(start, end, self.sample_rate, self.frame_rate, self.feet_frames_start(), max);
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self
|
||||
.edit
|
||||
.grid
|
||||
.lines(start.saturating_add(origin), end.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate, max)
|
||||
.into_iter()
|
||||
.map(|point| point.saturating_sub(origin))
|
||||
.collect();
|
||||
}
|
||||
let points = self.clip_marker_grid_points(|point| point >= start && point < end, &[], true);
|
||||
if points.len() <= max {
|
||||
return points;
|
||||
}
|
||||
match max {
|
||||
0 => Vec::new(),
|
||||
1 => points.first().copied().into_iter().collect(),
|
||||
_ => {
|
||||
let span = i128::from(end).saturating_sub(i128::from(start)).max(1);
|
||||
let mut bucket = None;
|
||||
points
|
||||
.into_iter()
|
||||
.filter(|point| {
|
||||
let next = i128::from(*point).saturating_sub(i128::from(start)).saturating_mul(max as i128) / span;
|
||||
if bucket == Some(next) {
|
||||
false
|
||||
} else {
|
||||
bucket = Some(next);
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current Grid line at or before `position`.
|
||||
pub fn grid_line_at_or_before(&self, position: Samples) -> Samples {
|
||||
self.grid_line_at_or_before_excluding(position, &[])
|
||||
}
|
||||
|
||||
/// Find the current Grid line at or before `position` without boundaries of `excluded` clips.
|
||||
pub fn grid_line_at_or_before_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.line_at_or_before(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self
|
||||
.edit
|
||||
.grid
|
||||
.line_at_or_before(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate)
|
||||
.saturating_sub(origin);
|
||||
}
|
||||
self.clip_marker_grid_points(|_| true, excluded, true).into_iter().take_while(|point| *point <= position).last().unwrap_or(position)
|
||||
}
|
||||
|
||||
/// Snap to the nearest line in the current Grid.
|
||||
pub fn snap_to_grid(&self, position: Samples) -> Samples {
|
||||
self.snap_to_grid_excluding(position, &[])
|
||||
}
|
||||
|
||||
/// Snap to the nearest current Grid line without using boundaries of `excluded` clips.
|
||||
pub fn snap_to_grid_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.snap(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self.edit.grid.snap(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate).saturating_sub(origin);
|
||||
}
|
||||
let mut points = self.clip_marker_grid_points(|_| true, excluded, false);
|
||||
if !points.is_empty() {
|
||||
points.push(0);
|
||||
}
|
||||
points.into_iter().min_by_key(|point| i128::from(*point).saturating_sub(i128::from(position)).abs()).unwrap_or(position)
|
||||
}
|
||||
|
||||
pub fn next_track_color(&self) -> [u8; 3] {
|
||||
let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count();
|
||||
TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128])
|
||||
@@ -762,6 +908,136 @@ mod tests {
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
/// Clips/Markers mode snaps and draws at clip and memory-location boundaries.
|
||||
#[test]
|
||||
fn clips_markers_grid_uses_visible_clip_edges_and_memory_locations() {
|
||||
let mut s = Session::default();
|
||||
let track = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 2_000, soundcraft_midi::Sequence::default());
|
||||
let clip_id = clip.id;
|
||||
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
|
||||
let hidden = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let hidden_clip = Clip::midi(s.new_clip_id(), "Hidden", 3_200, 100, soundcraft_midi::Sequence::default());
|
||||
if let Some(track) = s.track_mut(hidden) {
|
||||
track.hidden = true;
|
||||
track.playlist_mut().unwrap().clips.push(hidden_clip);
|
||||
}
|
||||
s.add_marker("Cue", MarkerKind::Marker, 2_500, 2_500);
|
||||
s.add_marker("Selection", MarkerKind::Selection, 4_000, 4_500);
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 2_500, 3_000, 4_000, 4_500]);
|
||||
assert_eq!(s.grid_line_at_or_before(2_800), 2_500);
|
||||
assert_eq!(s.snap_to_grid(2_800), 3_000);
|
||||
assert_eq!(s.snap_to_grid(3_700), 4_000);
|
||||
assert_eq!(s.grid_lines(2_400, 4_100, 2), [2_500, 4_000]);
|
||||
assert_eq!(s.snap_to_grid(3_200), 3_000);
|
||||
assert_eq!(s.snap_to_grid_excluding(2_800, &[clip_id]), 2_500);
|
||||
|
||||
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
s.track_mut(track).unwrap().folder = Some(folder);
|
||||
s.track_mut(folder).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 2_500, 4_000, 4_500]);
|
||||
}
|
||||
|
||||
/// Open hidden folders keep their visible descendants eligible for lines and snapping.
|
||||
#[test]
|
||||
fn clips_markers_grid_keeps_visible_children_of_hidden_open_folders() {
|
||||
let mut s = Session::default();
|
||||
let outer = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let inner = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_000, 2_000, soundcraft_midi::Sequence::default());
|
||||
s.track_mut(inner).unwrap().folder = Some(outer);
|
||||
s.track_mut(inner).unwrap().hidden = true;
|
||||
s.track_mut(child).unwrap().folder = Some(inner);
|
||||
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 3_000]);
|
||||
assert_eq!(s.snap_to_grid(2_800), 3_000);
|
||||
assert_eq!(s.grid_line_at_or_before(2_800), 1_000);
|
||||
|
||||
s.track_mut(child).unwrap().hidden = true;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
assert_eq!(s.snap_to_grid(2_800), 2_800);
|
||||
s.track_mut(child).unwrap().hidden = false;
|
||||
s.track_mut(inner).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
s.track_mut(inner).unwrap().folder_open = true;
|
||||
s.track_mut(outer).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
}
|
||||
|
||||
/// With no real Clips/Markers boundaries, snapping leaves the position alone while drawing
|
||||
/// retains the session-start line.
|
||||
#[test]
|
||||
fn empty_clips_markers_grid_does_not_snap_everything_to_zero() {
|
||||
let mut s = Session::default();
|
||||
s.edit.grid_clips_markers = true;
|
||||
assert_eq!(s.grid_lines(0, 1_000, 10), [0]);
|
||||
assert_eq!(s.snap_to_grid(678), 678);
|
||||
}
|
||||
|
||||
/// Frame-based grids share the same references as their displayed counters.
|
||||
#[test]
|
||||
fn frame_grids_apply_session_counter_origins() {
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps30, timecode_start: 1_600, ..Session::default() };
|
||||
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
|
||||
assert_eq!(s.grid_line_at_or_before(47_000), 46_400);
|
||||
assert_eq!(s.snap_to_grid(46_400), 46_400);
|
||||
|
||||
s.edit.grid = GridValue::FeetFrames(soundcraft_time::FeetFramesValue::Foot);
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
|
||||
assert_eq!(s.grid_line_at_or_before(25_000), 24_000);
|
||||
assert_eq!(s.snap_to_grid(24_000), 24_000);
|
||||
}
|
||||
|
||||
/// Every Feet+Frames increment shares the parser's single rounding at fractional frame rates.
|
||||
#[test]
|
||||
fn feet_frames_grid_matches_parsed_positions_with_signed_frame_origins() {
|
||||
use soundcraft_time::FeetFramesValue;
|
||||
for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: rate, ..Session::default() };
|
||||
for origin in [-17, -1, 1, 17] {
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), origin as f64);
|
||||
for value in FeetFramesValue::ALL {
|
||||
s.edit.grid = GridValue::FeetFrames(value);
|
||||
for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] {
|
||||
let at = s.parse_position(&format!("{feet}+00"), TimeFormat::FeetFrames).unwrap();
|
||||
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?} {value:?} origin {origin}, foot {feet}");
|
||||
assert_eq!(s.snap_to_grid(at), at);
|
||||
assert_eq!(s.grid_lines(at - 1, at + 2, 4), [at]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps29_97, ..Session::default() };
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
|
||||
s.edit.grid = GridValue::FeetFrames(FeetFramesValue::Foot);
|
||||
assert_eq!(s.snap_to_grid(49_649), 49_650);
|
||||
assert_eq!(s.format_position(49_650, TimeFormat::FeetFrames), "2+00");
|
||||
}
|
||||
|
||||
/// Drop-frame minute boundaries keep their counter alignment with signed session origins.
|
||||
#[test]
|
||||
fn drop_frame_grids_apply_session_origins_at_minute_boundaries() {
|
||||
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1_800), (FrameRate::Fps59_94Drop, 3_600)] {
|
||||
for origin_frame in [-1, 1] {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let origin = rate.samples_at_frame(origin_frame, sr);
|
||||
let mut s = Session { sample_rate: sr, frame_rate: rate, timecode_start: origin, ..Session::default() };
|
||||
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
|
||||
for boundary in [-minute_frame, minute_frame] {
|
||||
let at = rate.samples_at_frame(boundary, sr) - origin;
|
||||
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?}, origin {origin_frame}");
|
||||
assert_eq!(s.snap_to_grid(at + 1), at, "{rate:?}, origin {origin_frame}");
|
||||
assert_eq!(s.grid_lines(at - 1, at + 2, 10), [at]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_with_legacy_fit_height_loads() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+499
-20
@@ -1,11 +1,12 @@
|
||||
//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping.
|
||||
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, to_samples};
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples};
|
||||
|
||||
/// Note values for Bars|Beats grids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum NoteValue {
|
||||
Bar,
|
||||
Whole,
|
||||
Half,
|
||||
Quarter,
|
||||
Eighth,
|
||||
@@ -15,8 +16,10 @@ pub enum NoteValue {
|
||||
}
|
||||
|
||||
impl NoteValue {
|
||||
pub const ALL: [NoteValue; 7] = [
|
||||
/// Every musical increment in menu order, from largest to smallest.
|
||||
pub const ALL: [NoteValue; 8] = [
|
||||
NoteValue::Bar,
|
||||
NoteValue::Whole,
|
||||
NoteValue::Half,
|
||||
NoteValue::Quarter,
|
||||
NoteValue::Eighth,
|
||||
@@ -24,9 +27,11 @@ impl NoteValue {
|
||||
NoteValue::ThirtySecond,
|
||||
NoteValue::SixtyFourth,
|
||||
];
|
||||
/// Returns the nominal tick length; bars are adjusted for the active meter when used.
|
||||
pub fn ticks(self) -> i64 {
|
||||
match self {
|
||||
NoteValue::Bar => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Whole => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Half => TICKS_PER_QUARTER * 2,
|
||||
NoteValue::Quarter => TICKS_PER_QUARTER,
|
||||
NoteValue::Eighth => TICKS_PER_QUARTER / 2,
|
||||
@@ -35,9 +40,11 @@ impl NoteValue {
|
||||
NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16,
|
||||
}
|
||||
}
|
||||
/// Returns the menu label for this musical increment.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "Whole note",
|
||||
NoteValue::Half => "1/2 note",
|
||||
NoteValue::Quarter => "1/4 note",
|
||||
NoteValue::Eighth => "1/8 note",
|
||||
@@ -48,26 +55,257 @@ impl NoteValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time scale used by a Grid or Nudge value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridScale {
|
||||
BarsBeats,
|
||||
MinSecs,
|
||||
Timecode,
|
||||
FeetFrames,
|
||||
Samples,
|
||||
ClipsMarkers,
|
||||
}
|
||||
|
||||
impl GridScale {
|
||||
/// Numeric time scales shared by Grid and Nudge.
|
||||
pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples];
|
||||
|
||||
/// Returns the user-facing scale label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "Bars|Beats",
|
||||
GridScale::MinSecs => "Min:Secs",
|
||||
GridScale::Timecode => "Timecode",
|
||||
GridScale::FeetFrames => "Feet+Frames",
|
||||
GridScale::Samples => "Samples",
|
||||
GridScale::ClipsMarkers => "Clips/Markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "bars_beats",
|
||||
GridScale::MinSecs => "min_secs",
|
||||
GridScale::Timecode => "timecode",
|
||||
GridScale::FeetFrames => "feet_frames",
|
||||
GridScale::Samples => "samples",
|
||||
GridScale::ClipsMarkers => "clips_markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<GridScale> {
|
||||
Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Timecode Grid/Nudge increments, ordered independently of the session frame rate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TimecodeValue {
|
||||
Second,
|
||||
/// Five physical frames, independent of skipped drop-frame labels.
|
||||
FiveFrames,
|
||||
Frame,
|
||||
HalfFrame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl TimecodeValue {
|
||||
/// Every Timecode increment in menu order, from largest to smallest.
|
||||
pub const ALL: [TimecodeValue; 6] = [
|
||||
TimecodeValue::Second,
|
||||
TimecodeValue::FiveFrames,
|
||||
TimecodeValue::Frame,
|
||||
TimecodeValue::HalfFrame,
|
||||
TimecodeValue::QuarterFrame,
|
||||
TimecodeValue::SubFrame,
|
||||
];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "1 second",
|
||||
TimecodeValue::FiveFrames => "5 frames",
|
||||
TimecodeValue::Frame => "1 frame",
|
||||
TimecodeValue::HalfFrame => "1/2 frame",
|
||||
TimecodeValue::QuarterFrame => "1/4 frame",
|
||||
TimecodeValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "second",
|
||||
TimecodeValue::FiveFrames => "5_frames",
|
||||
TimecodeValue::Frame => "frame",
|
||||
TimecodeValue::HalfFrame => "half_frame",
|
||||
TimecodeValue::QuarterFrame => "quarter_frame",
|
||||
TimecodeValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<TimecodeValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames at the session rate.
|
||||
fn frames(self, rate: FrameRate) -> f64 {
|
||||
match self {
|
||||
TimecodeValue::Second => rate.nominal() as f64,
|
||||
TimecodeValue::FiveFrames => 5.0,
|
||||
TimecodeValue::Frame => 1.0,
|
||||
TimecodeValue::HalfFrame => 0.5,
|
||||
TimecodeValue::QuarterFrame => 0.25,
|
||||
TimecodeValue::SubFrame => 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FeetFramesValue {
|
||||
Foot,
|
||||
Frame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl FeetFramesValue {
|
||||
/// Every Feet+Frames increment in menu order, from largest to smallest.
|
||||
pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "1 foot",
|
||||
FeetFramesValue::Frame => "1 frame",
|
||||
FeetFramesValue::QuarterFrame => "1/4 frame",
|
||||
FeetFramesValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "foot",
|
||||
FeetFramesValue::Frame => "frame",
|
||||
FeetFramesValue::QuarterFrame => "quarter_frame",
|
||||
FeetFramesValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<FeetFramesValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames.
|
||||
fn frames(self) -> f64 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
num as f64 / den as f64
|
||||
}
|
||||
|
||||
/// Exact numerator and denominator of the increment in physical frames.
|
||||
fn frame_ratio(self) -> (i128, i128) {
|
||||
match self {
|
||||
Self::Foot => (16, 1),
|
||||
Self::Frame => (1, 1),
|
||||
Self::QuarterFrame => (1, 4),
|
||||
Self::SubFrame => (1, 100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the counter origin in frames before rounding once to the nearest sample.
|
||||
fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
let (num, den) = self.frame_ratio();
|
||||
let (rate_num, rate_den) = rate.rational();
|
||||
let frames = index * num - i128::from(origin) * den;
|
||||
let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den);
|
||||
let divisor = den * i128::from(rate_num);
|
||||
let magnitude = (samples.abs() + divisor / 2) / divisor;
|
||||
let rounded = if samples < 0 { -magnitude } else { magnitude };
|
||||
i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
/// Find a line index using exact frame arithmetic, allowing for whole-sample rounding.
|
||||
fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
let (rate_num, rate_den) = rate.rational();
|
||||
let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den);
|
||||
let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale;
|
||||
let mut index = (frames * den).div_euclid(scale * num);
|
||||
if self.sample_line(index + 1, sr, rate, origin) <= position {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// The Feet+Frames line at or before a sample, relative to the counter's frame origin.
|
||||
pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin)
|
||||
}
|
||||
|
||||
/// The next distinct sample line; subframe increments may share a sample at low sample rates.
|
||||
fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Snap against frame-origin lines, choosing the earlier line when distances are equal.
|
||||
pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
let lo = self.line_at_or_before(position, sr, rate, origin);
|
||||
let hi = self.next_line(lo, sr, rate, origin);
|
||||
if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi }
|
||||
}
|
||||
|
||||
/// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`.
|
||||
pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec<Samples> {
|
||||
let mut out = Vec::new();
|
||||
let mut line = self.line_at_or_before(start, sr, rate, origin);
|
||||
while line < end && out.len() < max {
|
||||
if line >= start {
|
||||
out.push(line);
|
||||
}
|
||||
let next = self.next_line(line, sr, rate, origin);
|
||||
if next <= line {
|
||||
break;
|
||||
}
|
||||
line = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid or nudge step.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridValue {
|
||||
/// Musical: note value, optionally dotted or triplet.
|
||||
Note { value: NoteValue, dotted: bool, triplet: bool },
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 10 s).
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 1 s).
|
||||
Seconds(f64),
|
||||
/// Frames at the session timecode rate.
|
||||
/// Kept for sessions and commands written before named timecode increments were added.
|
||||
Frames(i64),
|
||||
/// A named Timecode increment, including fractional frames and one second.
|
||||
Timecode(TimecodeValue),
|
||||
/// A Feet+Frames increment (one foot is 16 frames).
|
||||
FeetFrames(FeetFramesValue),
|
||||
/// Raw samples.
|
||||
Samples(i64),
|
||||
}
|
||||
|
||||
impl Default for GridValue {
|
||||
/// Starts on a straight sixteenth-note Bars|Beats grid.
|
||||
fn default() -> Self {
|
||||
GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl GridValue {
|
||||
/// Returns the user-facing label for this increment.
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
@@ -80,15 +318,47 @@ impl GridValue {
|
||||
}
|
||||
s
|
||||
}
|
||||
GridValue::Seconds(s) => format!("{s:.3} sec"),
|
||||
GridValue::Seconds(s) => {
|
||||
if *s >= 1.0 {
|
||||
format!("{s} second{}", if *s == 1.0 { "" } else { "s" })
|
||||
} else {
|
||||
format!("{} ms", (s * 1000.0).round() as i64)
|
||||
}
|
||||
}
|
||||
GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }),
|
||||
GridValue::Samples(n) => format!("{n} samples"),
|
||||
GridValue::Timecode(value) => value.label().to_string(),
|
||||
GridValue::FeetFrames(value) => value.label().to_string(),
|
||||
GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their
|
||||
/// dot or triplet; seconds, frames and samples step by tens.
|
||||
/// Returns the time scale to which this increment belongs.
|
||||
pub fn scale(self) -> GridScale {
|
||||
match self {
|
||||
GridValue::Note { .. } => GridScale::BarsBeats,
|
||||
GridValue::Seconds(_) => GridScale::MinSecs,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode,
|
||||
GridValue::FeetFrames(_) => GridScale::FeetFrames,
|
||||
GridValue::Samples(_) => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default increment for a newly selected time scale.
|
||||
pub fn for_scale(scale: GridScale) -> GridValue {
|
||||
match scale {
|
||||
GridScale::BarsBeats => GridValue::default(),
|
||||
GridScale::MinSecs => GridValue::Seconds(1.0),
|
||||
GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame),
|
||||
GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame),
|
||||
GridScale::Samples => GridValue::Samples(100),
|
||||
GridScale::ClipsMarkers => GridValue::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep
|
||||
/// their dot or triplet modifier.
|
||||
pub fn stepped(self, larger: bool) -> GridValue {
|
||||
/// Finds the next value in a numeric increment ladder without wrapping.
|
||||
fn next<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
|
||||
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
|
||||
}
|
||||
@@ -98,9 +368,11 @@ impl GridValue {
|
||||
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
|
||||
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
|
||||
}
|
||||
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds),
|
||||
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds),
|
||||
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
|
||||
GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples),
|
||||
GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode),
|
||||
GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames),
|
||||
GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +398,17 @@ impl GridValue {
|
||||
}
|
||||
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
||||
GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1),
|
||||
GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1),
|
||||
GridValue::Samples(n) => n.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest grid line to `pos`.
|
||||
pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.snap(pos, sr, rate, 0);
|
||||
}
|
||||
let lo = self.line_at_or_before(pos, sr, map, rate);
|
||||
let hi = self.next_line(lo, sr, map, rate);
|
||||
if (pos - lo) <= (hi - pos) { lo } else { hi }
|
||||
@@ -140,6 +417,7 @@ impl GridValue {
|
||||
/// The grid line at or before `pos`.
|
||||
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0),
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
// Float ticks can land a hair either side of a line's own tick.
|
||||
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
|
||||
@@ -148,12 +426,14 @@ impl GridValue {
|
||||
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
|
||||
t = t.saturating_add(1);
|
||||
}
|
||||
// Grid lines restart at each bar line so odd meters stay aligned.
|
||||
// Sub-bar values restart at each bar so odd meters stay aligned. A whole note is
|
||||
// always four quarter notes and therefore keeps a continuous tick origin.
|
||||
let bb = map.bar_beat_at_tick(t);
|
||||
let bar_tick = map.bar_start_tick(bb.bar);
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, bar_tick);
|
||||
let k = (t - bar_tick).div_euclid(step);
|
||||
map.tick_to_samples(bar_tick + k * step, sr)
|
||||
let origin = if value == NoteValue::Whole { 0 } else { bar_tick };
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, origin);
|
||||
let k = (t - origin).div_euclid(step);
|
||||
map.tick_to_samples(origin + k * step, sr)
|
||||
}
|
||||
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
|
||||
}
|
||||
@@ -162,21 +442,60 @@ impl GridValue {
|
||||
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
|
||||
/// rounded once, so rounding never accumulates from line to line.
|
||||
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.sample_line(i128::from(k), sr, rate, 0);
|
||||
}
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr);
|
||||
}
|
||||
if let Some(frames) = self.frame_step(rate) {
|
||||
let whole = frames.round();
|
||||
if frames == whole {
|
||||
return rate.samples_at_frame(k.saturating_mul(whole as i64), sr);
|
||||
}
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
return to_samples(k as f64 * step);
|
||||
}
|
||||
match *self {
|
||||
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
|
||||
GridValue::Frames(f) => rate.samples_at_frame(k.saturating_mul(f.clamp(1, 1_000_000)), sr),
|
||||
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
|
||||
GridValue::Note { .. } => 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the absolute grid line at or before `pos`.
|
||||
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
|
||||
let mut k = match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Frames(f) => rate.frames_at(pos, sr).div_euclid(f.clamp(1, 1_000_000)),
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate);
|
||||
let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds);
|
||||
let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude };
|
||||
while self.abs_line(k, sr, rate) > pos {
|
||||
let previous = k.saturating_sub(1);
|
||||
if previous == k {
|
||||
break;
|
||||
}
|
||||
k = previous;
|
||||
}
|
||||
while self.abs_line(k.saturating_add(1), sr, rate) <= pos {
|
||||
let next = k.saturating_add(1);
|
||||
if next == k {
|
||||
break;
|
||||
}
|
||||
k = next;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
let mut k = if let Some(frames) = self.frame_step(rate) {
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
(pos as f64 / step).floor() as i64
|
||||
} else {
|
||||
match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0,
|
||||
}
|
||||
};
|
||||
// Lines are rounded to whole samples, so the floored index can be one off either way.
|
||||
if self.abs_line(k, sr, rate) > pos {
|
||||
@@ -192,8 +511,36 @@ impl GridValue {
|
||||
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
|
||||
}
|
||||
|
||||
/// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate.
|
||||
fn drop_second_frame(index: i64, rate: FrameRate) -> i64 {
|
||||
let seconds = index.unsigned_abs();
|
||||
let total_minutes = seconds / 60;
|
||||
let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 };
|
||||
Timecode {
|
||||
negative: index < 0,
|
||||
hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX),
|
||||
minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0),
|
||||
seconds: i64::try_from(seconds % 60).unwrap_or(0),
|
||||
frames,
|
||||
}
|
||||
.to_frames(rate)
|
||||
}
|
||||
|
||||
/// A frame-based increment as a possibly fractional number of frames.
|
||||
fn frame_step(&self, rate: FrameRate) -> Option<f64> {
|
||||
match *self {
|
||||
GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64),
|
||||
GridValue::Timecode(value) => Some(value.frames(rate)),
|
||||
GridValue::FeetFrames(value) => Some(value.frames()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first grid line strictly after `line`.
|
||||
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.next_line(line, sr, rate, 0);
|
||||
}
|
||||
if !matches!(self, GridValue::Note { .. }) {
|
||||
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
|
||||
return if next > line { next } else { line.saturating_add(1) };
|
||||
@@ -205,6 +552,9 @@ impl GridValue {
|
||||
|
||||
/// All grid lines within `[start, end)`, capped at `max` lines.
|
||||
pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec<Samples> {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.lines(start, end, sr, rate, 0, max);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut p = self.line_at_or_before(start, sr, map, rate);
|
||||
while p < end && out.len() < max {
|
||||
@@ -221,10 +571,43 @@ impl GridValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves one position through an ordered increment menu without wrapping.
|
||||
fn step_enum<T: Copy + PartialEq>(values: &[T], value: T, larger: bool) -> Option<T> {
|
||||
let index = values.iter().position(|candidate| *candidate == value)?;
|
||||
let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?;
|
||||
values.get(next).copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Frame origins are subtracted before rounding whole frames, quarter frames and subframes.
|
||||
#[test]
|
||||
fn feet_frames_origins_round_only_after_applying_the_frame_offset() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97);
|
||||
for at in [49_650, 100_901, 177_778] {
|
||||
assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at);
|
||||
assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at);
|
||||
}
|
||||
assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050);
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]);
|
||||
}
|
||||
|
||||
/// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins.
|
||||
#[test]
|
||||
fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() {
|
||||
let sr = SampleRate::new(8_000).unwrap();
|
||||
let rate = FrameRate::Fps120;
|
||||
for origin in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]);
|
||||
for at in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at);
|
||||
assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_lines_are_their_own_line_at_odd_tempos() {
|
||||
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
|
||||
@@ -238,6 +621,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Musical boundaries stay exact across a tempo change while absolute scales remain
|
||||
/// independent of the tempo map.
|
||||
#[test]
|
||||
fn grids_stay_aligned_across_a_tempo_change() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop);
|
||||
let mut map = TempoMap::default();
|
||||
map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
|
||||
let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false };
|
||||
for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] {
|
||||
let line = map.tick_to_samples(tick, sr);
|
||||
assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line);
|
||||
}
|
||||
for absolute in [
|
||||
GridValue::Seconds(0.5),
|
||||
GridValue::Timecode(TimecodeValue::Second),
|
||||
GridValue::FeetFrames(FeetFramesValue::Foot),
|
||||
GridValue::Samples(1_000),
|
||||
] {
|
||||
let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate);
|
||||
assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteenth_grid_at_120() {
|
||||
let g = GridValue::default();
|
||||
@@ -248,14 +654,33 @@ mod tests {
|
||||
assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]);
|
||||
}
|
||||
|
||||
/// Absolute increments report their expected sample lengths at a fixed rate.
|
||||
#[test]
|
||||
fn absolute_grids() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19);
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720);
|
||||
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
|
||||
}
|
||||
|
||||
/// Bar length follows the meter while a whole note always spans four quarter notes.
|
||||
#[test]
|
||||
fn a_bar_and_a_whole_note_differ_in_three_four() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25);
|
||||
let note = |value| GridValue::Note { value, dotted: false, triplet: false };
|
||||
assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_grid_lines_land_on_whole_seconds() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
|
||||
@@ -268,6 +693,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy frame grids stay aligned to exact frame starts at fractional rates.
|
||||
#[test]
|
||||
fn frame_grid_lines_land_on_frame_starts() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
@@ -291,6 +717,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids.
|
||||
#[test]
|
||||
fn named_frame_grids_land_on_exact_subdivisions() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames));
|
||||
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
|
||||
for grid in grids.clone() {
|
||||
for index in 0..200 {
|
||||
let line = grid.abs_line(index, sr, rate);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}");
|
||||
assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-frame second grids follow labelled second boundaries in both timeline directions.
|
||||
#[test]
|
||||
fn drop_frame_second_grids_follow_timecode_labels() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grid = GridValue::Timecode(TimecodeValue::Second);
|
||||
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
for seconds in [0, 59, 60, 61, 600, 601, 3600] {
|
||||
let frame = GridValue::drop_second_frame(seconds, rate);
|
||||
let line = rate.samples_at_frame(frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}");
|
||||
assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}");
|
||||
for offset in -2..=rate.nominal() + 2 {
|
||||
let position = rate.samples_at_frame(frame.saturating_add(offset), sr);
|
||||
let before = grid.line_at_or_before(position, sr, &map, rate);
|
||||
assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
}
|
||||
}
|
||||
let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr);
|
||||
assert_eq!(grid.snap(labelled, sr, &map, rate), labelled);
|
||||
}
|
||||
|
||||
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] {
|
||||
let boundary = rate.samples_at_frame(minute_frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary");
|
||||
assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap");
|
||||
assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary");
|
||||
assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_capped() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
@@ -306,6 +781,7 @@ mod tests {
|
||||
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
|
||||
}
|
||||
|
||||
/// Stepping follows each menu ladder and stops at its endpoints.
|
||||
#[test]
|
||||
fn values_step_to_the_next_larger_or_smaller() {
|
||||
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
|
||||
@@ -315,9 +791,12 @@ mod tests {
|
||||
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
|
||||
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0));
|
||||
assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0));
|
||||
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame));
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot));
|
||||
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
|
||||
assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2));
|
||||
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ mod tempo;
|
||||
mod timecode;
|
||||
|
||||
pub use format::{TimeFormat, format_length, format_position, parse_position};
|
||||
pub use grid::{GridValue, NoteValue};
|
||||
pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue};
|
||||
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
|
||||
pub use timecode::{FrameRate, Timecode};
|
||||
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ soundcraft-video.workspace = true
|
||||
# 0.37 makes the bundled faces opt-in; the app's own font loader starts from
|
||||
# `FontDefinitions::default()`, and kittest never supplies fonts to this root.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
web-time.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
+322
-53
@@ -262,71 +262,213 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
contributors_grab_bag(ui, &list, v.names);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ⏶" } else { " ⏷" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// About › Models: AI models credited in Co-Authored-By trailers.
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(k.label().1).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
pub fn models_ui(ui: &mut egui::Ui) {
|
||||
if MODELS.is_empty() {
|
||||
ui.label("No model credits were built into this copy.");
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,4 +548,131 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,20 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
|
||||
}
|
||||
app.edit_layout.content_h = content_h;
|
||||
let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y));
|
||||
let grid = clips::GridLines::new(app.engine.session(), tl);
|
||||
let grid_revision = app.engine.revision;
|
||||
for (id, r) in app.edit_layout.rows.clone() {
|
||||
let row = rect(r);
|
||||
if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row));
|
||||
child.set_clip_rect(clip_rect);
|
||||
track_row(app, &mut child, TrackId(id), row, tl, &cols);
|
||||
track_row(app, &mut child, TrackId(id), row, tl, &cols, &grid);
|
||||
}
|
||||
}
|
||||
// Keep one consistent grid snapshot during this pass, then refresh after a row edits it.
|
||||
if app.engine.revision != grid_revision {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Below the tracks a click clears the selection and a double-click opens New Tracks.
|
||||
let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max);
|
||||
if empty.height() > 4.0 {
|
||||
|
||||
@@ -25,26 +25,41 @@ pub(super) fn draw_strip_silence(painter: &egui::Painter, s: &Session, tl: Rect,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) {
|
||||
let t = Tokens::current();
|
||||
let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x));
|
||||
let sr = s.sample_rate;
|
||||
// Bar lines always; grid lines when enabled and dense enough.
|
||||
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
|
||||
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if bar_px > 6.0 {
|
||||
let col = grid_veil(t.bar_line, t.playlist_bg);
|
||||
for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) {
|
||||
let x = x_of(s, tl, at);
|
||||
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
|
||||
/// Grid positions computed once for the current viewport and shared by all visible lanes.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GridLines {
|
||||
bars: Vec<f32>,
|
||||
grid: Vec<f32>,
|
||||
}
|
||||
|
||||
impl GridLines {
|
||||
/// Collect and project visible lines before drawing any lanes in this frame.
|
||||
pub(super) fn new(s: &Session, tl: Rect) -> Self {
|
||||
let mut lines = Self::default();
|
||||
let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x));
|
||||
let sr = s.sample_rate;
|
||||
// Bar lines always; grid lines when enabled and dense enough.
|
||||
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
|
||||
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if bar_px > 6.0 {
|
||||
lines.bars = bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600).into_iter().map(|at| x_of(s, tl, at)).collect();
|
||||
}
|
||||
if s.edit.grid_lines {
|
||||
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if s.edit.grid_clips_markers || step_px > 7.0 {
|
||||
let visible_columns = tl.width().ceil().clamp(1.0, 16_384.0) as usize;
|
||||
lines.grid = s.grid_lines(view.start, view.end, visible_columns).into_iter().map(|at| x_of(s, tl, at)).collect();
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
if s.edit.grid_lines {
|
||||
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if step_px > 7.0 {
|
||||
let col = grid_veil(t.grid_line, t.playlist_bg);
|
||||
for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) {
|
||||
let x = x_of(s, tl, at);
|
||||
|
||||
/// Draw the prepared lines on one lane, preserving bar and grid colors and stacking order.
|
||||
pub(super) fn draw(&self, painter: &egui::Painter, lane: Rect) {
|
||||
let t = Tokens::current();
|
||||
for (positions, color) in [(&self.bars, t.bar_line), (&self.grid, t.grid_line)] {
|
||||
let col = grid_veil(color, t.playlist_bg);
|
||||
for &x in positions {
|
||||
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at);
|
||||
*delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command));
|
||||
}
|
||||
Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)),
|
||||
Some(Gesture::TrimStart { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, true, ctx.input(|i| i.modifiers.command)),
|
||||
Some(Gesture::TrimEnd { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, false, ctx.input(|i| i.modifiers.command)),
|
||||
Some(Gesture::MidiClip { to, .. }) => *to = at,
|
||||
Some(Gesture::Fade { clip, fade_in, to, .. }) => {
|
||||
if let Some(range) = s.find_clip(*clip).and_then(|(_, c)| fade_drag_range(c, *fade_in, at)) {
|
||||
@@ -206,7 +207,18 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option<Pos2>) {
|
||||
|
||||
/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip.
|
||||
pub(super) fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples {
|
||||
if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
|
||||
drag_snap_excluding(s, at, flip, &[])
|
||||
}
|
||||
|
||||
/// Grid snapping that ignores boundaries belonging to clips changed by the gesture.
|
||||
pub(super) fn drag_snap_excluding(s: &Session, at: Samples, flip: bool, excluded: &[ClipId]) -> Samples {
|
||||
if s.edit.edit_mode.is_grid() != flip { s.snap_to_grid_excluding(at, excluded) } else { at }
|
||||
}
|
||||
|
||||
/// Snap a trim while preserving the clip edge when the pointer returns to its origin.
|
||||
pub(super) fn trim_drag_snap(s: &Session, clip: ClipId, at: Samples, start: bool, flip: bool) -> Samples {
|
||||
let origin = s.find_clip(clip).map(|(_, clip)| if start { clip.start } else { clip.end() });
|
||||
if origin == Some(at) { at } else { drag_snap_excluding(s, at, flip, &[clip]) }
|
||||
}
|
||||
|
||||
/// What a clip drag works out once per target track rather than every frame: where the tracks go
|
||||
@@ -216,6 +228,7 @@ pub(super) struct DragPlan {
|
||||
pub(super) dest: Option<Vec<(TrackId, TrackId)>>,
|
||||
pub(super) groups: Vec<(TrackId, Samples, Vec<Samples>)>,
|
||||
pub(super) moving: Vec<(TrackId, Samples, Samples)>,
|
||||
moving_ids: Vec<ClipId>,
|
||||
grabbed: Option<Clip>,
|
||||
}
|
||||
|
||||
@@ -246,10 +259,11 @@ pub(super) fn drag_plan(
|
||||
};
|
||||
let ids: HashSet<ClipId> = clips.iter().copied().collect();
|
||||
let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c);
|
||||
let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
|
||||
let moving: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
|
||||
let moving_ids: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| c.id)).collect();
|
||||
let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default();
|
||||
let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone());
|
||||
let plan = Arc::new(DragPlan { dest, groups, moving, grabbed });
|
||||
let plan = Arc::new(DragPlan { dest, groups, moving, moving_ids, grabbed });
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan))));
|
||||
plan
|
||||
}
|
||||
@@ -258,6 +272,10 @@ pub(super) fn drag_plan(
|
||||
/// Shuffle lands the earliest clip bound for the target track on a slot there, as
|
||||
/// `edit.move_clips` does; Grid snaps the grabbed clip.
|
||||
fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples {
|
||||
let track_changed = plan.dest.as_ref().is_some_and(|dest| ops::lands(dest, from) != from);
|
||||
if raw == 0 && !track_changed {
|
||||
return 0;
|
||||
}
|
||||
let mode = s.edit.edit_mode;
|
||||
if mode == EditMode::Shuffle {
|
||||
let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from));
|
||||
@@ -268,7 +286,7 @@ fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: b
|
||||
} else if mode.is_grid() != flip
|
||||
&& let Some(c) = &plan.grabbed
|
||||
{
|
||||
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative)
|
||||
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative, &plan.moving_ids)
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -9,9 +9,10 @@ use super::{
|
||||
|
||||
// ---- interaction -----------------------------------------------------------------------------
|
||||
|
||||
/// Apply snapping when the current edit mode or Snap to Grid option enables it.
|
||||
pub(super) fn snap(s: &Session, at: Samples) -> Samples {
|
||||
let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID);
|
||||
if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
|
||||
if on { s.snap_to_grid(at) } else { at }
|
||||
}
|
||||
|
||||
pub(super) fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,25 @@ use super::*;
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
/// A trim cannot snap to the clip's own old boundary in Clips/Markers mode.
|
||||
#[test]
|
||||
fn clips_markers_trim_ignores_the_trimmed_clip() {
|
||||
let mut s = Session::default();
|
||||
let track = s.add_track(TrackKind::Midi, soundcraft_model::ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 1_000, soundcraft_midi::Sequence::default());
|
||||
let clip_id = clip.id;
|
||||
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
|
||||
s.add_marker("Inside", soundcraft_model::MarkerKind::Marker, 1_500, 1_500);
|
||||
s.add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
|
||||
s.edit.edit_mode = soundcraft_model::EditMode::Grid;
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(super::drag::drag_snap(&s, 2_100, false), 2_000);
|
||||
assert_eq!(super::drag::drag_snap_excluding(&s, 2_100, false, &[clip_id]), 2_500);
|
||||
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 1_000, true, false), 1_000);
|
||||
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 2_000, false, false), 2_000);
|
||||
}
|
||||
|
||||
/// The painted header selectors are interactive controls; their accessible name is the field
|
||||
/// they edit, not the short value text ("Read", "Waveform") painted inside them.
|
||||
#[test]
|
||||
@@ -1508,3 +1527,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})");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::automation::draw_automation;
|
||||
use super::clips::{ClipLane, draw_clip, draw_live_take, draw_strip_silence, grid_lines};
|
||||
use super::clips::{ClipLane, GridLines, draw_clip, draw_live_take, draw_strip_silence};
|
||||
use super::header::{header_column, track_header};
|
||||
use super::input::lane_interaction;
|
||||
use super::layout::{Column, HEADER_W, LANE_H, STRIP_W, lane_rect, sample_at, x_of};
|
||||
@@ -58,7 +58,8 @@ pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers
|
||||
|
||||
// ---- track rows ------------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) {
|
||||
/// Draw one track with grid positions shared by all lanes in the current frame.
|
||||
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column], grid: &GridLines) {
|
||||
let t = Tokens::current();
|
||||
let s = app.engine.session_arc();
|
||||
let Some(track) = s.track(id) else { return };
|
||||
@@ -84,7 +85,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
|
||||
let s = app.engine.session_arc();
|
||||
// Shown grid lines go over the clips; otherwise the bar lines stay behind them.
|
||||
if !s.edit.grid_lines {
|
||||
grid_lines(&painter, &s, tl, lane);
|
||||
grid.draw(&painter, lane);
|
||||
}
|
||||
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
|
||||
let auto_view = AutoParam::parse(&track.view);
|
||||
@@ -98,7 +99,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
|
||||
draw_live_take(app, &painter, &s, track, tl, lane);
|
||||
crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);
|
||||
if s.edit.grid_lines {
|
||||
grid_lines(&painter, &s, tl, lane);
|
||||
grid.draw(&painter, lane);
|
||||
}
|
||||
if let Some(p) = &auto_view {
|
||||
draw_automation(&painter, &s, track, p, tl, lane);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(super) fn about(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(820.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in (0u8..).zip(["About", "Contributors", "Models"]) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+370
-35
@@ -3,11 +3,11 @@
|
||||
use crate::theme::{Tokens, bold, mono, regular};
|
||||
use crate::widgets::{icon_button, key, key_fill};
|
||||
use crate::{SoundApp, icons};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use egui::{Align2, Color32, CornerRadius, Painter, 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_time::{TimeFormat, format_length};
|
||||
use soundcraft_model::{EditMode, EditState, Session, Tool};
|
||||
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, NoteValue, TimeFormat, TimecodeValue, 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)
|
||||
@@ -276,12 +281,12 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// Grid-line toggle, grid value menu and nudge value menu.
|
||||
/// Draws the compact Grid and Nudge readouts and their scale menus.
|
||||
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
let (grid, nudge, lines) = {
|
||||
let (grid, nudge, lines, grid_follow, nudge_follow, clips_markers) = {
|
||||
let e = &app.engine.session().edit;
|
||||
(e.grid, e.nudge, e.grid_lines)
|
||||
(e.grid, e.nudge, e.grid_lines, e.grid_follow_main, e.nudge_follow_main, e.grid_clips_markers)
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover());
|
||||
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
|
||||
@@ -293,39 +298,243 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let _ = app.run("view.grid_lines", json!({}));
|
||||
}
|
||||
let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
|
||||
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text);
|
||||
let grid_label = if clips_markers { GridScale::ClipsMarkers.label().to_string() } else { compact_grid_label(app.engine.session(), grid) };
|
||||
if !clips_markers {
|
||||
draw_compact_note_icon(ui.painter(), gr, grid, t.counter_text);
|
||||
}
|
||||
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid_label, mono(11.0), t.counter_text);
|
||||
let gresp = ui.interact(gr, ui.scope_id().with("grid_value"), Sense::click());
|
||||
value_menu(app, &gresp, "view.grid");
|
||||
value_menu(app, &gresp, "view.grid", grid, grid_follow, clips_markers, true);
|
||||
ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label);
|
||||
let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0));
|
||||
ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text);
|
||||
draw_compact_note_icon(ui.painter(), nr, nudge, t.counter_text);
|
||||
ui.painter().text(
|
||||
pos2(nr.max.x - 14.0, nr.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
compact_grid_label(app.engine.session(), nudge),
|
||||
mono(11.0),
|
||||
t.counter_text,
|
||||
);
|
||||
let nresp = ui.interact(nr, ui.scope_id().with("nudge_value"), Sense::click());
|
||||
value_menu(app, &nresp, "view.nudge");
|
||||
value_menu(app, &nresp, "view.nudge", nudge, nudge_follow, false, false);
|
||||
for rr in [gr, nr] {
|
||||
icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
|
||||
}
|
||||
}
|
||||
|
||||
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) {
|
||||
egui::Popup::menu(resp).show(|ui| {
|
||||
for v in ["1 bar", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/8t", "1/16t", "1/4."] {
|
||||
if ui.button(v).clicked() {
|
||||
let _ = app.run(cmd, json!({"value": v}));
|
||||
/// Formats a Grid/Nudge increment in the compact counter style for its time scale.
|
||||
fn compact_grid_label(session: &Session, value: GridValue) -> String {
|
||||
match value {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let at_tick = session.tempo.samples_to_ticks(session.edit.selection.start, session.sample_rate);
|
||||
let meter = session.tempo.meter_at_tick(at_tick);
|
||||
let mut ticks = if value == NoteValue::Bar { meter.ticks_per_bar() } else { value.ticks() };
|
||||
if dotted {
|
||||
ticks = ticks * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
ticks = ticks * 2 / 3;
|
||||
}
|
||||
let per_bar = meter.ticks_per_bar().max(1);
|
||||
let per_beat = meter.ticks_per_beat().max(1);
|
||||
let bars = ticks / per_bar;
|
||||
let remaining = ticks % per_bar;
|
||||
format!("{bars}|{}|{:03}", remaining / per_beat, remaining % per_beat)
|
||||
}
|
||||
GridValue::Seconds(seconds) => {
|
||||
let samples = session.sample_rate.samples(seconds);
|
||||
format_length(session.edit.selection.start, samples, TimeFormat::MinSecs, session.sample_rate, &session.tempo, session.frame_rate)
|
||||
}
|
||||
GridValue::Frames(frames) => {
|
||||
let samples = session.frame_rate.samples_at_frame(frames, session.sample_rate);
|
||||
format!(
|
||||
"{}.00",
|
||||
format_length(session.edit.selection.start, samples, TimeFormat::Timecode, session.sample_rate, &session.tempo, session.frame_rate)
|
||||
)
|
||||
}
|
||||
GridValue::Timecode(value) => match value {
|
||||
TimecodeValue::Second => "00:00:01:00.00",
|
||||
TimecodeValue::FiveFrames => "00:00:00:05.00",
|
||||
TimecodeValue::Frame => "00:00:00:01.00",
|
||||
TimecodeValue::HalfFrame => "00:00:00:00.50",
|
||||
TimecodeValue::QuarterFrame => "00:00:00:00.25",
|
||||
TimecodeValue::SubFrame => "00:00:00:00.01",
|
||||
}
|
||||
.to_string(),
|
||||
GridValue::FeetFrames(value) => match value {
|
||||
FeetFramesValue::Foot => "1+00",
|
||||
FeetFramesValue::Frame => "0+01",
|
||||
FeetFramesValue::QuarterFrame => "0+00.25",
|
||||
FeetFramesValue::SubFrame => "0+00.01",
|
||||
}
|
||||
.to_string(),
|
||||
GridValue::Samples(samples) => samples.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the musical symbol beside a compact Bars|Beats value.
|
||||
fn draw_compact_note_icon(painter: &Painter, rect: Rect, value: GridValue, color: Color32) {
|
||||
let GridValue::Note { value, dotted, triplet } = value else { return };
|
||||
draw_note_value_icon(painter, Rect::from_min_size(pos2(rect.min.x + 1.0, rect.min.y + 2.0), vec2(14.0, 16.0)), value, dotted, triplet, color);
|
||||
}
|
||||
|
||||
/// Draws a musical duration and its optional dotted or triplet modifier.
|
||||
fn draw_note_value_icon(painter: &Painter, rect: Rect, value: NoteValue, dotted: bool, triplet: bool, color: Color32) {
|
||||
let stroke = Stroke::new(1.2, color);
|
||||
let head = pos2(rect.min.x + 4.0, rect.max.y - 3.5);
|
||||
if value == NoteValue::Bar {
|
||||
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 2.0), pos2(rect.min.x + 2.0, rect.max.y - 2.0)], stroke);
|
||||
painter.line_segment([pos2(rect.min.x + 5.0, rect.min.y + 2.0), pos2(rect.min.x + 5.0, rect.max.y - 2.0)], stroke);
|
||||
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 3.0), pos2(rect.min.x + 10.0, rect.min.y + 3.0)], stroke);
|
||||
} else {
|
||||
let hollow = matches!(value, NoteValue::Whole | NoteValue::Half);
|
||||
if hollow {
|
||||
painter.circle_stroke(head, 2.2, stroke);
|
||||
} else {
|
||||
painter.circle_filled(head, 2.2, color);
|
||||
}
|
||||
if value != NoteValue::Whole {
|
||||
let stem_x = head.x + 2.0;
|
||||
painter.line_segment([pos2(stem_x, head.y), pos2(stem_x, rect.min.y + 2.0)], stroke);
|
||||
let flags = match value {
|
||||
NoteValue::Eighth => 1,
|
||||
NoteValue::Sixteenth => 2,
|
||||
NoteValue::ThirtySecond => 3,
|
||||
NoteValue::SixtyFourth => 4,
|
||||
_ => 0,
|
||||
};
|
||||
for flag in 0..flags {
|
||||
let y = rect.min.y + 2.0 + flag as f32 * 2.0;
|
||||
painter.line_segment([pos2(stem_x, y), pos2(stem_x + 4.0, y + 2.5)], stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
if dotted {
|
||||
painter.circle_filled(pos2(rect.max.x - 1.5, head.y), 1.0, color);
|
||||
}
|
||||
if triplet {
|
||||
painter.text(pos2(rect.min.x, rect.min.y), Align2::LEFT_TOP, "3", regular(7.0), color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the complete Grid or Nudge increment, scale, and follow-mode menu.
|
||||
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str, current: GridValue, follow_main: bool, clips_markers: bool, allow_clips: bool) {
|
||||
egui::Popup::menu(resp).show(|ui| {
|
||||
ui.set_min_width(210.0);
|
||||
match current.scale() {
|
||||
GridScale::BarsBeats => {
|
||||
let selected = match current {
|
||||
GridValue::Note { value, .. } => value,
|
||||
_ => NoteValue::Sixteenth,
|
||||
};
|
||||
let suffix = match current {
|
||||
GridValue::Note { dotted: true, .. } => ".",
|
||||
GridValue::Note { triplet: true, .. } => "t",
|
||||
_ => "",
|
||||
};
|
||||
for value in NoteValue::ALL {
|
||||
let id = match value {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "whole",
|
||||
NoteValue::Half => "1/2",
|
||||
NoteValue::Quarter => "1/4",
|
||||
NoteValue::Eighth => "1/8",
|
||||
NoteValue::Sixteenth => "1/16",
|
||||
NoteValue::ThirtySecond => "1/32",
|
||||
NoteValue::SixtyFourth => "1/64",
|
||||
};
|
||||
menu_note_value(ui, app, cmd, selected == value, value, json!(format!("{id}{suffix}")));
|
||||
}
|
||||
ui.separator();
|
||||
let (value, dotted, triplet) = match current {
|
||||
GridValue::Note { value, dotted, triplet } => (value, dotted, triplet),
|
||||
_ => (NoteValue::Sixteenth, false, false),
|
||||
};
|
||||
let base = match value {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "whole",
|
||||
NoteValue::Half => "1/2",
|
||||
NoteValue::Quarter => "1/4",
|
||||
NoteValue::Eighth => "1/8",
|
||||
NoteValue::Sixteenth => "1/16",
|
||||
NoteValue::ThirtySecond => "1/32",
|
||||
NoteValue::SixtyFourth => "1/64",
|
||||
};
|
||||
menu_modifier(ui, app, cmd, dotted, "Dotted", "·", json!(if dotted { base.to_string() } else { format!("{base}.") }));
|
||||
menu_modifier(ui, app, cmd, triplet, "Triplet", "3", json!(if triplet { base.to_string() } else { format!("{base}t") }));
|
||||
}
|
||||
GridScale::MinSecs => {
|
||||
for seconds in [1.0, 0.5, 0.1, 0.01, 0.001] {
|
||||
let value = GridValue::Seconds(seconds);
|
||||
menu_value(ui, app, cmd, current == value, &value.label(), json!({"seconds": seconds}));
|
||||
}
|
||||
}
|
||||
GridScale::Timecode => {
|
||||
for value in TimecodeValue::ALL {
|
||||
let selected = current == GridValue::Timecode(value)
|
||||
|| matches!(
|
||||
(current, value),
|
||||
(GridValue::Frames(5), TimecodeValue::FiveFrames) | (GridValue::Frames(1), TimecodeValue::Frame)
|
||||
);
|
||||
menu_value(ui, app, cmd, selected, value.label(), json!({"timecode": value.id()}));
|
||||
}
|
||||
}
|
||||
GridScale::FeetFrames => {
|
||||
for value in FeetFramesValue::ALL {
|
||||
menu_value(ui, app, cmd, current == GridValue::FeetFrames(value), value.label(), json!({"feet_frames": value.id()}));
|
||||
}
|
||||
}
|
||||
GridScale::Samples => {
|
||||
for samples in [10_000, 1_000, 100, 10, 2, 1] {
|
||||
let value = GridValue::Samples(samples);
|
||||
menu_value(ui, app, cmd, current == value, &value.label(), json!({"samples": samples}));
|
||||
}
|
||||
}
|
||||
GridScale::ClipsMarkers => {}
|
||||
}
|
||||
ui.separator();
|
||||
for (label, v) in [
|
||||
("1 msec", json!({"seconds": 0.001})),
|
||||
("10 msec", json!({"seconds": 0.01})),
|
||||
("100 msec", json!({"seconds": 0.1})),
|
||||
("1 second", json!({"seconds": 1.0})),
|
||||
("1 frame", json!({"frames": 1})),
|
||||
("100 samples", json!({"samples": 100})),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(cmd, json!({"value": v}));
|
||||
for scale in GridScale::TIME {
|
||||
let selected = !clips_markers && current.scale() == scale;
|
||||
if ui.selectable_label(selected, scale.label()).clicked() {
|
||||
let _ = app.run(cmd, json!({"format": scale.id(), "follow_main": false}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if allow_clips && ui.selectable_label(clips_markers, GridScale::ClipsMarkers.label()).clicked() {
|
||||
let _ = app.run(cmd, json!({"format": GridScale::ClipsMarkers.id(), "follow_main": false}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(follow_main, "Follow Main Time Scale").clicked() {
|
||||
let _ = app.run(cmd, json!({"follow_main": !follow_main}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds one text menu value and dispatches its command when selected.
|
||||
fn menu_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, value: serde_json::Value) {
|
||||
if ui.selectable_label(selected, label).clicked() {
|
||||
let _ = app.run(cmd, json!({"value": value}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds one musical value with its duration symbol.
|
||||
fn menu_note_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, note: NoteValue, value: serde_json::Value) {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
draw_note_value_icon(ui.painter(), rect, note, false, false, ui.visuals().text_color());
|
||||
menu_value(ui, app, cmd, selected, note.label(), value);
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a dotted or triplet modifier with its compact symbol.
|
||||
fn menu_modifier(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, symbol: &str, value: serde_json::Value) {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, symbol, regular(11.0), ui.visuals().text_color());
|
||||
menu_value(ui, app, cmd, selected, label, value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -385,8 +594,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Count-off, meter and tempo readouts; the tempo value is editable in place for the tempo event at or before the
|
||||
/// playhead.
|
||||
/// Count-off, meter and tempo readouts; the tempo value edits the event at or before the playhead.
|
||||
fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
let (bpm, meter, countoff, click, bars) = {
|
||||
@@ -448,6 +656,7 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) {
|
||||
resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it");
|
||||
}
|
||||
|
||||
/// Draws the compact stereo main-output meter.
|
||||
fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover());
|
||||
let m = app.main_meter;
|
||||
@@ -457,3 +666,129 @@ fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
|
||||
crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
/// Builds a compact Grid/Nudge toolbar test harness.
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let mut harness = Harness::builder().with_size(vec2(700.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let edit = &app.engine.session().edit;
|
||||
let (grid, grid_follow, clips, nudge, nudge_follow) =
|
||||
(edit.grid, edit.grid_follow_main, edit.grid_clips_markers, edit.nudge, edit.nudge_follow_main);
|
||||
let grid_anchor = ui.button("Grid menu");
|
||||
value_menu(app, &grid_anchor, "view.grid", grid, grid_follow, clips, true);
|
||||
let nudge_anchor = ui.button("Nudge menu");
|
||||
value_menu(app, &nudge_anchor, "view.nudge", nudge, nudge_follow, false, false);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness
|
||||
}
|
||||
|
||||
/// Opens a toolbar value menu and lets the popup settle.
|
||||
fn open(harness: &mut Harness<SoundApp>, label: &str) {
|
||||
harness.get_by_label(label).click();
|
||||
harness.run_steps(3);
|
||||
}
|
||||
|
||||
/// Compact values use their corresponding counter formats and symbols.
|
||||
#[test]
|
||||
fn compact_grid_values_use_their_counter_formats() {
|
||||
let session = Session::default();
|
||||
let note = |value, dotted, triplet| GridValue::Note { value, dotted, triplet };
|
||||
assert_eq!(compact_grid_label(&session, note(NoteValue::Quarter, false, false)), "0|1|000");
|
||||
assert_eq!(compact_grid_label(&session, note(NoteValue::Eighth, false, true)), "0|0|320");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Seconds(0.001)), "0:00.001");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::Second)), "00:00:01:00.00");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::QuarterFrame)), "00:00:00:00.25");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::FeetFrames(FeetFramesValue::Foot)), "1+00");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Samples(10_000)), "10000");
|
||||
}
|
||||
|
||||
/// The Grid menu exposes every scale and retains its numeric value in Clips/Markers mode.
|
||||
#[test]
|
||||
fn grid_menu_exposes_each_scale_and_keeps_the_increment_in_clips_markers_mode() {
|
||||
let mut harness = harness();
|
||||
open(&mut harness, "Grid menu");
|
||||
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Clips/Markers", "Follow Main Time Scale"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
for label in ["1 bar", "Whole note", "1/2 note", "1/4 note", "1/8 note", "1/16 note", "1/32 note", "1/64 note", "Dotted", "Triplet"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
|
||||
harness.get_by_label("Timecode").click();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Grid menu");
|
||||
for label in ["1 second", "5 frames", "1 frame", "1/2 frame", "1/4 frame", "1 subframe"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
harness.get_by_label("1/4 frame").click();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("Clips/Markers").click();
|
||||
harness.run_steps(3);
|
||||
let edit = &harness.state().engine.session().edit;
|
||||
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
|
||||
assert!(edit.grid_clips_markers);
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("1/2 frame").click();
|
||||
harness.run_steps(3);
|
||||
let edit = &harness.state().engine.session().edit;
|
||||
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::HalfFrame));
|
||||
assert!(!edit.grid_clips_markers);
|
||||
}
|
||||
|
||||
/// Selecting another musical increment preserves the active dotted or triplet modifier.
|
||||
#[test]
|
||||
fn note_menu_keeps_the_current_modifier_when_changing_value() {
|
||||
let mut harness = harness();
|
||||
harness.state_mut().run("view.grid", json!({"value": "1/8t"})).unwrap();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("1/16 note").click();
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: true });
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("Whole note").click();
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: true });
|
||||
}
|
||||
|
||||
/// The Nudge menu exposes every numeric scale without the Grid-only Clips/Markers mode.
|
||||
#[test]
|
||||
fn nudge_menu_has_every_numeric_scale_but_no_clips_markers_mode() {
|
||||
let mut harness = harness();
|
||||
open(&mut harness, "Nudge menu");
|
||||
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Follow Main Time Scale"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
assert_eq!(harness.query_all_by_label("Clips/Markers").count(), 0);
|
||||
harness.get_by_label("Samples").click();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Nudge menu");
|
||||
for label in ["10000 samples", "1000 samples", "100 samples", "10 samples", "2 samples", "1 sample"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1262: the two link toggles differ only by their icon, so each option toggle needs its own.
|
||||
#[test]
|
||||
fn option_toggles_have_distinct_icons() {
|
||||
let toggles = option_toggles(&EditState::default());
|
||||
let timeline = toggles.iter().find(|t| t.0 == "options.link_timeline_edit").unwrap();
|
||||
let track = toggles.iter().find(|t| t.0 == "options.link_track_edit").unwrap();
|
||||
assert_ne!(timeline.1, track.1, "the timeline and track links need different icons");
|
||||
let mut icons: Vec<&str> = toggles.iter().map(|t| t.1).collect();
|
||||
icons.sort_unstable();
|
||||
icons.dedup();
|
||||
assert_eq!(icons.len(), toggles.len(), "every option toggle needs its own icon");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use egui::{Color32, FullOutput, Pos2, RawInput, Rect, Shape, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackId, TrackKind};
|
||||
use soundcraft_ui_egui::theme::Tokens;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, edit_window};
|
||||
|
||||
@@ -22,6 +23,7 @@ fn near(a: [i32; 3], b: Color32) -> bool {
|
||||
a.iter().zip([b.r(), b.g(), b.b()]).all(|(x, y)| (x - i32::from(y)).abs() <= 6)
|
||||
}
|
||||
|
||||
/// Numeric Grid lines retain their stacking order over clips in both themes.
|
||||
#[test]
|
||||
fn grid_lines_are_drawn_over_clips_while_shown() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
@@ -68,3 +70,94 @@ fn grid_lines_are_drawn_over_clips_while_shown() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clips/Markers grid lines reach the timeline at memory-location boundaries.
|
||||
#[test]
|
||||
fn clips_markers_grid_draws_a_line_at_a_memory_location() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap();
|
||||
let clip = &kick.clips()[0];
|
||||
let marker = clip.start + clip.length / 3;
|
||||
app.run("markers.add", json!({"at": marker, "name": "Grid target"})).unwrap();
|
||||
app.run("view.grid", json!({"format": "clips_markers", "lines": true})).unwrap();
|
||||
|
||||
frame(&ctx, &mut app);
|
||||
let out = frame(&ctx, &mut app);
|
||||
let session = app.engine.session();
|
||||
let kick = session.track_by_name("Kick").unwrap();
|
||||
let [x0, y0, x1, y1] = app.edit_layout.timeline;
|
||||
let timeline = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
|
||||
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1;
|
||||
let lane = Rect::from_min_max(pos2(timeline.min.x, row[1]), pos2(timeline.max.x, row[1] + kick.height.points()));
|
||||
let expected_x = edit_window::x_of(session, timeline, marker);
|
||||
|
||||
assert!(out.shapes.iter().any(|shape| {
|
||||
matches!(
|
||||
&shape.shape,
|
||||
Shape::LineSegment { points: [a, b], .. }
|
||||
if a.x == expected_x && b.x == expected_x && a.y == lane.min.y && b.y == lane.max.y
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether a vertical grid line spans the main lane of a visible track at the requested sample.
|
||||
fn line_on_track(out: &FullOutput, app: &SoundApp, track: TrackId, at: i64) -> bool {
|
||||
let s = app.engine.session();
|
||||
let [x0, y0, x1, y1] = app.edit_layout.timeline;
|
||||
let timeline = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
|
||||
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == track.0).unwrap().1;
|
||||
let bottom = row[1] + s.track(track).unwrap().height.points();
|
||||
let x = edit_window::x_of(s, timeline, at);
|
||||
out.shapes.iter().any(|shape| {
|
||||
matches!(&shape.shape, Shape::LineSegment { points: [a, b], .. }
|
||||
if a.x == x && b.x == x && a.y == row[1] && b.y == bottom)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared frame lines reflect visible children, edited boundaries, scrolling and zoom on every lane.
|
||||
#[test]
|
||||
fn clips_markers_lines_refresh_across_lanes_and_hidden_open_folders() {
|
||||
let mut engine = soundcraft_engine::Engine::default();
|
||||
let s = engine.session_mut();
|
||||
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let other = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
s.track_mut(folder).unwrap().hidden = true;
|
||||
s.track_mut(child).unwrap().folder = Some(folder);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_200, 5_000, Default::default());
|
||||
let clip_id = clip.id;
|
||||
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, 9_000, 9_000);
|
||||
s.edit.grid_clips_markers = true;
|
||||
s.edit.grid_lines = true;
|
||||
let mut app = SoundApp::new(engine, None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
for (start, marker, scroll, zoom) in [(1_200, 9_000, 0, 20.0), (3_000, 11_000, 0, 20.0), (3_000, 11_000, 1_000, 40.0)] {
|
||||
let s = app.engine.session_mut();
|
||||
s.find_clip_mut(clip_id).unwrap().start = start;
|
||||
s.markers[0].start = marker;
|
||||
s.markers[0].end = marker;
|
||||
s.edit.zoom.scroll = scroll;
|
||||
s.edit.zoom.samples_per_px = zoom;
|
||||
frame(&ctx, &mut app);
|
||||
let out = frame(&ctx, &mut app);
|
||||
assert!(!app.edit_layout.rows.iter().any(|(id, _)| *id == folder.0));
|
||||
for track in [child, other] {
|
||||
assert!(line_on_track(&out, &app, track, start));
|
||||
assert!(line_on_track(&out, &app, track, marker));
|
||||
if start != 1_200 {
|
||||
assert!(!line_on_track(&out, &app, track, 1_200));
|
||||
assert!(!line_on_track(&out, &app, track, 9_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.engine.session_mut().track_mut(folder).unwrap().folder_open = false;
|
||||
frame(&ctx, &mut app);
|
||||
let out = frame(&ctx, &mut app);
|
||||
assert!(!app.edit_layout.rows.iter().any(|(id, _)| *id == child.0));
|
||||
assert!(!line_on_track(&out, &app, other, 3_000));
|
||||
assert!(line_on_track(&out, &app, other, 11_000));
|
||||
}
|
||||
|
||||
@@ -1,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");
|
||||
}
|
||||
|
||||
+135
-14
@@ -72,6 +72,22 @@ impl Dpb {
|
||||
self.entries.retain(|e| e.needed_for_output || e.mark != RefMark::Unused);
|
||||
}
|
||||
|
||||
/// Make room for a picture: output one (C.4.5.3), or, when none waits for output and
|
||||
/// references alone fill the DPB (marking that keeps more than it holds), drop the reference
|
||||
/// decoded longest ago. False when the DPB is empty.
|
||||
fn make_room(&mut self, out: &mut Vec<Output>) -> bool {
|
||||
if self.bump(out) {
|
||||
return true;
|
||||
}
|
||||
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.mark != RefMark::Unused).min_by_key(|(_, e)| e.frame.id).map(|(i, _)| i)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.entries[i].mark = RefMark::Unused;
|
||||
self.prune();
|
||||
true
|
||||
}
|
||||
|
||||
/// Output the picture with the smallest POC (C.4.5.3 "bumping"). Returns false if none waits.
|
||||
fn bump(&mut self, out: &mut Vec<Output>) -> bool {
|
||||
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.needed_for_output).min_by_key(|(_, e)| e.poc).map(|(i, _)| i) else {
|
||||
@@ -134,21 +150,29 @@ impl Dpb {
|
||||
poc_state: &mut dyn FnMut(u32),
|
||||
) {
|
||||
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
|
||||
// Only the last `max_num_ref_frames` missing frames can still be references when the gap
|
||||
// ends: the sliding window drops the earlier ones first, so building their pictures would
|
||||
// be work (up to 65 535 frame copies from one slice header) thrown straight away. They
|
||||
// advance the POC state only.
|
||||
let gap = (frame_num + max_frame_num - unused) % max_frame_num;
|
||||
let skip = gap.saturating_sub(u32::try_from(max_num_ref_frames.max(1)).unwrap_or(u32::MAX));
|
||||
let mut guard = 0;
|
||||
while unused != frame_num && guard < max_frame_num {
|
||||
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
|
||||
let (frame, meta) = make_frame(unused);
|
||||
poc_state(unused);
|
||||
self.entries.push(DpbEntry {
|
||||
poc: frame.poc,
|
||||
frame,
|
||||
frame_num: unused,
|
||||
long_term_frame_idx: 0,
|
||||
mark: RefMark::Short,
|
||||
needed_for_output: false,
|
||||
non_existing: true,
|
||||
meta,
|
||||
});
|
||||
if guard >= skip {
|
||||
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
|
||||
let (frame, meta) = make_frame(unused);
|
||||
self.entries.push(DpbEntry {
|
||||
poc: frame.poc,
|
||||
frame,
|
||||
frame_num: unused,
|
||||
long_term_frame_idx: 0,
|
||||
mark: RefMark::Short,
|
||||
needed_for_output: false,
|
||||
non_existing: true,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
unused = (unused + 1) % max_frame_num;
|
||||
guard += 1;
|
||||
}
|
||||
@@ -277,6 +301,11 @@ impl Dpb {
|
||||
}
|
||||
}
|
||||
}
|
||||
3 | 6 if self.max_long_term_frame_idx.is_none_or(|max| m.long_term_frame_idx > max) => {
|
||||
// A long-term index outside what MMCO 4 (or the IDR) allowed (8.2.5.4.3): a
|
||||
// conforming stream never sends one, and honouring it would let distinct
|
||||
// indices retain references past the DPB's capacity.
|
||||
}
|
||||
3 => {
|
||||
let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1);
|
||||
for e in &mut self.entries {
|
||||
@@ -384,13 +413,13 @@ impl Dpb {
|
||||
out.push(Output { frame, poc, meta });
|
||||
return;
|
||||
}
|
||||
if !self.bump(out) {
|
||||
if !self.make_room(out) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while self.entries.len() >= self.capacity {
|
||||
if !self.bump(out) {
|
||||
if !self.make_room(out) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -429,6 +458,98 @@ mod tests {
|
||||
Mmco { op, difference_of_pic_nums_minus1: diff, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }
|
||||
}
|
||||
|
||||
fn frame(id: u32) -> (FrameRef, Arc<OutputMeta>) {
|
||||
let meta = OutputMeta {
|
||||
pts: 0,
|
||||
key: false,
|
||||
crop: (0, 0, 0, 0),
|
||||
full_range: false,
|
||||
colour_primaries: 0,
|
||||
transfer_characteristics: 0,
|
||||
matrix_coefficients: 0,
|
||||
sar: (1, 1),
|
||||
draft: false,
|
||||
};
|
||||
(Arc::new(crate::h264::picture::Frame::new(id, 0, 1, 1)), Arc::new(meta))
|
||||
}
|
||||
|
||||
/// A P slice header of a reference picture, with nothing else set.
|
||||
fn reference_slice() -> SliceHeader {
|
||||
SliceHeader {
|
||||
nal_ref_idc: 1,
|
||||
nal_unit_type: 1,
|
||||
idr: false,
|
||||
first_mb_in_slice: 0,
|
||||
slice_type_raw: 0,
|
||||
slice_type: SliceType::P,
|
||||
pps_id: 0,
|
||||
colour_plane_id: 0,
|
||||
frame_num: 0,
|
||||
field_pic: false,
|
||||
bottom_field: false,
|
||||
idr_pic_id: 0,
|
||||
pic_order_cnt_lsb: 0,
|
||||
delta_pic_order_cnt_bottom: 0,
|
||||
delta_pic_order_cnt: [0; 2],
|
||||
redundant_pic_cnt: 0,
|
||||
direct_spatial_mv_pred: false,
|
||||
num_ref_idx_active: [1, 0],
|
||||
ref_pic_list_mod: [Vec::new(), Vec::new()],
|
||||
pred_weight_table: None,
|
||||
no_output_of_prior_pics: false,
|
||||
long_term_reference: false,
|
||||
adaptive_ref_pic_marking: false,
|
||||
mmcos: Vec::new(),
|
||||
cabac_init_idc: 0,
|
||||
slice_qp_delta: 0,
|
||||
sp_for_switch: false,
|
||||
slice_qs_delta: 0,
|
||||
disable_deblocking_filter_idc: 0,
|
||||
slice_alpha_c0_offset_div2: 0,
|
||||
slice_beta_offset_div2: 0,
|
||||
slice_group_change_cycle: 0,
|
||||
header_bits: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_num_gap_builds_only_the_frames_that_stay_references() {
|
||||
let mut dpb = Dpb::new();
|
||||
let (mut built, mut poc_updates) = (Vec::new(), 0u32);
|
||||
// frame_num 0 was the last reference; the next slice says 60_000: 59 999 missing frames.
|
||||
dpb.fill_frame_num_gap(0, 60_000, 1 << 16, 4, &mut |n| (built.push(n), frame(n)).1, &mut |_| poc_updates += 1);
|
||||
assert_eq!(poc_updates, 59_999, "every missing frame advances the POC state");
|
||||
assert_eq!(built, vec![59_996, 59_997, 59_998, 59_999], "only the ones the sliding window would keep are built");
|
||||
assert_eq!(dpb.entries.iter().map(|e| e.frame_num).collect::<Vec<_>>(), built);
|
||||
// A gap shorter than the window builds every frame, as before.
|
||||
let mut dpb = Dpb::new();
|
||||
let mut built = Vec::new();
|
||||
dpb.fill_frame_num_gap(7, 10, 16, 4, &mut |n| (built.push(n), frame(n)).1, &mut |_| {});
|
||||
assert_eq!(built, vec![8, 9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_term_marking_cannot_grow_the_dpb_past_its_capacity() {
|
||||
let mut dpb = Dpb::new();
|
||||
dpb.capacity = 4;
|
||||
let mut out = Vec::new();
|
||||
let mut sh = SliceHeader { adaptive_ref_pic_marking: true, ..reference_slice() };
|
||||
// Each picture takes a distinct long-term index, after MMCO 4 allowed 1000 of them.
|
||||
for n in 0..40u32 {
|
||||
sh.frame_num = n;
|
||||
sh.mmcos = vec![Mmco { op: 4, max_long_term_frame_idx_plus1: 1000, ..mmco(0, 0) }, Mmco { op: 6, long_term_frame_idx: n, ..mmco(0, 0) }];
|
||||
let (f, meta) = frame(n);
|
||||
dpb.store_picture(&sh, f, n as i32, 1 << 16, 16, meta, &mut out);
|
||||
assert!(dpb.entries.len() <= 4, "{} entries after picture {n}", dpb.entries.len());
|
||||
}
|
||||
// An index past what MMCO 4 allowed is ignored: the picture is a short-term reference.
|
||||
sh.frame_num = 40;
|
||||
sh.mmcos = vec![Mmco { op: 6, long_term_frame_idx: 5000, ..mmco(0, 0) }];
|
||||
let (f, meta) = frame(40);
|
||||
dpb.store_picture(&sh, f, 40, 1 << 16, 16, meta, &mut out);
|
||||
assert_eq!(dpb.entries.iter().find(|e| e.frame_num == 40).map(|e| e.mark), Some(RefMark::Short));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mmco_huge_difference_does_not_panic() {
|
||||
for op in [1, 3] {
|
||||
|
||||
@@ -397,6 +397,10 @@ impl Sps {
|
||||
let pic_height_in_map_units = plus(r.read_ue()?, 1, "picture too large")?;
|
||||
ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large");
|
||||
let frame_mbs_only = r.read_flag()?;
|
||||
// Picture state is allocated from these before any macroblock is read: the movie's pixel
|
||||
// budget applies here too, so a 16 384-square SPS can't claim its planes.
|
||||
let coded_mbs = u64::from(pic_width_in_mbs) * u64::from(pic_height_in_map_units) * if frame_mbs_only { 1 } else { 2 };
|
||||
ensure!(coded_mbs * 256 <= crate::movie::MAX_PIXELS, "picture too large");
|
||||
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
|
||||
let direct_8x8_inference = r.read_flag()?;
|
||||
let frame_crop = if r.read_flag()? { Some((r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?)) } else { None };
|
||||
@@ -699,6 +703,37 @@ mod tests {
|
||||
assert_eq!(l, [10; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_sps_past_the_pixel_budget_is_refused_before_allocation() {
|
||||
// As `parse_minimal_baseline_sps`, with `mbs` × `mbs` macroblocks.
|
||||
let sps = |mbs: u32, frame_mbs_only: bool| {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(66, 8);
|
||||
w.write_bits(0xC0, 8);
|
||||
w.write_bits(30, 8);
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_ue(2);
|
||||
w.write_ue(1);
|
||||
w.write_bit(false);
|
||||
w.write_ue(mbs - 1);
|
||||
w.write_ue(mbs - 1);
|
||||
w.write_bit(frame_mbs_only);
|
||||
if !frame_mbs_only {
|
||||
w.write_bit(false); // mb_adaptive_frame_field
|
||||
}
|
||||
w.write_bit(true);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
w.rbsp_trailing();
|
||||
Sps::parse(&w.finish())
|
||||
};
|
||||
assert!(sps(512, true).is_ok(), "8192 × 8192 is the budget");
|
||||
assert!(sps(513, true).is_err(), "one macroblock column more is past it");
|
||||
assert!(sps(512, false).is_err(), "field pictures double the map units");
|
||||
assert!(sps(362, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_baseline_sps() {
|
||||
// profile 66, level 30, sps_id 0, log2_max_frame_num_minus4 0, poc type 2, 1 ref, 11x9 MBs.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -197,8 +197,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_failed_update_restores_the_manifest() {
|
||||
let root = std::env::temp_dir().join(format!("xtask-version-restore-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// Created exclusively: a folder already there (planted, or left by a failed run) would be
|
||||
// someone else's, and a link inside it could redirect the writes below.
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
|
||||
let root = std::env::temp_dir().join(format!("xtask-version-restore-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let path = root.join("Cargo.toml");
|
||||
std::fs::write(&path, MANIFEST).unwrap();
|
||||
let err = set(&root, &path, MANIFEST, "2.0.0", || Err("cargo update failed".into())).unwrap_err();
|
||||
|
||||
Reference in New Issue
Block a user