commit
7c061de737
|
|
@ -3424,7 +3424,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -3442,7 +3442,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
|
|
@ -3452,7 +3452,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3464,7 +3464,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
|
|
@ -3474,7 +3474,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading",
|
||||
|
|
@ -3486,7 +3486,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
|
|
@ -3497,7 +3497,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3508,7 +3508,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3530,7 +3530,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3540,7 +3540,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
|
|
@ -3556,7 +3556,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3569,7 +3569,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
|
|
@ -3582,7 +3582,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3592,7 +3592,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
|
|
@ -3618,7 +3618,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"proptest",
|
||||
|
|
@ -3629,7 +3629,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
|
|
@ -3644,7 +3644,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ very welcome, in the issues or on Discord.
|
|||
|
||||
## Downloads
|
||||
|
||||
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
|||
## Progress log
|
||||
|
||||
- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256)
|
||||
- **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248)
|
||||
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
|
||||
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
|
|
@ -145,6 +146,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: convert option validation (#256) |
|
||||
| 2026-10-12 | trivial | Progress log: timecode overflow (#248) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ enum Purpose {
|
|||
}
|
||||
|
||||
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
|
||||
const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const AUDIO_EXTS: &[&str] =
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
|
||||
|
||||
impl Purpose {
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ fn services() -> Services {
|
|||
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
|
||||
_ => d.add_filter(
|
||||
"Audio",
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"],
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
|
||||
),
|
||||
};
|
||||
d.pick_file().map(|p| p.to_string_lossy().into_owned())
|
||||
|
|
|
|||
|
|
@ -40,3 +40,6 @@ sync_github_name = true
|
|||
real_name = "Vikram Bhaskaran"
|
||||
display_name = "Vikram Bhaskaran"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ fn detect_by_ext(ext: &str) -> FileFormat {
|
|||
"flac" => FileFormat::Flac,
|
||||
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
|
||||
"ogg" | "oga" => FileFormat::Ogg,
|
||||
"aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac,
|
||||
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
|
||||
"caf" => FileFormat::Caf,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ fn detect_format_by_magic_and_hint() {
|
|||
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
|
||||
|
|
|
|||
|
|
@ -164,10 +164,12 @@ fn lufs(energy: f64) -> f32 {
|
|||
/// K-weighting pre-filter (high shelf) and RLB high-pass designed for any sample rate.
|
||||
fn k_weighting(sr: f32) -> [Coeffs; 2] {
|
||||
let sr = f64::from(sr);
|
||||
let pi = std::f64::consts::PI;
|
||||
// Bilinear prewarp. A corner at or past Nyquist wraps tan() negative (poles outside the unit
|
||||
// circle) or aliases it, so it is held just below Nyquist, as `Coeffs::design` does.
|
||||
let prewarp = |f0: f64| (std::f64::consts::PI * f0.min(sr * 0.49) / sr).tan();
|
||||
// Stage 1: shelving.
|
||||
let (f0, g, q) = (1_681.974_450_955_533, 3.999_843_853_973_347, 0.707_175_236_955_419_6);
|
||||
let k = (pi * f0 / sr).tan();
|
||||
let k = prewarp(f0);
|
||||
let vh = 10f64.powf(g / 20.0);
|
||||
let vb = vh.powf(0.499_666_774_154_541_6);
|
||||
let a0 = 1.0 + k / q + k * k;
|
||||
|
|
@ -180,7 +182,7 @@ fn k_weighting(sr: f32) -> [Coeffs; 2] {
|
|||
};
|
||||
// Stage 2: high-pass.
|
||||
let (f0, q) = (38.135_470_876_024_44, 0.500_327_037_323_877_3);
|
||||
let k = (pi * f0 / sr).tan();
|
||||
let k = prewarp(f0);
|
||||
let a0 = 1.0 + k / q + k * k;
|
||||
let s2 = Coeffs { b0: 1.0, b1: -2.0, b2: 1.0, a1: (2.0 * (k * k - 1.0) / a0) as f32, a2: ((1.0 - k / q + k * k) / a0) as f32 };
|
||||
[s1, s2]
|
||||
|
|
|
|||
|
|
@ -341,7 +341,14 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
|
|||
return ch.to_vec();
|
||||
}
|
||||
let factor = 2f64.powf(f64::from(st) / 12.0);
|
||||
let stretched = time_stretch(ch, factor, sample_rate);
|
||||
// time_stretch clamps to 0.1..=10, short of the 16x (or 1/16) that ±48 needs: stretch twice by
|
||||
// the square root instead.
|
||||
let stretched = if (0.1..=10.0).contains(&factor) {
|
||||
time_stretch(ch, factor, sample_rate)
|
||||
} else {
|
||||
let half = factor.sqrt();
|
||||
time_stretch(&time_stretch(ch, half, sample_rate), half, sample_rate)
|
||||
};
|
||||
let len = max_len(ch);
|
||||
let slen = max_len(&stretched);
|
||||
if slen == 0 || len == 0 {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ pub fn yin(frame: &[f32], sample_rate: f32, min_hz: f32, max_hz: f32, threshold:
|
|||
}
|
||||
let max_tau = ((sample_rate / min_hz.max(20.0)) as usize).min(frame.len() / 2);
|
||||
let min_tau = ((sample_rate / max_hz.max(min_hz + 1.0)) as usize).max(2);
|
||||
if max_tau <= min_tau + 2 {
|
||||
if max_tau <= min_tau.saturating_add(2) {
|
||||
return None;
|
||||
}
|
||||
let w = frame.len() - max_tau;
|
||||
|
|
@ -140,4 +140,16 @@ mod tests {
|
|||
assert!(audio_to_notes(&[], f32::NAN).is_empty());
|
||||
assert!(yin(&[0.0; 10], 48_000.0, 50.0, 1600.0, 0.15).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_finite_rate_is_unvoiced() {
|
||||
// #241: the shortest lag saturates to usize::MAX; that is "no pitch", not an overflow.
|
||||
assert!(yin(&[0.0; 64], 1.0e30, 50.0, 1600.0, 0.15).is_none());
|
||||
assert!(yin(&[0.5; 8192], f32::MAX, 50.0, 1600.0, 0.15).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_to_notes_survives_huge_finite_rate() {
|
||||
assert!(audio_to_notes(&[0.5; 8192], 1.0e30).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,12 +184,18 @@ impl<const S: usize> Eq<S> {
|
|||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let mut snapped = false;
|
||||
for (i, (s, info)) in self.sm.iter_mut().zip(self.info.params).enumerate() {
|
||||
s.set(self.p.v(i));
|
||||
if matches!(info.unit, Unit::Toggle | Unit::Choice) {
|
||||
s.snap();
|
||||
snapped |= self.vals.get(i) != Some(&s.value());
|
||||
}
|
||||
}
|
||||
// A snapped smoother never settles, so `process` would not redesign for it.
|
||||
if snapped {
|
||||
self.redesign();
|
||||
}
|
||||
let g = db_to_gain(self.total_gain_db(|i| self.p.v(i)));
|
||||
self.gain.set(g);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,7 +316,15 @@ impl Plugin for ModFx {
|
|||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
ms_to_samples(200.0, self.sr) as usize
|
||||
// Time for feedback to fall by 60 dB at the longest delay of the sweep, as for the delay
|
||||
// above; never shorter than the 200 ms this used to report.
|
||||
let longest = match self.kind {
|
||||
ModKind::Chorus => self.p.v(2) + self.p.v(1) / 100.0 * 6.0,
|
||||
ModKind::Flanger => self.p.v(2),
|
||||
};
|
||||
let fb = (self.p.v(3).abs() / 100.0).clamp(0.0, 0.99);
|
||||
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
|
||||
ms_to_samples((longest * repeats).max(200.0), self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,21 @@ fn loudness_full_scale_mono_and_44k() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loudness_stays_bounded_at_low_sample_rates() {
|
||||
// The K-weighting shelf corner (~1.7 kHz) sits at or above Nyquist below ~3.4 kHz; the
|
||||
// filter must stay stable there instead of reading hundreds of LUFS (issue #240).
|
||||
for sr in [1000.0f32, 1200.0, 1600.0, 2000.0, 3000.0, 3400.0, 8000.0, 48_000.0] {
|
||||
let len = sr as usize;
|
||||
let mut m = LoudnessMeter::new(sr, 1);
|
||||
assert_eq!(m.sample_rate(), sr);
|
||||
m.process(&[sine(100.0, 0.5, sr, len)], len);
|
||||
for level in [m.integrated_lufs(), m.momentary_lufs(), m.short_term_lufs()] {
|
||||
assert!(level.is_finite() && (-30.0..=0.0).contains(&level), "{sr}: {level}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loudness_gating_ignores_silence() {
|
||||
let sr = 48_000.0;
|
||||
|
|
@ -155,6 +170,19 @@ fn pitch_shift_preserves_length_and_shifts() {
|
|||
assert!((f - want).abs() < 4.0, "{f} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitch_shift_reaches_the_ends_of_its_range() {
|
||||
let sr = 48_000.0;
|
||||
let x = sine(440.0, 0.5, sr, 48_000);
|
||||
for st in [40.0f32, 48.0, -40.0, -48.0] {
|
||||
let out = offline::pitch_shift(std::slice::from_ref(&x), st, sr);
|
||||
assert_eq!(out[0].len(), x.len());
|
||||
let f = dominant_hz(&out[0], sr);
|
||||
let want = 440.0 * 2f32.powf(st / 12.0);
|
||||
assert!((f / want - 1.0).abs() < 0.002, "{st} semitones: {f} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_hits_target_peak() {
|
||||
let mut ch = vec![sine(100.0, 0.2, 48_000.0, 4800), sine(100.0, 0.05, 48_000.0, 4800)];
|
||||
|
|
|
|||
|
|
@ -199,6 +199,44 @@ fn eq_magnitude_at_center_matches_gain() {
|
|||
assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eq_switches_and_choices_apply_to_a_running_eq() {
|
||||
// (plugin, settings, the one switch or choice changed while running, test tone)
|
||||
let cases: [(&str, &[(&str, f32)], (&str, f32), f32); 7] = [
|
||||
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0)], ("hpf_on", 1.0), 1000.0),
|
||||
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0), ("hpf_on", 1.0)], ("hpf_on", 0.0), 1000.0),
|
||||
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 0.0), ("hpf_on", 1.0)], ("hpf_slope", 3.0), 1000.0),
|
||||
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 3.0)], ("lpf_on", 1.0), 4000.0),
|
||||
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 0.0), ("lpf_on", 1.0)], ("lpf_slope", 3.0), 4000.0),
|
||||
("eq_1band", &[("freq", 1000.0)], ("type", 4.0), 10_000.0),
|
||||
("channel_strip", &[("hpf_freq", 2000.0)], ("hpf_on", 1.0), 1000.0),
|
||||
];
|
||||
let mut stale = Vec::new();
|
||||
for (id, setup, (key, value), tone) in cases {
|
||||
let x = [sine(tone, 0.5, 24_000)];
|
||||
let level = |p: &mut dyn Plugin| gain_to_db(rms(&run(p, &x)[0][12_000..]));
|
||||
let build = |settings: &[(&str, f32)]| {
|
||||
let mut p = create(id).unwrap();
|
||||
p.prepare(SR, BLOCK, 1);
|
||||
for &(k, v) in settings {
|
||||
assert!(p.set_param(k, v), "{id} {k}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
};
|
||||
let mut live = build(setup);
|
||||
let before = level(live.as_mut());
|
||||
assert!(live.set_param(key, value));
|
||||
let after = level(live.as_mut());
|
||||
let want = level(build(&[setup, &[(key, value)]].concat()).as_mut());
|
||||
assert!((before - want).abs() > 3.0, "{id} {key}={value}: the tone must tell the settings apart");
|
||||
if (after - want).abs() >= 0.1 {
|
||||
stale.push(format!("{id} {key}={value}: live {after:.2} dB, fresh {want:.2} dB, before {before:.2} dB"));
|
||||
}
|
||||
}
|
||||
assert!(stale.is_empty(), "{stale:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressor_steady_state_gain_reduction() {
|
||||
let mut p = prepared("compressor");
|
||||
|
|
@ -329,6 +367,23 @@ fn delay_repeats_at_time() {
|
|||
assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modulation_tail_covers_feedback_echoes() {
|
||||
for (id, delay, feedback) in [("chorus", 30.0, 90.0), ("flanger", 10.0, 95.0), ("flanger", 10.0, -95.0)] {
|
||||
let mut p = prepared(id);
|
||||
for (param, v) in [("delay", delay), ("feedback", feedback), ("depth", 0.0), ("mix", 100.0)] {
|
||||
assert!(p.set_param(param, v));
|
||||
}
|
||||
p.reset();
|
||||
let mut x = vec![0.0f32; 48_000 * 5];
|
||||
x[0] = 0.5;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let floor = peak(&out[0]) * 1e-3;
|
||||
let last = out[0].iter().rposition(|v| v.abs() > floor).unwrap();
|
||||
assert!(last < p.tail_samples(), "{id} feedback {feedback}: echo at {last}, tail {}", p.tail_samples());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_and_invert() {
|
||||
let mut p = prepared("invert");
|
||||
|
|
|
|||
|
|
@ -199,9 +199,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
continue;
|
||||
}
|
||||
let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1);
|
||||
let mut warp = base.clone();
|
||||
warp.stretch = ratio;
|
||||
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &warp, out_len)? else { continue };
|
||||
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue };
|
||||
if orig.is_none()
|
||||
&& let soundcraft_model::ClipContent::Audio { source, offset } = base.content
|
||||
{
|
||||
|
|
@ -288,8 +286,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let s = e.session_mut();
|
||||
let mut made = 0;
|
||||
for id in ids {
|
||||
let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000);
|
||||
if reps > 0 && c.loop_id.is_none() {
|
||||
let lid = s.alloc();
|
||||
if let Some(src) = s.find_clip_mut(id) {
|
||||
src.loop_id = Some(lid);
|
||||
}
|
||||
c.loop_id = Some(lid);
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for k in 1..=reps {
|
||||
let mut nc = c.clone();
|
||||
|
|
@ -378,6 +383,45 @@ mod tests {
|
|||
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
|
||||
}
|
||||
|
||||
/// A clip on an Elastic track playing `clip_len` samples of a source that is silent before `tone_at`.
|
||||
fn elastic_clip(source_len: usize, tone_at: usize, clip_len: i64) -> (crate::Engine, TrackId, ClipId) {
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic"));
|
||||
let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect();
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
|
||||
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len));
|
||||
let mut e = crate::Engine::new(s);
|
||||
e.execute("track.elastic", &json!({"track": t.0, "algorithm": "polyphonic"})).unwrap();
|
||||
(e, t, cid)
|
||||
}
|
||||
|
||||
fn rendered_peak(e: &crate::Engine, t: TrackId, cid: ClipId) -> f32 {
|
||||
let range = e.session().find_clip(cid).unwrap().1.range();
|
||||
soundcraft_mix::render_clips(e.session(), t, range).iter().flatten().fold(0.0, |m, x| m.max(x.abs()))
|
||||
}
|
||||
|
||||
/// Elastic Properties stretches the material the clip plays, by the requested ratio (#227).
|
||||
#[test]
|
||||
fn elastic_properties_stretches_the_clip_material() {
|
||||
let (mut e, t, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
|
||||
assert_eq!(e.session().find_clip(cid).unwrap().1.length, 40_000);
|
||||
assert!(rendered_peak(&e, t, cid) > 0.3, "the tone in the second half was lost");
|
||||
|
||||
let (mut e, t, cid) = elastic_clip(60_000, 20_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
|
||||
assert_eq!(rendered_peak(&e, t, cid), 0.0, "audio from past the clip end leaked in");
|
||||
|
||||
for (ratio, len) in [(4.0, 80_000), (0.2, 4_000)] {
|
||||
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": ratio})).unwrap();
|
||||
assert_eq!(e.session().find_clip(cid).unwrap().1.length, len, "ratio {ratio}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
|
|
|||
|
|
@ -101,12 +101,13 @@ fn regroup(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
|
||||
fn same_material(a: &Clip, b: &Clip) -> bool {
|
||||
a.length == b.length
|
||||
&& match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
}
|
||||
&& (a.loop_id.is_some() && a.loop_id == b.loop_id
|
||||
|| match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The loop a clip belongs to: back-to-back copies of the same material. Origin first.
|
||||
|
|
@ -160,6 +161,7 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
} else {
|
||||
for c in pl.clips.iter_mut().filter(|c| chain.contains(&c.id)) {
|
||||
c.group = None;
|
||||
c.loop_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -304,6 +306,42 @@ mod tests {
|
|||
assert!(e.execute("clip.unloop", &json!({"clip": ids[3].0})).is_err());
|
||||
}
|
||||
|
||||
/// A 100-sample MIDI clip with one note, on a fresh instrument-less MIDI track.
|
||||
fn midi_engine() -> (Engine, TrackId, ClipId) {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let id = s.new_clip_id();
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 240 });
|
||||
crate::edit::place_clip(&mut s, t, Clip::midi(id, "m", 0, 100, seq));
|
||||
s.edit.selected_tracks = vec![t];
|
||||
(Engine::new(s), t, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_recognises_loops_after_source_edit() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "count": 4})).unwrap()["loops"], 3);
|
||||
// Editing the source note makes the copies' content differ from it.
|
||||
if let Some(c) = e.session_mut().find_clip_mut(id)
|
||||
&& let ClipContent::Midi { sequence } = &mut c.content
|
||||
{
|
||||
sequence.notes[0].length = 1100;
|
||||
}
|
||||
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
|
||||
let left: Vec<ClipId> = e.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
|
||||
assert_eq!(left, vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_flatten_turns_loop_into_independent_clips() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "count": 3})).unwrap();
|
||||
e.execute("clip.unloop", &json!({"clip": id.0, "mode": "flatten"})).unwrap();
|
||||
assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
|
||||
assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regroup_reuses_group() {
|
||||
let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]);
|
||||
|
|
|
|||
|
|
@ -15,11 +15,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear),
|
||||
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
"edit.cut_clip_gain"
|
||||
)),
|
||||
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| {
|
||||
super::edit_more::copy_clip_gain(e, p)?;
|
||||
reset_clip_gain(e, p, "edit.cut_clip_gain")
|
||||
}),
|
||||
cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
|
|
@ -591,13 +590,13 @@ fn trim_to_file(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let frames =
|
||||
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
|
||||
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
|
||||
if let Some((frames, lo, hi)) = trim_limits(s, id)
|
||||
&& let Some(c) = s.find_clip_mut(id)
|
||||
{
|
||||
let off = c.source_offset();
|
||||
let new_start = c.start - off;
|
||||
c.trim_start_to(new_start);
|
||||
c.trim_end_to(new_start + frames);
|
||||
c.trim_start_to(new_start.max(lo));
|
||||
c.trim_end_to((new_start + frames).min(hi));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -610,18 +609,31 @@ fn trim_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let frames =
|
||||
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
|
||||
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
|
||||
if let Some((frames, lo, hi)) = trim_limits(s, id)
|
||||
&& let Some(c) = s.find_clip_mut(id)
|
||||
{
|
||||
let file_start = c.start - c.source_offset();
|
||||
c.trim_start_to(r.start.max(file_start));
|
||||
c.trim_end_to(r.end.min(file_start + frames));
|
||||
c.trim_start_to(r.start.max(file_start).max(lo));
|
||||
c.trim_end_to(r.end.min(file_start + frames).min(hi));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"trimmed": n}))
|
||||
}
|
||||
|
||||
/// How far a trim may extend an audio clip: its source length in frames, and the earliest
|
||||
/// start and latest end before it would run over the clip before or after it on the playlist
|
||||
/// (an extension never overwrites, like the trim tool). An overlap the clip already has with
|
||||
/// a neighbour (a crossfade) is kept rather than trimmed away.
|
||||
fn trim_limits(s: &Session, id: ClipId) -> Option<(Samples, Samples, Samples)> {
|
||||
let (track, c) = s.find_clip(id)?;
|
||||
let frames = c.source().and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX))?;
|
||||
let others = || s.track(track).into_iter().flat_map(|t| t.clips()).filter(|x| x.id != id);
|
||||
let lo = others().filter(|x| x.start < c.start).map(|x| x.end()).max().map_or(Samples::MIN, |end| end.min(c.start));
|
||||
let hi = others().filter(|x| x.start > c.start).map(|x| x.start).min().map_or(Samples::MAX, |start| start.max(c.end()));
|
||||
Some((frames, lo, hi))
|
||||
}
|
||||
|
||||
fn mute_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
|
|
@ -759,7 +771,7 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
if crate::io::consolidate(e, "edit.consolidate", t, r)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -782,4 +794,58 @@ mod tests {
|
|||
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
|
||||
assert_ne!(e.revision, rev);
|
||||
}
|
||||
|
||||
/// A 60,000-frame source with `clips` placed on one track as (start, length, source offset).
|
||||
fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec<ClipId>) {
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] };
|
||||
let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let mut ids = Vec::new();
|
||||
for &(start, len, off) in clips {
|
||||
let id = s.new_clip_id();
|
||||
ops::place_clip(&mut s, t, Clip::audio(id, "c", src, off, start, len));
|
||||
ids.push(id);
|
||||
}
|
||||
(Engine::new(s), t, ids)
|
||||
}
|
||||
|
||||
fn bounds(e: &Engine, t: TrackId) -> Vec<(Samples, Samples, Samples)> {
|
||||
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end(), c.source_offset())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_file_boundaries_stops_at_the_neighbours() {
|
||||
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]);
|
||||
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_fill_selection_stops_at_the_neighbours() {
|
||||
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]);
|
||||
e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[1].0], "start": 5_000, "end": 45_000})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_without_neighbours_expand_fully() {
|
||||
let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]);
|
||||
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[0].0]})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(0, 60_000, 0)]);
|
||||
let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]);
|
||||
e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[0].0], "start": 5_000, "end": 45_000})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(5_000, 45_000, 5_000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_file_boundaries_keeps_an_existing_crossfade() {
|
||||
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]);
|
||||
e.execute("edit.fades_create", &json!({"tracks": [t], "start": 9_500, "end": 10_500})).unwrap();
|
||||
let faded = bounds(&e, t);
|
||||
assert_eq!((faded[0].1, faded[1].0), (10_500, 9_500), "the fixture needs an overlapping crossfade");
|
||||
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![faded[0], (9_500, 60_000, 9_500)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Re
|
|||
Ok(json!({"lanes": lanes_n, "length": r.len()}))
|
||||
}
|
||||
|
||||
fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
pub(super) fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "edit.copy_clip_gain";
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let r = nonempty_range(e, p, id)?;
|
||||
|
|
@ -1044,6 +1044,22 @@ mod tests {
|
|||
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_clip_gain_can_be_pasted() {
|
||||
let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000), (4000, 1000)]);
|
||||
e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap();
|
||||
e.execute("clip.gain", &json!({"clip": ids[2].0, "db": -12.0})).unwrap();
|
||||
// An older copy sits in the clipboard: the paste must get the cut gain, not this one.
|
||||
e.execute("edit.copy_clip_gain", &json!({"start": 4000, "end": 5000})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "clips": [ids[0].0], "start": 0, "end": 1000})).unwrap();
|
||||
e.execute("edit.cut_clip_gain", &json!({})).unwrap();
|
||||
let src = e.session().track(t).unwrap().clips()[0].clone();
|
||||
assert!(clip_db(&src, 500).abs() < 1e-3, "cut resets the source: {src:?}");
|
||||
e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap();
|
||||
let c = e.session().track(t).unwrap().clips()[1].clone();
|
||||
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_to_new_playlist_clears_main() {
|
||||
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
|
||||
|
|
|
|||
|
|
@ -314,7 +314,8 @@ fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let at = position_param(e, "markers.edit", p, "at")?;
|
||||
let s = e.session_mut();
|
||||
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
|
||||
if let Some(n) = p.get("rename").and_then(Value::as_str) {
|
||||
// `name` is the documented new name; when it was the selector it already matches, so this is a no-op.
|
||||
if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) {
|
||||
m.name = n.to_string();
|
||||
}
|
||||
if let Some(c) = str_param(p, "comments") {
|
||||
|
|
@ -376,4 +377,28 @@ mod tests {
|
|||
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
fn marker_name(e: &crate::Engine, id: u64) -> String {
|
||||
e.session().markers.iter().find(|m| m.id.0 == id).map(|m| m.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `markers.edit` documents `name`: with `id` or `number` selecting the location, it is the new name.
|
||||
#[test]
|
||||
fn edit_marker_renames_with_documented_name() {
|
||||
let mut e = crate::Engine::default();
|
||||
let verse = e.execute("markers.add", &json!({"name": "Verse", "at": 1000})).unwrap();
|
||||
let outro = e.execute("markers.add", &json!({"name": "Outro", "at": 3000})).unwrap();
|
||||
let (verse_id, outro_id) = (verse["id"].as_u64().unwrap(), outro["id"].as_u64().unwrap());
|
||||
|
||||
e.execute("markers.edit", &json!({"id": verse_id, "name": "Chorus"})).unwrap();
|
||||
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Outro".into()));
|
||||
|
||||
e.execute("markers.edit", &json!({"number": outro["number"], "name": "Bridge"})).unwrap();
|
||||
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Bridge".into()));
|
||||
|
||||
e.execute("markers.edit", &json!({"name": "Bridge", "comments": "last"})).unwrap();
|
||||
let s = e.session();
|
||||
let bridge = s.markers.iter().find(|m| m.id.0 == outro_id).unwrap();
|
||||
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!(
|
||||
"midi.new_clip",
|
||||
"New MIDI Clip",
|
||||
[],
|
||||
["Clip"],
|
||||
None,
|
||||
"{track, start?, end?|length?} (default: the edit selection, or 1 bar)",
|
||||
has_tracks,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Set Plugin Parameters",
|
||||
[],
|
||||
None,
|
||||
"{track?, slot, params: {id: value}, preset?: name}",
|
||||
"{track?, slot, params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)",
|
||||
has_selection,
|
||||
insert_params
|
||||
),
|
||||
|
|
@ -360,7 +360,8 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
/// Apply many parameters at once (preset recall); unknown ids are ignored, values clamped.
|
||||
/// Apply many parameters at once (preset recall); values are clamped. Unknown ids and
|
||||
/// non-numeric values are errors, not silent no-ops: a typo must not look like success.
|
||||
fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (t, slot) = track_slot(e, p, "mix.insert_params", INSERT_SLOTS)?;
|
||||
let obj = p.get("params").and_then(Value::as_object).cloned().ok_or_else(|| bad("mix.insert_params", "`params` must be an object"))?;
|
||||
|
|
@ -372,11 +373,29 @@ fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
.and_then(Option::as_mut)
|
||||
.ok_or_else(|| bad("mix.insert_params", "empty slot"))?;
|
||||
let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_params", "unknown plugin"))?;
|
||||
let mut unknown: Vec<&str> = Vec::new();
|
||||
let mut non_numeric: Vec<&str> = Vec::new();
|
||||
for (k, v) in &obj {
|
||||
if !info.params.iter().any(|x| x.id == k.as_str()) {
|
||||
unknown.push(k.as_str());
|
||||
} else if v.as_f64().is_none_or(|f| !f.is_finite()) {
|
||||
non_numeric.push(k.as_str());
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
unknown.sort_unstable();
|
||||
return Err(bad("mix.insert_params", format!("`{}` has no parameter(s): {}", info.id, unknown.join(", "))));
|
||||
}
|
||||
if !non_numeric.is_empty() {
|
||||
non_numeric.sort_unstable();
|
||||
return Err(bad("mix.insert_params", format!("parameter(s) need a numeric value: {}", non_numeric.join(", "))));
|
||||
}
|
||||
let mut n = 0;
|
||||
for (k, v) in obj {
|
||||
let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) else { continue };
|
||||
ins.params.insert(k, (f as f32).clamp(pi.min, pi.max));
|
||||
n += 1;
|
||||
if let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) {
|
||||
ins.params.insert(k, (f as f32).clamp(pi.min, pi.max));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if let Some(name) = preset {
|
||||
ins.preset = name;
|
||||
|
|
@ -802,6 +821,42 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_params_rejects_unknown_and_non_numeric_keys() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let t = e.session().tracks[0].id.0;
|
||||
e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap();
|
||||
fn gain(e: &Engine) -> f32 {
|
||||
e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"]
|
||||
}
|
||||
|
||||
// A typo names no parameter: the call fails and says which key.
|
||||
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err();
|
||||
assert!(err.to_string().contains("gain_db"), "{err}");
|
||||
|
||||
// Mixed keys fail as a whole and change nothing.
|
||||
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err();
|
||||
assert!(err.to_string().contains("width"), "{err}");
|
||||
assert_eq!(gain(&e), 0.0);
|
||||
|
||||
// Non-numeric and non-finite values are rejected too.
|
||||
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err());
|
||||
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err());
|
||||
assert_eq!(gain(&e), 0.0);
|
||||
|
||||
// Correct keys still apply, clamped to the parameter range.
|
||||
let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap();
|
||||
assert_eq!(r["set"], 1);
|
||||
assert_eq!(gain(&e), -14.0);
|
||||
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap();
|
||||
assert_eq!(gain(&e), 24.0);
|
||||
|
||||
// Empty params with a preset name still recall (the rename path).
|
||||
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap();
|
||||
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeting_a_send_keeps_its_settings() {
|
||||
let mut e = Engine::default();
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
|
|||
}
|
||||
};
|
||||
for f in fmts {
|
||||
if let Ok(x) = soundcraft_time::parse_position(body, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) {
|
||||
if let Ok(x) = s.parse_position(body, f) {
|
||||
return Ok(Some(x));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let at = position_param(e, "session.format_time", p, "at")?.unwrap_or(0);
|
||||
let s = e.session();
|
||||
let f = str_param(p, "format").and_then(soundcraft_time::TimeFormat::from_id).unwrap_or(s.edit.main_counter);
|
||||
Ok(json!({"text": soundcraft_time::format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)}))
|
||||
Ok(json!({"text": s.format_position(at, f)}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let r = soundcraft_time::Range::new(0, end);
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
// Each track keeps its own length (its material plus a second), as before.
|
||||
let tr = crate::io::bounded_to_track(e.session(), t, r);
|
||||
if crate::io::consolidate(e, id, t, tr)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -318,7 +320,7 @@ fn current_feet_frames(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let at = s.edit.selection.start.max(0);
|
||||
let frames_at = s.frame_rate.frames_at(at, s.sample_rate);
|
||||
let start = ft * 16 + fr - frames_at;
|
||||
s.edit.values.insert("feet_frames.start".into(), start as f64);
|
||||
s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64);
|
||||
Ok(json!({"feet_frames_start": start}))
|
||||
}
|
||||
|
||||
|
|
@ -556,8 +558,7 @@ mod tests {
|
|||
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
|
||||
e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap();
|
||||
let s = e.session();
|
||||
let shown =
|
||||
soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode);
|
||||
assert_eq!(shown, "01:00:00:00");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"track": t}))
|
||||
}),
|
||||
cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))),
|
||||
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, |_, _| Ok(json!({}))),
|
||||
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, super::track_more::clear_trim),
|
||||
cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click),
|
||||
cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)),
|
||||
cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)),
|
||||
|
|
@ -128,7 +128,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}),
|
||||
cmd!("track.vca", "Assign to VCA", [], None, "{tracks?, vca: VCA track id|name | null}", has_selection, |e, p| {
|
||||
let tracks = tracks_required(e, "track.vca", p)?;
|
||||
let vca = track_param(e, "track.vca", p, "vca")?;
|
||||
let vca = match p.get("vca") {
|
||||
Some(Value::Null) => None,
|
||||
_ => track_param(e, "track.vca", p, "vca")?,
|
||||
};
|
||||
if let Some(v) = vca
|
||||
&& e.session().track(v).is_none_or(|t| t.kind != TrackKind::Vca)
|
||||
{
|
||||
|
|
@ -680,4 +683,24 @@ mod tests {
|
|||
assert!(matches!(kick.mixer.output, Route::Bus(_)));
|
||||
assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vca_null_clears_the_assignment() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"name": "Member"})).unwrap();
|
||||
e.execute("track.new", &json!({"kind": "vca", "name": "Master"})).unwrap();
|
||||
let (member, master) = (e.session().tracks[0].id, e.session().tracks[1].id);
|
||||
let vca = |e: &Engine| e.session().track(member).unwrap().mixer.vca;
|
||||
e.execute("track.vca", &json!({"tracks": [member.0], "vca": master.0})).unwrap();
|
||||
assert_eq!(vca(&e), Some(master.0));
|
||||
// #237: null is documented as "no VCA"; anything else that is not a VCA is still an error.
|
||||
for invalid in [json!(true), json!(999), json!("Missing"), json!(member.0)] {
|
||||
assert!(e.execute("track.vca", &json!({"tracks": [member.0], "vca": invalid})).is_err());
|
||||
assert_eq!(vca(&e), Some(master.0));
|
||||
}
|
||||
e.execute("track.vca", &json!({"tracks": [member.0], "vca": null})).unwrap();
|
||||
assert_eq!(vca(&e), None);
|
||||
assert!(e.undo());
|
||||
assert_eq!(vca(&e), Some(master.0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -447,6 +447,21 @@ fn coalesce_trim(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"tracks": n}))
|
||||
}
|
||||
|
||||
pub(super) fn clear_trim(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.clear_trim_automation", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
let before = tr.automation.len();
|
||||
tr.automation.retain(|l| l.param != trim_param());
|
||||
if tr.automation.len() < before {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"tracks": n}))
|
||||
}
|
||||
|
||||
fn coalesce_vca(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "track.coalesce_vca";
|
||||
let picked = tracks_param(e, id, p)?;
|
||||
|
|
@ -649,6 +664,26 @@ mod tests {
|
|||
assert!(tr.lane(&trim_param()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_trim_automation_removes_only_trim() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 2})).unwrap();
|
||||
let ids: Vec<TrackId> = e.session().tracks.iter().map(|t| t.id).collect();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
e.execute("automation.set_point", &json!({"track": a.0, "param": "volume", "at": 0, "value": -3.0})).unwrap();
|
||||
e.execute("track.trim_automation_write", &json!({"tracks": [a.0, b.0], "start": 0, "end": 4800, "db": -12.0})).unwrap();
|
||||
let has_trim = |e: &Engine, t: TrackId| e.session().track(t).unwrap().lane(&trim_param()).is_some_and(|l| !l.points.is_empty());
|
||||
e.execute("edit.select", &json!({"tracks": [a.0]})).unwrap();
|
||||
e.execute("track.clear_trim_automation", &json!({})).unwrap();
|
||||
assert!(!has_trim(&e, a), "the selected track loses its trim");
|
||||
assert!(has_trim(&e, b), "other tracks keep theirs");
|
||||
assert_eq!(e.session().track(a).unwrap().lane(&AutoParam::Volume).unwrap().points.len(), 1);
|
||||
e.execute("track.clear_trim_automation", &json!({"tracks": [b.0]})).unwrap();
|
||||
assert!(!has_trim(&e, b), "an explicit track is cleared too");
|
||||
assert!(e.undo());
|
||||
assert!(has_trim(&e, b), "undo brings the trim back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_and_recent_playlists() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
|
|||
n += 1;
|
||||
}
|
||||
}
|
||||
pl.sort();
|
||||
n
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
use crate::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId, Track, TrackId};
|
||||
use soundcraft_time::{TimeFormat, format_position};
|
||||
use soundcraft_time::TimeFormat;
|
||||
|
||||
fn clip_json(e: &Engine, c: &Clip) -> Value {
|
||||
let s = e.session();
|
||||
let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let fmt = |x| s.format_position(x, TimeFormat::MinSecs);
|
||||
let mut v = json!({
|
||||
"id": c.id, "name": c.name, "start": c.start, "end": c.end(), "length": c.length,
|
||||
"start_time": fmt(c.start), "gain_db": c.gain_db, "muted": c.muted,
|
||||
|
|
@ -110,7 +110,7 @@ pub fn clip(e: &Engine, id: ClipId) -> Option<Value> {
|
|||
/// Plain-text session report (File › Export › Session Info as Text).
|
||||
pub fn session_text(e: &Engine) -> String {
|
||||
let s = e.session();
|
||||
let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let fmt = |x| s.format_position(x, TimeFormat::MinSecs);
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n",
|
||||
s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(),
|
||||
|
|
|
|||
|
|
@ -387,18 +387,27 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
|
|||
keep.len()
|
||||
}
|
||||
|
||||
/// Consolidate the selection on a track into one new clip backed by a rendered source.
|
||||
pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
|
||||
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
|
||||
/// whole range, silence included; a range too long to render is refused rather than shortened.
|
||||
pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result<bool> {
|
||||
let s = e.session();
|
||||
if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) {
|
||||
let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) };
|
||||
let start = r.start.max(0);
|
||||
let len = r.end.saturating_sub(start);
|
||||
if len <= 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let r = bounded_to_track(s, t, r);
|
||||
if r.is_empty() {
|
||||
return Ok(false);
|
||||
let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1);
|
||||
if usize::try_from(len).map_or(true, |n| n > max) {
|
||||
let secs = max as f64 / s.sample_rate.as_f64();
|
||||
return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name)));
|
||||
}
|
||||
let r = Range::new(start, start + len);
|
||||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||||
let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str()));
|
||||
if audio.iter().any(|ch| ch.len() as i64 != len) {
|
||||
return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into()));
|
||||
}
|
||||
let name = format!("{}_consolidated", tr.name);
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, &name, buf, None, FileFormat::Wav);
|
||||
|
|
@ -1139,7 +1148,10 @@ mod save_tests {
|
|||
open_session(&mut missing, &path("A/A.scraft")).unwrap();
|
||||
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
|
||||
let mut r = Engine::default();
|
||||
assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]);
|
||||
assert_eq!(
|
||||
open_session(&mut r, &path("B/Moved.scraft")).unwrap(),
|
||||
vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
//! Consolidate makes one clip per track that covers exactly the edit selection (#164).
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_engine::{Engine, EngineError};
|
||||
use soundcraft_model::{Clip, TrackId};
|
||||
use soundcraft_time::Range;
|
||||
|
||||
fn stereo_tracks(n: u64) -> Engine {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": n, "format": "stereo"})).unwrap();
|
||||
e.session_mut().edit.selected_tracks = e.session().tracks.iter().map(|t| t.id).collect();
|
||||
e
|
||||
}
|
||||
|
||||
fn sr(e: &Engine) -> i64 {
|
||||
e.session().sample_rate.samples(1.0)
|
||||
}
|
||||
|
||||
/// Put a constant-level clip of `secs` seconds at the start of track `i`.
|
||||
fn add_clip(e: &mut Engine, i: usize, secs: i64) -> TrackId {
|
||||
let frames = usize::try_from(secs * sr(e)).unwrap();
|
||||
let t = e.session().tracks[i].id;
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; frames]; 2] };
|
||||
let src = soundcraft_engine::io::add_source(e.session_mut(), "tone", buf, None, FileFormat::Wav);
|
||||
let id = e.session_mut().new_clip_id();
|
||||
soundcraft_engine::edit::place_clip(e.session_mut(), t, Clip::audio(id, "tone", src, 0, 0, frames as i64));
|
||||
t
|
||||
}
|
||||
|
||||
fn clips(e: &Engine, i: usize) -> Vec<(i64, i64)> {
|
||||
e.session().tracks[i].clips().iter().map(|c| (c.start, c.end())).collect()
|
||||
}
|
||||
|
||||
/// Frames in the source behind the only clip on track `i`, and its first sample.
|
||||
fn source_of_only_clip(e: &Engine, i: usize) -> (usize, f32) {
|
||||
let s = e.session();
|
||||
let c = &s.tracks[i].clips()[0];
|
||||
let audio = s.pool.get(c.source().unwrap()).unwrap();
|
||||
(audio.buffer.frames(), audio.buffer.channels[0][0])
|
||||
}
|
||||
|
||||
fn assert_each_track_has(e: &Engine, start: i64, end: i64) {
|
||||
for (i, t) in e.session().tracks.iter().enumerate() {
|
||||
assert_eq!(clips(e, i), vec![(start, end)], "track {}", t.name);
|
||||
assert_eq!(source_of_only_clip(e, i).0 as i64, end - start, "track {}", t.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_silence_from_zero_covers_the_whole_selection() {
|
||||
let mut e = stereo_tracks(5);
|
||||
let sr = sr(&e);
|
||||
e.session_mut().edit.selection = Range::new(0, 5 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
assert_each_track_has(&e, 0, 5 * sr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_silence_later_in_the_timeline_covers_the_whole_selection() {
|
||||
let mut e = stereo_tracks(5);
|
||||
let sr = sr(&e);
|
||||
e.session_mut().edit.selection = Range::new(2 * sr, 7 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
assert_each_track_has(&e, 2 * sr, 7 * sr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_past_the_end_of_a_clip_keeps_the_trailing_silence() {
|
||||
let mut e = stereo_tracks(1);
|
||||
let sr = sr(&e);
|
||||
add_clip(&mut e, 0, 1);
|
||||
e.session_mut().edit.selection = Range::new(0, 5 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
assert_each_track_has(&e, 0, 5 * sr);
|
||||
assert_eq!(source_of_only_clip(&e, 0).1, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_track_consolidate_is_one_undo_step_and_fills_empty_tracks() {
|
||||
let mut e = stereo_tracks(3);
|
||||
let sr = sr(&e);
|
||||
add_clip(&mut e, 1, 2);
|
||||
let before = e.session().clone();
|
||||
let undo_depth = e.undo_history().len();
|
||||
e.session_mut().edit.selection = Range::new(sr, 4 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
// The clip on track 2 is split at the selection start; the head stays put.
|
||||
assert_eq!(clips(&e, 0), vec![(sr, 4 * sr)]);
|
||||
assert_eq!(clips(&e, 1), vec![(0, sr), (sr, 4 * sr)]);
|
||||
assert_eq!(clips(&e, 2), vec![(sr, 4 * sr)]);
|
||||
assert_eq!(e.undo_history().len(), undo_depth + 1);
|
||||
assert!(e.undo());
|
||||
for (a, b) in e.session().tracks.iter().zip(before.tracks.iter()) {
|
||||
assert_eq!(a.clips(), b.clips(), "track {}", a.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_too_long_to_render_is_refused_not_shortened() {
|
||||
let mut e = stereo_tracks(2);
|
||||
add_clip(&mut e, 0, 1);
|
||||
let before: Vec<_> = (0..2).map(|i| clips(&e, i)).collect();
|
||||
// A stereo track renders at most MAX_RENDER_SAMPLES / 2 frames (about 93 minutes at 48 kHz).
|
||||
let max = i64::try_from(soundcraft_mix::MAX_RENDER_SAMPLES / 2).unwrap();
|
||||
let err = e.execute("edit.consolidate", &json!({"start": 0, "end": max + 1})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams(..)), "{err}");
|
||||
assert!(err.to_string().contains("too long"), "{err}");
|
||||
let after: Vec<_> = (0..2).map(|i| clips(&e, i)).collect();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_dpe_still_consolidates_each_track_to_its_own_material() {
|
||||
let mut e = stereo_tracks(2);
|
||||
let sr = sr(&e);
|
||||
let a = add_clip(&mut e, 0, 1);
|
||||
let b = add_clip(&mut e, 1, 3);
|
||||
e.execute("options.prepare_dpe_tracks", &json!({"tracks": [a.0, b.0]})).unwrap();
|
||||
// Unchanged from before #164: each track runs to its last clip plus a second, capped at the session end.
|
||||
assert_eq!(clips(&e, 0), vec![(0, 2 * sr)]);
|
||||
assert_eq!(clips(&e, 1), vec![(0, 3 * sr)]);
|
||||
assert_eq!(source_of_only_clip(&e, 0), (2 * sr as usize, 0.25));
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, model::Session};
|
||||
use soundcraft_time::{FrameRate, SampleRate};
|
||||
|
||||
fn feet_frames(engine: &mut Engine, at: i64) -> serde_json::Value {
|
||||
engine.execute("session.format_time", &json!({"at": at, "format": "feet_frames"})).unwrap()["text"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_feet_frames_sets_the_reading_at_the_insertion_point() {
|
||||
let mut session = Session::new("Feet frames", SampleRate::HZ_48000);
|
||||
session.frame_rate = FrameRate::Fps30;
|
||||
let mut engine = Engine::new(session);
|
||||
engine.execute("edit.select", &json!({"start": 48_000})).unwrap();
|
||||
assert_eq!(feet_frames(&mut engine, 48_000), "1+14");
|
||||
|
||||
let response = engine.execute("setup.current_feet_frames", &json!({"feet_frames": "12+08"})).unwrap();
|
||||
assert_eq!(response, json!({"feet_frames_start": 170}));
|
||||
|
||||
assert_eq!(feet_frames(&mut engine, 48_000), "12+08");
|
||||
// 30 frames later, at 16 frames per foot.
|
||||
assert_eq!(feet_frames(&mut engine, 96_000), "14+06");
|
||||
assert_eq!(engine.session().edit.selection.start, 48_000);
|
||||
let samples = engine.execute("session.format_time", &json!({"at": 48_000, "format": "samples"})).unwrap();
|
||||
assert_eq!(samples["text"], "48000");
|
||||
|
||||
// Typed positions go through the same reference.
|
||||
engine.execute("edit.select", &json!({"start": "feet_frames:14+06"})).unwrap();
|
||||
assert_eq!(engine.session().edit.selection.start, 96_000);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_time::Range;
|
||||
|
||||
fn render(e: &Engine) -> Vec<Vec<f32>> {
|
||||
soundcraft_mix::render_range(e.session(), Range::new(0, 72_000), 512)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_track_offset_moves_the_notes_like_moving_the_clip() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Timing", "instrument": "subtractive_synth"})).unwrap();
|
||||
let track = r["tracks"][0].as_u64().unwrap();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 12_000, "length": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
let baseline = render(&e);
|
||||
assert!(baseline.iter().flatten().any(|x| x.abs() > 1e-6), "the fixture must make sound");
|
||||
|
||||
e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 24_000})).unwrap();
|
||||
let offset = render(&e);
|
||||
assert!(offset != baseline, "the offset must change the render");
|
||||
|
||||
e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 0})).unwrap();
|
||||
assert!(render(&e) == baseline, "a zero offset plays as before");
|
||||
|
||||
// The offset shifts playback by exactly as much as moving the clip would.
|
||||
e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap();
|
||||
assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples");
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, SourceId, TrackKind};
|
||||
|
||||
#[test]
|
||||
fn trim_to_selection_keeps_clips_sorted() {
|
||||
let mut session = Session::default();
|
||||
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let long = session.new_clip_id();
|
||||
let short = session.new_clip_id();
|
||||
session.track_mut(track).unwrap().playlist_mut().unwrap().clips =
|
||||
vec![Clip::audio(long, "long", SourceId(1), 0, 0, 30), Clip::audio(short, "short", SourceId(1), 0, 5, 5)];
|
||||
let mut engine = Engine::new(session);
|
||||
engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 15, "end": 20})).unwrap();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(short, 5, 5), (long, 15, 5)]);
|
||||
engine.undo();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
|
||||
}
|
||||
|
|
@ -251,6 +251,9 @@ impl DelayLine {
|
|||
}
|
||||
self.idx = (start + frames) % self.len.max(1);
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
self.bufs.iter_mut().for_each(|b| b.fill(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip Effects: an EQ and a compressor per clip, configured from `edit.values`
|
||||
|
|
@ -344,6 +347,13 @@ impl SlotCtx<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when the host handed over an instance for this slot. It replaces whatever the slot
|
||||
/// holds, even an instance of the same plugin: the host made it because it no longer tracks
|
||||
/// the old one (it was removed and re-added between two renders), and drives its editor.
|
||||
fn waiting(&self, t: TrackId, idx: usize) -> bool {
|
||||
self.external && self.adopt.contains_key(&(t, idx))
|
||||
}
|
||||
|
||||
/// A new instance for `ins` on track `t`, slot `idx`, prepared for `ch` channels.
|
||||
fn make(&mut self, t: TrackId, idx: usize, ins: &soundcraft_model::Insert, ch: usize) -> Option<PluginSlot> {
|
||||
let third = is_third_party(&ins.plugin);
|
||||
|
|
@ -474,6 +484,9 @@ impl MixEngine {
|
|||
i.plugin.reset();
|
||||
}
|
||||
s.held.clear();
|
||||
s.align.clear();
|
||||
s.align_pre.clear();
|
||||
s.out_extra.clear();
|
||||
}
|
||||
self.meters.clear();
|
||||
self.main_meter = StripMeter::default();
|
||||
|
|
@ -511,7 +524,7 @@ impl MixEngine {
|
|||
strip.plugins.resize_with(t.mixer.inserts.len(), || None);
|
||||
for (idx, (slot, ins)) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()).enumerate() {
|
||||
match ins {
|
||||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) => {
|
||||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) || cx.waiting(t.id, idx) => {
|
||||
let old = slot.take();
|
||||
cx.retire(old);
|
||||
*slot = cx.make(t.id, idx, i, ch);
|
||||
|
|
@ -525,7 +538,9 @@ impl MixEngine {
|
|||
}
|
||||
let want = t.instrument.as_ref();
|
||||
let ich = ch.max(2);
|
||||
if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich)) {
|
||||
if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich))
|
||||
|| (want.is_some() && cx.waiting(t.id, INSTRUMENT_SLOT))
|
||||
{
|
||||
let old = strip.instrument.take();
|
||||
cx.retire(old);
|
||||
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
|
||||
|
|
@ -763,8 +778,7 @@ impl MixEngine {
|
|||
let m = build_matrix(src_fmt, src.len(), dst.fmt, dst.buf.len(), &PanSpec { stereo, surround }, law);
|
||||
apply_matrix(src, frames, &mut dst.buf, &m, gain);
|
||||
}
|
||||
let spec = PanSpec { stereo: [pan_at(t, 0, pos), pan_at(t, 1, pos)], surround: t.mixer.surround.as_ref() };
|
||||
match &t.mixer.output {
|
||||
let src: &[Vec<f32>] = match &t.mixer.output {
|
||||
Route::Main if strip.out_extra.len > 0 => {
|
||||
if strip.outbuf.len() != strip.buf.len() {
|
||||
strip.outbuf = vec![Vec::new(); strip.buf.len()];
|
||||
|
|
@ -774,20 +788,25 @@ impl MixEngine {
|
|||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||||
}
|
||||
strip.out_extra.process(&mut strip.outbuf, frames);
|
||||
let m = build_matrix(src_fmt, strip.outbuf.len(), self.main_fmt, self.main.len(), &spec, law);
|
||||
apply_matrix(&strip.outbuf, frames, &mut self.main, &m, 1.0);
|
||||
&strip.outbuf
|
||||
}
|
||||
Route::Main => {
|
||||
let m = build_matrix(src_fmt, strip.buf.len(), self.main_fmt, self.main.len(), &spec, law);
|
||||
apply_matrix(&strip.buf, frames, &mut self.main, &m, 1.0);
|
||||
}
|
||||
Route::Bus(b) => {
|
||||
if let Some(dst) = self.busses.get_mut(b) {
|
||||
let m = build_matrix(src_fmt, strip.buf.len(), dst.fmt, dst.buf.len(), &spec, law);
|
||||
apply_matrix(&strip.buf, frames, &mut dst.buf, &m, 1.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => &strip.buf,
|
||||
};
|
||||
let (dst, dst_fmt) = match &t.mixer.output {
|
||||
Route::Main => (&mut self.main, self.main_fmt),
|
||||
Route::Bus(b) => match self.busses.get_mut(b) {
|
||||
Some(d) => (&mut d.buf, d.fmt),
|
||||
None => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let mut start = 0;
|
||||
while start < frames {
|
||||
let (at, end) = pan_segment(t, pos, start, frames);
|
||||
let spec = PanSpec { stereo: [pan_at(t, 0, at), pan_at(t, 1, at)], surround: t.mixer.surround.as_ref() };
|
||||
let m = build_matrix(src_fmt, src.len(), dst_fmt, dst.len(), &spec, law);
|
||||
apply_matrix_span(src, start..end, dst, &m, 1.0);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1305,9 +1324,27 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
|
|||
}
|
||||
}
|
||||
|
||||
/// The track's MIDI Track Offset (`midi.offset.<id>` in the edit values), in samples. Looked up
|
||||
/// without formatting the key, since this runs on the audio thread every block.
|
||||
fn midi_offset(s: &Session, t: &Track) -> i64 {
|
||||
const PREFIX: &str = "midi.offset.";
|
||||
s.edit
|
||||
.values
|
||||
.range::<str, _>((std::ops::Bound::Included(PREFIX), std::ops::Bound::Unbounded))
|
||||
.take_while(|(k, _)| k.starts_with(PREFIX))
|
||||
.find(|(k, _)| k.get(PREFIX.len()..).and_then(|id| id.parse::<u64>().ok()) == Some(t.id.0))
|
||||
.map(|(_, v)| *v)
|
||||
.filter(|v| v.is_finite())
|
||||
.map_or(0, |v| v as i64)
|
||||
}
|
||||
|
||||
/// Send note on/offs for MIDI clips overlapping the block.
|
||||
fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {
|
||||
let sr = s.sample_rate;
|
||||
// MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so
|
||||
// the block looks that far back into the clips.
|
||||
let offset = midi_offset(s, t);
|
||||
let pos = pos.saturating_sub(offset);
|
||||
let end = pos.saturating_add(frames as i64);
|
||||
for clip in t.clips() {
|
||||
if clip.muted {
|
||||
|
|
@ -1458,6 +1495,23 @@ fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
|
|||
base
|
||||
}
|
||||
|
||||
/// Pan automation is read on a grid of [`PAN_STEP`] timeline samples rather than once per block,
|
||||
/// so playback (callback-sized blocks) and bounces (1024-frame blocks) pan identically.
|
||||
const PAN_STEP: Samples = 64;
|
||||
|
||||
/// The position to read the pan at for the frames from `start` of the block at `pos`, and the end
|
||||
/// of the frames that share it. Without pan automation the whole block shares one pan.
|
||||
fn pan_segment(t: &Track, pos: Samples, start: usize, frames: usize) -> (Samples, usize) {
|
||||
let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(l.param, AutoParam::Pan(_)) && !l.points.is_empty());
|
||||
if !automated {
|
||||
return (pos, frames);
|
||||
}
|
||||
let here = pos.saturating_add(start as i64);
|
||||
let at = here.div_euclid(PAN_STEP).saturating_mul(PAN_STEP);
|
||||
let left = usize::try_from(at.saturating_add(PAN_STEP).saturating_sub(here)).unwrap_or(1).max(1);
|
||||
(at, start.saturating_add(left).min(frames))
|
||||
}
|
||||
|
||||
fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
|
||||
|
|
@ -1707,6 +1761,11 @@ fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: u
|
|||
|
||||
/// `dst[d] += Σ_s src[s] × gain × m[s][d]` for every destination channel (no allocation).
|
||||
fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matrix, gain: f32) {
|
||||
apply_matrix_span(src, 0..frames, dst, m, gain);
|
||||
}
|
||||
|
||||
/// [`apply_matrix`] over the frames in `span` only.
|
||||
fn apply_matrix_span(src: &[Vec<f32>], span: std::ops::Range<usize>, dst: &mut [Vec<f32>], m: &Matrix, gain: f32) {
|
||||
let ns = src.len().min(MAX_CHANNELS);
|
||||
for (d, out) in dst.iter_mut().enumerate().take(MAX_CHANNELS) {
|
||||
let mut idx = [(0usize, 0.0f32); MAX_CHANNELS];
|
||||
|
|
@ -1724,7 +1783,7 @@ fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matri
|
|||
if taps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, o) in out.iter_mut().enumerate().take(frames) {
|
||||
for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) {
|
||||
let mut acc = 0.0f32;
|
||||
for &(s, g) in taps {
|
||||
acc += src.get(s).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain * g;
|
||||
|
|
@ -2063,6 +2122,22 @@ mod tests {
|
|||
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_automation_does_not_depend_on_the_block_size() {
|
||||
// Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244).
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
{
|
||||
let l = s.track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0));
|
||||
l.set_point(0, -1.0);
|
||||
l.set_point(480, 1.0);
|
||||
}
|
||||
let bounce = render_range(&s, Range::new(0, 4800), 1024);
|
||||
assert!(bounce[0][600].abs() < 1e-4 && bounce[1][600] > 0.49, "{} {}", bounce[0][600], bounce[1][600]);
|
||||
for block in [512, 100, 37] {
|
||||
assert_eq!(render_range(&s, Range::new(0, 4800), block), bounce, "block {block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_and_clip_gain_apply() {
|
||||
let (mut s, t) = session_with_dc(1.0, 48_000);
|
||||
|
|
@ -2218,6 +2293,31 @@ mod tests {
|
|||
assert!(eng.drain_plugins().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
|
||||
// The host removed and re-added the plugin, and both session updates reached the audio
|
||||
// thread in one go: the slot still holds the old instance (same id, same channels), but
|
||||
// the host made a new one and drives its editor. The new one must win, or the editor
|
||||
// talks to an instance that was retired ("the plugin is gone").
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.fake"));
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
eng.set_external_instances(true);
|
||||
let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let make = |blob: &[u8]| {
|
||||
let mut gain = soundcraft_dsp::create("gain").unwrap();
|
||||
gain.prepare(48_000.0, 512, 1);
|
||||
Box::new(Stateful { inner: gain, blob: blob.to_vec(), sets: Arc::clone(&sets) })
|
||||
};
|
||||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"old") }]);
|
||||
render_block(&mut eng, &s, 0);
|
||||
assert_eq!(eng.capture_states(), vec![(t, 0, b"old".to_vec())]);
|
||||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"new") }]);
|
||||
render_block(&mut eng, &s, 512);
|
||||
assert_eq!(eng.capture_states(), vec![(t, 0, b"new".to_vec())]);
|
||||
assert_eq!(eng.take_retired().len(), 1, "the old instance is retired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_state_comes_first_then_session_params() {
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||||
|
|
@ -2287,6 +2387,27 @@ mod tests {
|
|||
assert_ne!(argmax(&out[0]), argmax(&out[1]));
|
||||
}
|
||||
|
||||
/// A reset (stop, locate) must not leave the previous position's audio in the alignment delay.
|
||||
#[test]
|
||||
fn reset_clears_delay_compensation_history() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
s.edit.click = false;
|
||||
s.track_mut(t).unwrap().mixer.pan = vec![1.0];
|
||||
let latent = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut comp = Insert::new("compressor");
|
||||
comp.params.insert("lookahead".into(), 5.0);
|
||||
s.track_mut(latent).unwrap().mixer.inserts[0] = Some(comp);
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||||
for b in 0..9 {
|
||||
eng.render(&s, b * 512, 512, &mut out);
|
||||
}
|
||||
assert!(eng.latency > 0 && out[1][500] > 0.3, "the fixture plays through the alignment delay");
|
||||
eng.reset();
|
||||
eng.render(&s, 1_000_000, 512, &mut out);
|
||||
assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset");
|
||||
}
|
||||
|
||||
fn impulse_at(ch: &[f32]) -> Vec<usize> {
|
||||
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,21 +204,28 @@ impl AutomationLane {
|
|||
|
||||
/// Thin: remove points whose removal changes the curve by less than `tolerance`.
|
||||
pub fn thin(&mut self, tolerance: f32) -> usize {
|
||||
if self.points.len() < 3 {
|
||||
if self.points.len() < 3 || tolerance.is_nan() {
|
||||
return 0;
|
||||
}
|
||||
let tolerance = f64::from(tolerance);
|
||||
let mut keep = vec![true; self.points.len()];
|
||||
let mut last = 0usize;
|
||||
// Slopes from the last kept point that keep every point removed since then within `tolerance`.
|
||||
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
for i in 1..self.points.len() - 1 {
|
||||
let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue };
|
||||
let span = (c.at - a.at) as f32;
|
||||
let interp = if span > 0.0 { a.value + (c.value - a.value) * ((b.at - a.at) as f32 / span) } else { a.value };
|
||||
if (interp - b.value).abs() <= tolerance {
|
||||
let dt = b.at.saturating_sub(a.at) as f64;
|
||||
let rise = f64::from(b.value) - f64::from(a.value);
|
||||
lo = lo.max((rise - tolerance) / dt);
|
||||
hi = hi.min((rise + tolerance) / dt);
|
||||
let slope = (f64::from(c.value) - f64::from(a.value)) / c.at.saturating_sub(a.at) as f64;
|
||||
if (lo..=hi).contains(&slope) {
|
||||
if let Some(k) = keep.get_mut(i) {
|
||||
*k = false;
|
||||
}
|
||||
} else {
|
||||
last = i;
|
||||
(lo, hi) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
}
|
||||
}
|
||||
let before = self.points.len();
|
||||
|
|
@ -279,6 +286,23 @@ mod tests {
|
|||
assert_eq!(l.points.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_keeps_removed_points_within_tolerance() {
|
||||
for direction in [1.0, -1.0] {
|
||||
let mut lane = AutomationLane::new(AutoParam::Volume);
|
||||
for (at, value) in [(0, 0.0), (100, 0.0), (200, 0.0), (300, 0.12), (400, 0.26)] {
|
||||
lane.set_point(at, value * direction);
|
||||
}
|
||||
let original = lane.points.clone();
|
||||
let tolerance = 0.1;
|
||||
assert!(lane.thin(tolerance) > 0);
|
||||
for point in original {
|
||||
let error = (lane.value_at(point.at, 0.0) - point.value).abs();
|
||||
assert!(error <= tolerance, "at {}: error {error} exceeds {tolerance}", point.at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_ids() {
|
||||
assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1)));
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ pub struct Clip {
|
|||
/// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews).
|
||||
#[serde(default)]
|
||||
pub group: Option<u64>,
|
||||
/// Marks the clips made by `clip.loop` (the origin and its copies share one id), so
|
||||
/// `clip.unloop` still recognises the loop after the source's content is edited.
|
||||
#[serde(default)]
|
||||
pub loop_id: Option<u64>,
|
||||
}
|
||||
|
||||
fn one() -> f64 {
|
||||
|
|
@ -131,6 +135,7 @@ impl Clip {
|
|||
color: None,
|
||||
stretch: 1.0,
|
||||
group: None,
|
||||
loop_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +220,12 @@ impl Clip {
|
|||
self.length -= delta;
|
||||
self.start = new_start;
|
||||
self.clamp_fades();
|
||||
self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect();
|
||||
// Keep the gain at the cut when earlier breakpoints are dropped.
|
||||
if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) {
|
||||
self.gain_env.insert(0, (delta, env_value(&self.gain_env, delta)));
|
||||
}
|
||||
self.gain_env.retain(|&(t, _)| t >= delta);
|
||||
self.gain_env.iter_mut().for_each(|(t, _)| *t = t.saturating_sub(delta));
|
||||
}
|
||||
|
||||
/// Move the end edge to `new_end`.
|
||||
|
|
@ -282,6 +292,27 @@ mod tests {
|
|||
assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_start_keeps_gain_envelope_at_cut() {
|
||||
for (env, cut, expected) in [
|
||||
(vec![(0, 0.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]),
|
||||
(vec![(0, 0.0), (100, -20.0)], 500, vec![(0, -20.0)]),
|
||||
(vec![(0, 0.0), (500, -10.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]),
|
||||
(vec![], 500, vec![]),
|
||||
(vec![(100, -3.0)], 0, vec![(100, -3.0)]),
|
||||
(vec![(100, -3.0)], -100, vec![(200, -3.0)]),
|
||||
] {
|
||||
let mut clip = Clip::audio(ClipId(1), "a", SourceId(1), 100, 0, 1000);
|
||||
clip.gain_env = env;
|
||||
let gain_at_cut = clip.gain_at(cut);
|
||||
clip.trim_start_to(cut);
|
||||
assert_eq!(clip.gain_env, expected);
|
||||
if cut > 0 {
|
||||
assert!((clip.gain_at(0) - gain_at_cut).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_keep_material_anchored() {
|
||||
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub use markers::{Group, MarkerKind, MemoryLocation};
|
|||
pub use mixer::{
|
||||
Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db,
|
||||
};
|
||||
pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState};
|
||||
pub use session::{BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState};
|
||||
pub use source::{Source, SourceAudio, SourcePool, VideoSource};
|
||||
pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap
|
|||
/// Native session file extension.
|
||||
pub const SESSION_EXTENSION: &str = "scraft";
|
||||
|
||||
/// `EditState::values` key holding the feet+frames reading of sample 0, in frames.
|
||||
pub const FEET_FRAMES_START: &str = "feet_frames.start";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("not a SoundCraft session: {0}")]
|
||||
|
|
@ -421,6 +424,21 @@ impl Session {
|
|||
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position).
|
||||
pub fn feet_frames_start(&self) -> i64 {
|
||||
self.edit.value(FEET_FRAMES_START, 0.0) as i64
|
||||
}
|
||||
|
||||
/// Format a position against this session's timecode and feet+frames references.
|
||||
pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String {
|
||||
soundcraft_time::format_position(at, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
|
||||
}
|
||||
|
||||
/// Parse a position typed in `fmt` against this session's references.
|
||||
pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result<Samples, soundcraft_time::TimeError> {
|
||||
soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
|
||||
}
|
||||
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ impl AudioState {
|
|||
self.pos = at;
|
||||
self.pending_len = 0;
|
||||
self.read = 0.0;
|
||||
self.mix.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -332,7 +333,10 @@ impl Side {
|
|||
/// Drops retired instances (closing their editors first, inside the plugin's drop).
|
||||
fn collect_garbage(&mut self) {
|
||||
while let Ok(g) = self.garbage.try_recv() {
|
||||
drop(g);
|
||||
for p in g {
|
||||
log::debug!("plugin instance released: {}", p.info().name);
|
||||
drop(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +685,7 @@ impl Side {
|
|||
}
|
||||
self.forget(key);
|
||||
let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch);
|
||||
log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some());
|
||||
self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
|
||||
if let Some(mut plugin) = made {
|
||||
if let Some(e) = plugin.editor() {
|
||||
|
|
@ -949,6 +954,49 @@ mod tests {
|
|||
assert!(shared.playing.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_while_playing_drops_the_old_position_audio() {
|
||||
use soundcraft_model::{ChannelFormat, Clip, Insert, SourceAudio, SourceId, TrackKind};
|
||||
for (plugin, params) in [("compressor", &[("lookahead", 5.0), ("ratio", 1.0)][..]), ("room_reverb", &[][..])] {
|
||||
let mut s = soundcraft_model::Session::default();
|
||||
s.edit.click = false;
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(
|
||||
SourceId(5),
|
||||
Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })),
|
||||
);
|
||||
let id = s.new_clip_id();
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 4800));
|
||||
let mut ins = Insert::new(plugin);
|
||||
for (k, v) in params {
|
||||
ins.params.insert((*k).into(), *v);
|
||||
}
|
||||
tr.mixer.inserts[0] = Some(ins);
|
||||
let shared = Arc::new(Shared {
|
||||
position: AtomicI64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed: AtomicU32::new(1000),
|
||||
meters: Mutex::new(MeterSnapshot::default()),
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
|
||||
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
|
||||
let mut out = vec![0.0f32; 2 * 512];
|
||||
let mut before = 0.0f32;
|
||||
for _ in 0..9 {
|
||||
st.fill(&mut out, 2);
|
||||
before = out.iter().fold(before, |m, x| m.max(x.abs()));
|
||||
}
|
||||
assert!(before > 0.1, "{plugin}: the clip plays before the locate");
|
||||
tx.send(Cmd::Locate(1_000_000)).unwrap();
|
||||
for _ in 0..8 {
|
||||
st.fill(&mut out, 2);
|
||||
assert!(out.iter().all(|x| x.abs() < 1e-6), "{plugin}: audio from before the locate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_tracks_are_heard_and_metered_after_playback_stops() {
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
|
|
|
|||
|
|
@ -50,14 +50,15 @@ fn min_secs(s: Samples, sr: SampleRate) -> String {
|
|||
format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" })
|
||||
}
|
||||
|
||||
/// Format an absolute position.
|
||||
pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> String {
|
||||
/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the
|
||||
/// feet+frames reading of sample 0 in frames.
|
||||
pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String {
|
||||
match fmt {
|
||||
TimeFormat::Samples => s.to_string(),
|
||||
TimeFormat::MinSecs => min_secs(s, sr),
|
||||
TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate),
|
||||
TimeFormat::FeetFrames => {
|
||||
let (ft, fr) = feet_frames(s, rate, sr);
|
||||
let (ft, fr) = feet_frames(s, rate, sr, ff_start);
|
||||
format!("{ft}+{fr:02}")
|
||||
}
|
||||
TimeFormat::BarsBeats => {
|
||||
|
|
@ -80,12 +81,20 @@ pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRa
|
|||
format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1))
|
||||
}
|
||||
TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate),
|
||||
other => format_position(len, other, sr, map, rate, 0),
|
||||
other => format_position(len, other, sr, map, rate, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a position typed into a counter or dialog.
|
||||
pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> Result<Samples, TimeError> {
|
||||
pub fn parse_position(
|
||||
text: &str,
|
||||
fmt: TimeFormat,
|
||||
sr: SampleRate,
|
||||
map: &TempoMap,
|
||||
rate: FrameRate,
|
||||
tc_start: Samples,
|
||||
ff_start: i64,
|
||||
) -> Result<Samples, TimeError> {
|
||||
let t = text.trim();
|
||||
let err = || TimeError::Parse(t.to_string(), fmt.label());
|
||||
match fmt {
|
||||
|
|
@ -110,7 +119,7 @@ pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMa
|
|||
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
|
||||
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
|
||||
let fr: i64 = fr.trim().parse().map_err(|_| err())?;
|
||||
Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr), sr))
|
||||
Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr))
|
||||
}
|
||||
TimeFormat::BarsBeats => {
|
||||
let parts: Vec<i64> =
|
||||
|
|
@ -136,11 +145,11 @@ mod tests {
|
|||
#[test]
|
||||
fn formats_like_pro_tools_counters() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000");
|
||||
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500");
|
||||
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000");
|
||||
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00");
|
||||
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345");
|
||||
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000");
|
||||
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500");
|
||||
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000");
|
||||
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00");
|
||||
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -148,8 +157,8 @@ mod tests {
|
|||
let (sr, map, rate) = ctx();
|
||||
for fmt in TimeFormat::ALL {
|
||||
for s in [0i64, 48_000, 96_000 * 3, 1_440_000] {
|
||||
let text = format_position(s, fmt, sr, &map, rate, 0);
|
||||
let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap();
|
||||
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
|
||||
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
|
||||
assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}");
|
||||
}
|
||||
}
|
||||
|
|
@ -158,9 +167,9 @@ mod tests {
|
|||
#[test]
|
||||
fn parse_rejects_garbage() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err());
|
||||
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err());
|
||||
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err());
|
||||
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err());
|
||||
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err());
|
||||
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ impl FrameRate {
|
|||
return 0;
|
||||
}
|
||||
let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
|
||||
i64::try_from(v).unwrap_or(0)
|
||||
i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
/// Samples per frame (fractional).
|
||||
|
|
@ -153,13 +153,14 @@ impl Timecode {
|
|||
/// Absolute frame count of this label.
|
||||
pub fn to_frames(&self, rate: FrameRate) -> i64 {
|
||||
let base = rate.nominal().max(1);
|
||||
let total_min = self.hours * 60 + self.minutes;
|
||||
let mut f = (total_min * 60 + self.seconds) * base + self.frames;
|
||||
let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes);
|
||||
let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames);
|
||||
if rate.is_drop() {
|
||||
let drop = if base >= 60 { 4 } else { 2 };
|
||||
f -= drop * (total_min - total_min / 10);
|
||||
}
|
||||
if self.negative { -f } else { f }
|
||||
let f = if self.negative { -f } else { f };
|
||||
i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode {
|
||||
|
|
@ -202,9 +203,9 @@ impl Timecode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Feet+Frames for 35 mm film (16 frames per foot).
|
||||
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate) -> (i64, i64) {
|
||||
let f = rate.frames_at(s, sr);
|
||||
/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0.
|
||||
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) {
|
||||
let f = rate.frames_at(s, sr).saturating_add(start);
|
||||
(f.div_euclid(16), f.rem_euclid(16))
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +234,19 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_timecode_saturates_instead_of_overflowing() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] {
|
||||
let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap();
|
||||
assert_eq!(tc.to_samples(r, sr), i64::MAX);
|
||||
let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap();
|
||||
assert_eq!(tc.to_samples(r, sr), i64::MIN);
|
||||
}
|
||||
let tc = Timecode::parse("01:02:03:04").unwrap();
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variants() {
|
||||
let tc = Timecode::parse("01:02:03:04").unwrap();
|
||||
|
|
@ -246,6 +260,6 @@ mod tests {
|
|||
#[test]
|
||||
fn feet_and_frames() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr), (1, 8));
|
||||
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,8 +108,13 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
|
|||
Ok(v) => v,
|
||||
Err(e) => return Some(err(e)),
|
||||
};
|
||||
// Same predicate as the engine's `engine.commands`: id or label substring, ASCII case-insensitive.
|
||||
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
|
||||
let matches = |id: &str, label: &str| {
|
||||
filter.as_deref().is_none_or(|f| id.to_ascii_lowercase().contains(f) || label.to_ascii_lowercase().contains(f))
|
||||
};
|
||||
if let Some(arr) = list.as_array_mut() {
|
||||
for (id, label, path, sc) in crate::menus::UI_COMMANDS {
|
||||
for (id, label, path, sc) in crate::menus::UI_COMMANDS.iter().filter(|c| matches(c.0, c.1)) {
|
||||
arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -655,13 +655,9 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row:
|
|||
TimeFormat::BarsBeats => s.tempo.bar_beat_at(at, sr).bar.to_string(),
|
||||
TimeFormat::MinSecs => {
|
||||
let secs = sr.seconds(at);
|
||||
if secs.fract().abs() < 1e-9 {
|
||||
format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60)
|
||||
} else {
|
||||
format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start)
|
||||
}
|
||||
if secs.fract().abs() < 1e-9 { format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60) } else { s.format_position(at, f) }
|
||||
}
|
||||
_ => format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start),
|
||||
_ => s.format_position(at, f),
|
||||
};
|
||||
painter.text(pos2(x + 3.0, row.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.ruler_text);
|
||||
} else {
|
||||
|
|
@ -1325,7 +1321,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
|||
painter.line_segment([pos2(x, r.min.y + name_h), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90)));
|
||||
}
|
||||
let time_text = if s.edit.flag("view.clip.time.current") {
|
||||
Some(format_position(clip.start, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start))
|
||||
Some(s.format_position(clip.start, s.edit.main_counter))
|
||||
} else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") {
|
||||
clip.source().and_then(|src| s.source(src)).map(|src| {
|
||||
format_position(
|
||||
|
|
@ -1335,6 +1331,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
|||
&s.tempo,
|
||||
s.frame_rate,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
|
|
@ -1633,7 +1630,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
|||
Tool::Pencil => {
|
||||
if let Some(param) = &auto_view {
|
||||
let (lo, hi, _) = param.range();
|
||||
let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { (-60.0, 12.0) } else { (lo, hi) };
|
||||
let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) {
|
||||
(-60.0, 12.0)
|
||||
} else if matches!(param, AutoParam::Plugin { .. }) {
|
||||
plugin_range(track, param)
|
||||
} else {
|
||||
(lo, hi)
|
||||
};
|
||||
let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0);
|
||||
let _ = app.run(
|
||||
"automation.set_point",
|
||||
|
|
@ -2457,4 +2460,24 @@ mod tests {
|
|||
let want: Vec<TrackId> = [1, 0, 2, 3].iter().map(|&i| before[i]).collect();
|
||||
assert_eq!(order(&h), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pencil_click_on_a_plugin_lane_uses_the_plugin_range() {
|
||||
let mut engine = soundcraft_engine::demo::demo_engine();
|
||||
engine.execute("track.view", &json!({"track": "Kick", "view": "plugin:a:input_gain"})).unwrap();
|
||||
engine.execute("edit.tool", &json!({"tool": "pencil"})).unwrap();
|
||||
let mut h = harness_with(engine);
|
||||
let s = h.state().engine.session_arc();
|
||||
let kick = s.track_by_name("Kick").unwrap();
|
||||
let i = h.state().edit_layout.rows.iter().position(|(id, _)| *id == kick.id.0).unwrap();
|
||||
let lane = lane_rect(kick, row(&h, i), timeline(&h));
|
||||
// A quarter up the lane: -12 dB in the Input range of -24..24 dB.
|
||||
let at = pos2(lane.min.x + 100.0, lane.max.y - 4.0 - (lane.height() - 8.0) * 0.25);
|
||||
click(&mut h, at);
|
||||
let s = h.state().engine.session_arc();
|
||||
let param = AutoParam::Plugin { slot: 0, param: "input_gain".into() };
|
||||
let points = s.track_by_name("Kick").and_then(|t| t.lane(¶m)).map(|l| l.points.clone()).unwrap_or_default();
|
||||
assert_eq!(points.len(), 1, "status: {}", h.state().ui.status);
|
||||
assert!((points[0].value + 12.0).abs() < 0.01, "{}", points[0].value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,6 +270,10 @@ pub struct SoundApp {
|
|||
last_meter_poll: f64,
|
||||
/// Movies for the Video window and Video track (decoded in the background).
|
||||
pub video: video_track::VideoPool,
|
||||
/// Test-only: owns the process-wide palette lock while this app exists, so frames painted
|
||||
/// and colour assertions in one test can never straddle another test's palette change.
|
||||
#[cfg(test)]
|
||||
_palette_lock: theme::TestPaletteGuard,
|
||||
}
|
||||
|
||||
/// Whether another automation-write pass is due: the transport moved at least
|
||||
|
|
@ -326,6 +330,8 @@ impl SoundApp {
|
|||
last_frame: None,
|
||||
last_meter_poll: f64::NEG_INFINITY,
|
||||
video: video_track::VideoPool::default(),
|
||||
#[cfg(test)]
|
||||
_palette_lock: theme::lock_test_palette(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@ pub fn tree() -> Vec<MenuNode> {
|
|||
insert_path(&mut roots, line);
|
||||
}
|
||||
insert_path(&mut roots, "Window > Reset Panel Layout");
|
||||
// Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282).
|
||||
// It creates a clip rather than acting on the selected ones, so it leads the Clip menu.
|
||||
insert_path(&mut roots, "Clip > New MIDI Clip");
|
||||
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
|
||||
clip.children.rotate_right(1);
|
||||
}
|
||||
for (path, _) in MENU_WINDOWS {
|
||||
insert_path(&mut roots, path);
|
||||
}
|
||||
|
|
@ -490,7 +496,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
|||
toggle(&mut app.ui.show_audio_health)
|
||||
}
|
||||
"window.about" => toggle(&mut app.ui.show_about),
|
||||
"window.midi_editor" | "window.midi_front" => toggle(&mut app.ui.show_midi_editor),
|
||||
"window.midi_editor" => toggle(&mut app.ui.show_midi_editor),
|
||||
"window.midi_front" => {
|
||||
app.ui.show_midi_editor = true;
|
||||
json!({"value": true})
|
||||
}
|
||||
"window.midi_back" => {
|
||||
app.ui.show_midi_editor = false;
|
||||
json!({})
|
||||
|
|
@ -557,6 +567,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
|||
app.ui.show_undo_history = false;
|
||||
app.ui.show_session_info = false;
|
||||
app.ui.show_audio_health = false;
|
||||
app.ui.show_automation = false;
|
||||
app.ui.plugin_windows.clear();
|
||||
app.ui.audiosuite = None;
|
||||
json!({})
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pub fn target_clip(app: &SoundApp) -> Option<ClipId> {
|
|||
let s = app.engine.session();
|
||||
for id in &s.edit.selected_clips {
|
||||
if let Some((_, c)) = s.find_clip(*id)
|
||||
&& !c.is_audio()
|
||||
&& matches!(c.content, ClipContent::Midi { .. })
|
||||
{
|
||||
return Some(*id);
|
||||
}
|
||||
|
|
@ -84,6 +84,19 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
regular(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
let grid_ticks = match app.engine.session().edit.grid {
|
||||
soundcraft_time::GridValue::Note { value, dotted, triplet } => {
|
||||
let mut g = value.ticks();
|
||||
if dotted {
|
||||
g = g * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
g = g * 2 / 3;
|
||||
}
|
||||
g.max(1)
|
||||
}
|
||||
_ => TICKS_PER_QUARTER / 4,
|
||||
};
|
||||
// Header toolbar: whole-clip MIDI operations.
|
||||
let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0));
|
||||
let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
|
|
@ -99,7 +112,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
op = Some(("event.change_velocity", json!({"add": 10})));
|
||||
}
|
||||
if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() {
|
||||
op = Some(("event.quantize", json!({"grid": "1/16", "strength": 100})));
|
||||
op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100})));
|
||||
}
|
||||
if let Some((id, mut p)) = op {
|
||||
p["clips"] = json!([cid.0]);
|
||||
|
|
@ -109,19 +122,6 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let sr = s.sample_rate;
|
||||
let base_tick = s.tempo.samples_to_ticks(clip.start, sr);
|
||||
let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1);
|
||||
let grid_ticks = match s.edit.grid {
|
||||
soundcraft_time::GridValue::Note { value, dotted, triplet } => {
|
||||
let mut g = value.ticks();
|
||||
if dotted {
|
||||
g = g * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
g = g * 2 / 3;
|
||||
}
|
||||
g.max(1)
|
||||
}
|
||||
_ => TICKS_PER_QUARTER / 4,
|
||||
};
|
||||
let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0));
|
||||
let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y));
|
||||
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
|
||||
|
|
@ -330,7 +330,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi}));
|
||||
}
|
||||
}
|
||||
if !app.midi.selected.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
|
||||
if !app.midi.selected.is_empty()
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
{
|
||||
let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected}));
|
||||
app.midi.selected.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ use crate::theme::{Tokens, bold, mono, regular, rgb};
|
|||
use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ClipId, SourceId};
|
||||
use soundcraft_time::format_position;
|
||||
|
||||
fn panel_header(ui: &mut Ui, title: &str) {
|
||||
let t = Tokens::current();
|
||||
|
|
@ -218,7 +217,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
let s = app.engine.session();
|
||||
let fmt = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let fmt = |x: i64| s.format_position(x, s.edit.main_counter);
|
||||
if flags.contains("view.transport.counters") || expanded || flags.is_empty() {
|
||||
ui.label(egui::RichText::new(fmt(app.position())).font(mono(20.0)).color(t.counter_text));
|
||||
}
|
||||
|
|
@ -271,7 +270,7 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
}
|
||||
egui::Window::new("Big Counter").open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| {
|
||||
let s = app.engine.session();
|
||||
let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let txt = s.format_position(app.position(), s.edit.main_counter);
|
||||
let r = ui.available_rect_before_wrap();
|
||||
let t = Tokens::current();
|
||||
ui.painter().rect_filled(r, 4.0, t.big_counter_bg);
|
||||
|
|
@ -291,7 +290,7 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
|
|||
});
|
||||
let rows: Vec<(u32, String, String, String, String)> = {
|
||||
let s = app.engine.session();
|
||||
let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let f = |x: i64| s.format_position(x, s.edit.main_counter);
|
||||
s.markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
|
|
@ -563,10 +562,16 @@ fn preset_menu(
|
|||
if let Ok(text) = std::fs::read_to_string(dir.join(format!("{n}.json")))
|
||||
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&text)
|
||||
{
|
||||
let _ = app.run(
|
||||
"mix.insert_params",
|
||||
json!({"track": tid.0, "slot": slot, "params": v.get("params").cloned().unwrap_or_default(), "preset": n}),
|
||||
);
|
||||
// Old presets may carry parameters the plugin no longer exposes; drop them so recall succeeds.
|
||||
let params: serde_json::Map<String, serde_json::Value> = v
|
||||
.get("params")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(k, val)| info.params.iter().any(|pi| pi.id == k.as_str()) && val.as_f64().is_some_and(f64::is_finite))
|
||||
.map(|(k, val)| (k.clone(), val.clone()))
|
||||
.collect();
|
||||
let _ = app.run("mix.insert_params", json!({"track": tid.0, "slot": slot, "params": params, "preset": n}));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,64 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
|
||||
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Unit tests that change the process-wide palette must own it until their assertions finish.
|
||||
/// Unit tests that change or read the process-wide palette must own it until their assertions
|
||||
/// finish. The lock is re-entrant per thread, so a test may hold it and still build apps
|
||||
/// (`SoundApp::new` takes it too) without deadlocking itself.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> {
|
||||
static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
pub(crate) fn lock_test_palette() -> TestPaletteGuard {
|
||||
TestPaletteGuard::acquire()
|
||||
}
|
||||
|
||||
/// RAII handle for the re-entrant, per-thread test palette lock; see [`lock_test_palette`].
|
||||
#[cfg(test)]
|
||||
pub(crate) struct TestPaletteGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl TestPaletteGuard {
|
||||
fn acquire() -> Self {
|
||||
palette_lock::acquire();
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TestPaletteGuard {
|
||||
fn drop(&mut self) {
|
||||
palette_lock::release();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod palette_lock {
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
|
||||
static PALETTE: Mutex<()> = Mutex::new(());
|
||||
|
||||
thread_local! {
|
||||
/// How many guards this thread holds; the OS lock is taken on 0→1 and released on 1→0.
|
||||
static DEPTH: Cell<usize> = const { Cell::new(0) };
|
||||
static GUARD: RefCell<Option<MutexGuard<'static, ()>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub(super) fn acquire() {
|
||||
DEPTH.with(|depth| {
|
||||
if depth.get() == 0 {
|
||||
GUARD.with(|guard| *guard.borrow_mut() = Some(PALETTE.lock().unwrap_or_else(PoisonError::into_inner)));
|
||||
}
|
||||
depth.set(depth.get() + 1);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn release() {
|
||||
DEPTH.with(|depth| {
|
||||
let left = depth.get().saturating_sub(1);
|
||||
depth.set(left);
|
||||
if left == 0 {
|
||||
GUARD.with(|guard| *guard.borrow_mut() = None);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
|
||||
|
|
@ -415,6 +468,26 @@ mod tests {
|
|||
use crate::{Services, SoundApp, UiState};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_palette_lock_is_reentrant_and_excludes_other_threads() {
|
||||
// Regression for the parallel-run flakes: the process-wide palette must be owned for a
|
||||
// whole test, re-entrant on the owning thread (so `SoundApp::new` can take it too), and
|
||||
// exclusive against other threads (so a frame cannot flip it mid-test).
|
||||
let outer = lock_test_palette();
|
||||
let inner = lock_test_palette();
|
||||
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _held = lock_test_palette();
|
||||
let _ = tx.send(());
|
||||
});
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "another thread must wait for the palette lock");
|
||||
drop(inner);
|
||||
drop(outer);
|
||||
drop(app);
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(), "the palette lock must be released when every holder drops it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_is_the_default() {
|
||||
assert_eq!(ThemeMode::default(), ThemeMode::Dark);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::{SoundApp, icons};
|
|||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{EditMode, Tool};
|
||||
use soundcraft_time::{TimeFormat, format_length, format_position};
|
||||
use soundcraft_time::{TimeFormat, format_length};
|
||||
|
||||
pub const HEIGHT: f32 = 78.0;
|
||||
|
||||
|
|
@ -23,18 +23,23 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let full = ui.max_rect();
|
||||
ui.painter().rect_filled(full, 0.0, t.toolbar_bg);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
edit_modes(app, ui);
|
||||
zoom_group(app, ui);
|
||||
tools(app, ui);
|
||||
counters(app, ui);
|
||||
grid_nudge(app, ui);
|
||||
transport(app, ui);
|
||||
tempo_meter(app, ui);
|
||||
output_meter(app, ui);
|
||||
});
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("edit_toolbar_scroll")
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
edit_modes(app, ui);
|
||||
zoom_group(app, ui);
|
||||
tools(app, ui);
|
||||
counters(app, ui);
|
||||
grid_nudge(app, ui);
|
||||
transport(app, ui);
|
||||
tempo_meter(app, ui);
|
||||
output_meter(app, ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
|
||||
|
|
@ -173,7 +178,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
|
|||
|
||||
fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
|
||||
let s = app.engine.session();
|
||||
format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)
|
||||
s.format_position(at, f)
|
||||
}
|
||||
|
||||
fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use crate::video_track::{MovieKey, movie_secs, online, picture_at};
|
|||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ClipContent, TrackKind};
|
||||
use soundcraft_time::{TimeFormat, format_position};
|
||||
use soundcraft_time::TimeFormat;
|
||||
|
||||
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if app.ui.show_video {
|
||||
|
|
@ -81,7 +81,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170));
|
||||
}
|
||||
if burn {
|
||||
let tc = format_position(pos, TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let tc = s.format_position(pos, TimeFormat::Timecode);
|
||||
let size = (h / 12.0).clamp(10.0, 36.0);
|
||||
let galley = painter.layout_no_wrap(tc, mono(size), Color32::WHITE);
|
||||
let at = pos2(img.center().x - galley.size().x / 2.0, img.max.y - galley.size().y - size * 0.6);
|
||||
|
|
@ -95,6 +95,12 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Timeline position of strip coordinate `x` (the strip spans `left`..`left + width` and samples
|
||||
/// 0..`end`), clamped to the start of the clip being drawn.
|
||||
fn strip_position(x: f32, left: f32, width: f32, end: i64, clip_start: i64) -> i64 {
|
||||
(((x - left) / width * end as f32) as i64).max(clip_start)
|
||||
}
|
||||
|
||||
fn first_movie_aspect(s: &soundcraft_model::Session) -> Option<f32> {
|
||||
s.videos.first().map(|v| v.aspect() as f32)
|
||||
}
|
||||
|
|
@ -131,8 +137,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
let hpx = ((cr.height() * ppp / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32;
|
||||
let mut x = cr.min.x;
|
||||
while x < cr.max.x {
|
||||
let at = c.start + ((x - r.min.x) / r.width() * end as f32) as i64 - c.start.min(0);
|
||||
let secs = movie_secs(&s, c.start, offset, at.max(c.start)).max(0.0);
|
||||
let secs = movie_secs(&s, c.start, offset, strip_position(x, r.min.x, r.width(), end, c.start)).max(0.0);
|
||||
if let Some(tex) = app.video.thumb(ctx, key, &v.path, secs, hpx) {
|
||||
let tr = Rect::from_min_size(pos2(x, cr.min.y), vec2(tw, cr.height()));
|
||||
let vis = tr.intersect(cr);
|
||||
|
|
@ -164,3 +169,33 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
app.ui.show_video_universe = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_position;
|
||||
|
||||
// A 1024 px strip over 1_024_000 samples: 1000 samples per point, exact in f32.
|
||||
const LEFT: f32 = 100.0;
|
||||
const WIDTH: f32 = 1024.0;
|
||||
const END: i64 = 1_024_000;
|
||||
|
||||
#[test]
|
||||
fn strip_position_is_the_timeline_position_for_clips_after_zero() {
|
||||
// A clip at 48_000 has its left edge at x = 148.
|
||||
assert_eq!(strip_position(148.0, LEFT, WIDTH, END, 48_000), 48_000);
|
||||
assert_eq!(strip_position(248.0, LEFT, WIDTH, END, 48_000), 148_000);
|
||||
let s = soundcraft_model::Session::default();
|
||||
let at = strip_position(148.0, LEFT, WIDTH, END, 48_000);
|
||||
assert_eq!(crate::video_track::movie_secs(&s, 48_000, 0, at), 0.0, "the clip's left edge shows the movie's first picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_position_is_unchanged_for_clips_at_or_before_zero() {
|
||||
assert_eq!(strip_position(100.0, LEFT, WIDTH, END, 0), 0);
|
||||
assert_eq!(strip_position(200.0, LEFT, WIDTH, END, 0), 100_000);
|
||||
// A clip at -48_000 starts left of the strip; positions before it clamp to its start.
|
||||
assert_eq!(strip_position(0.0, LEFT, WIDTH, END, -48_000), -48_000);
|
||||
assert_eq!(strip_position(52.0, LEFT, WIDTH, END, -48_000), -48_000);
|
||||
assert_eq!(strip_position(200.0, LEFT, WIDTH, END, -48_000), 100_000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,10 +202,7 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
("Clips", s.tracks.iter().map(|t| t.clips().len()).sum::<usize>().to_string()),
|
||||
("Audio files", s.sources.len().to_string()),
|
||||
("Markers", s.markers.len().to_string()),
|
||||
(
|
||||
"Length",
|
||||
soundcraft_time::format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, 0),
|
||||
),
|
||||
("Length", s.format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs)),
|
||||
] {
|
||||
ui.label(k);
|
||||
ui.label(v);
|
||||
|
|
@ -334,7 +331,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
}
|
||||
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
|
||||
let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
|
||||
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
|
||||
.then_some((name, is_dir))
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
//! Every user-facing engine command must be reachable from the GUI: through a menu, a shortcut,
|
||||
//! or a widget that names it in `src/`. Commands that only work from the control channel / CLI /
|
||||
//! MCP left users stuck (#282: `midi.new_clip` had no UI path, so an empty instrument track could
|
||||
//! never get a MIDI clip).
|
||||
|
||||
use soundcraft_engine::{catalog, cmd::command_specs};
|
||||
use soundcraft_ui_egui::menus::{self, MenuNode};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today.
|
||||
/// Give each one a UI path and remove it from this list; don't add to it.
|
||||
const KNOWN_UNREACHED: &[&str] = &[
|
||||
"automation.clear",
|
||||
"edit.paste_clip_effects",
|
||||
"edit.paste_clip_gain",
|
||||
"event.midi_note",
|
||||
"file.bounce_stems",
|
||||
"markers.next",
|
||||
"markers.previous",
|
||||
"mix.clear_mutes",
|
||||
"mix.clear_solos",
|
||||
"mix.insert_move",
|
||||
"mix.instrument_param",
|
||||
"mix.phase_invert",
|
||||
"mix.sampler_clear",
|
||||
"mix.sampler_load",
|
||||
"mix.send_surround_pan",
|
||||
"mix.solo_safe",
|
||||
"options.post_roll",
|
||||
"options.pre_roll",
|
||||
"setup.hardware", // menu opens the Playback Engine window, which doesn't run it
|
||||
"setup.playback_engine", // same
|
||||
"setup.reset_edit_state",
|
||||
"track.elastic",
|
||||
"track.load_preset",
|
||||
"track.pin",
|
||||
"track.timebase",
|
||||
"track.trim_automation_write",
|
||||
"track.vca",
|
||||
"transport.pause",
|
||||
"transport.status",
|
||||
"video.offset",
|
||||
"view.sub_counter",
|
||||
];
|
||||
|
||||
fn ui_source() -> String {
|
||||
fn walk(dir: &Path, out: &mut String) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let p = entry.unwrap().path();
|
||||
if p.is_dir() {
|
||||
walk(&p, out);
|
||||
} else if p.extension().is_some_and(|e| e == "rs") {
|
||||
out.push_str(&std::fs::read_to_string(&p).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = String::new();
|
||||
walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut s);
|
||||
s
|
||||
}
|
||||
|
||||
/// Command ids behind the leaves of the menu bar actually built by the app. A spec's own `menu`
|
||||
/// field is not enough: the bar is built from the catalog, so an off-catalog path never shows.
|
||||
fn menu_commands() -> HashSet<String> {
|
||||
fn walk(nodes: &[MenuNode], extra: &[(&str, &str)], out: &mut HashSet<String>) {
|
||||
for n in nodes {
|
||||
if n.children.is_empty() {
|
||||
out.extend(catalog::implemented_by(&n.path, extra));
|
||||
}
|
||||
walk(&n.children, extra, out);
|
||||
}
|
||||
}
|
||||
let mut out = HashSet::new();
|
||||
walk(&menus::tree(), &menus::ui_aliases(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_a_ui_path() {
|
||||
let src = ui_source();
|
||||
let in_menus = menu_commands();
|
||||
let mut unreached = Vec::new();
|
||||
let mut stale = Vec::new();
|
||||
for c in command_specs() {
|
||||
// Queries and view-only commands are for agents / the UI's own reads.
|
||||
if !c.journal || c.view {
|
||||
continue;
|
||||
}
|
||||
let reached = in_menus.contains(c.id) || c.shortcut.is_some() || src.contains(&format!("\"{}\"", c.id));
|
||||
let known = KNOWN_UNREACHED.contains(&c.id);
|
||||
if !reached && !known {
|
||||
unreached.push(c.id);
|
||||
} else if reached && known {
|
||||
stale.push(c.id);
|
||||
}
|
||||
}
|
||||
assert!(unreached.is_empty(), "commands with no menu, shortcut or UI reference: {unreached:?}");
|
||||
assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_midi_clip_is_in_the_clip_menu() {
|
||||
let extra = menus::ui_aliases();
|
||||
assert_eq!(catalog::implemented_by("Clip > New MIDI Clip", &extra).as_deref(), Some("midi.new_clip"));
|
||||
assert!(menu_commands().contains("midi.new_clip"));
|
||||
let tree = menus::tree();
|
||||
let clip = tree.iter().find(|n| n.label == "Clip").unwrap();
|
||||
assert_eq!(clip.children[0].label, "New MIDI Clip", "creates rather than edits, so it comes first");
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
use serde_json::{Value, json};
|
||||
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp, control, menus};
|
||||
|
||||
fn commands(params: Value) -> Vec<Value> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()).with_control(rx);
|
||||
let (req, reply) = ControlRequest::new("engine.commands", params);
|
||||
tx.send(req).unwrap();
|
||||
control::drain(&mut app, &egui::Context::default());
|
||||
let response = reply.try_recv().unwrap();
|
||||
assert_eq!(response["ok"], true, "{response}");
|
||||
response["result"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
fn ids(rows: &[Value]) -> Vec<&str> {
|
||||
rows.iter().map(|r| r["id"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_applies_to_ui_commands() {
|
||||
assert_eq!(ids(&commands(json!({"filter": "window.search"}))), ["window.search"]);
|
||||
assert_eq!(ids(&commands(json!({"filter": "WINDOW.Search"}))), ["window.search"]);
|
||||
// "Arrange Panel" matches only by label, and only for ui.dock.
|
||||
assert_eq!(ids(&commands(json!({"filter": "arrange PANEL"}))), ["ui.dock"]);
|
||||
assert!(commands(json!({"filter": "zzzznomatch"})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_or_empty_filter_lists_every_ui_command() {
|
||||
for params in [json!({}), json!({"filter": ""})] {
|
||||
let rows = commands(params);
|
||||
let ids = ids(&rows);
|
||||
for (id, ..) in menus::UI_COMMANDS {
|
||||
assert!(ids.contains(id), "{id} missing");
|
||||
}
|
||||
assert!(ids.contains(&"track.new"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
#[test]
|
||||
fn hide_floating_closes_automation() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let main_window = app.ui.window;
|
||||
app.run("window.automation", json!({"value": true})).unwrap();
|
||||
app.run("window.transport", json!({"value": true})).unwrap();
|
||||
assert!(app.ui.show_automation && app.ui.show_transport);
|
||||
|
||||
app.run("window.hide_floating", json!({})).unwrap();
|
||||
assert!(!app.ui.show_transport);
|
||||
assert!(!app.ui.show_automation, "Automation must be hidden");
|
||||
assert_eq!(app.ui.window, main_window);
|
||||
|
||||
app.run("window.automation", json!({"value": true})).unwrap();
|
||||
assert!(app.ui.show_automation);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
/// The Keys track's MIDI clip in the editor with its first two notes selected, the Rename Track dialog open or not.
|
||||
fn harness(rename: bool) -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
|
||||
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
|
||||
if rename {
|
||||
app.dialogs.open_rename_track(keys, "Keys");
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
h.state_mut().midi.selected = vec![0, 1];
|
||||
h
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness<'_, SoundApp>, key: Key) {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_text_in_the_rename_field_keeps_the_selected_notes() {
|
||||
for key in [Key::Backspace, Key::Delete] {
|
||||
let mut h = harness(true);
|
||||
let before = h.state().engine.session().clone();
|
||||
press(&mut h, key);
|
||||
// The field opens with the old name selected, so the key clears it.
|
||||
assert!(matches!(&h.state().dialogs.open, Some(Dialog::RenameTrack { name, .. }) if name.is_empty()), "{key:?}");
|
||||
assert!(*h.state().engine.session() == before, "{key:?} edited the session");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_in_the_editor_still_removes_the_selected_notes() {
|
||||
for key in [Key::Backspace, Key::Delete] {
|
||||
let mut h = harness(false);
|
||||
let before = h.state().engine.session().clone();
|
||||
press(&mut h, key);
|
||||
assert!(*h.state().engine.session() != before, "{key:?} left the notes");
|
||||
assert!(h.state().midi.selected.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
#[test]
|
||||
fn bring_to_front_keeps_an_open_midi_editor_open() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("window.midi_editor", json!({"value": true})).unwrap();
|
||||
let path = "Window > MIDI Editors > Bring to Front";
|
||||
let id = menus::command_for_path(path, &menus::ui_aliases()).unwrap();
|
||||
assert_eq!(id, "window.midi_front");
|
||||
for _ in 0..2 {
|
||||
menus::invoke_menu(&mut app, &id, path);
|
||||
assert!(app.ui.show_midi_editor, "Bring to Front hid the editor");
|
||||
}
|
||||
|
||||
app.run("window.midi_editor", json!({"value": false})).unwrap();
|
||||
app.run("window.midi_front", json!({})).unwrap();
|
||||
assert!(app.ui.show_midi_editor, "Bring to Front did not show a hidden editor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_midi_editor_command_still_toggles() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
assert!(app.ui.show_midi_editor);
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
assert!(!app.ui.show_midi_editor);
|
||||
}
|
||||
|
|
@ -150,3 +150,23 @@ fn check_held_replacement(operation: &str) {
|
|||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset);
|
||||
assert_eq!(*app.engine.session(), session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_video_clip_is_not_a_midi_target() {
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipContent, SourceId, Track, TrackKind};
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
|
||||
let midi = midi_editor::target_clip(&app).unwrap();
|
||||
let s = app.engine.session_mut();
|
||||
let track = s.add_track(TrackKind::Video, ChannelFormat::Mono, None);
|
||||
let video = s.new_clip_id();
|
||||
let source = SourceId(s.alloc());
|
||||
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(Clip::video(video, "picture", source, 0, 0, 48_000));
|
||||
|
||||
app.run("edit.select", json!({"clips": [video.0]})).unwrap();
|
||||
assert_eq!(midi_editor::target_clip(&app), None, "a video clip alone leaves the MIDI editors without a target");
|
||||
app.run("edit.select", json!({"clips": [video.0, midi.0]})).unwrap();
|
||||
assert_eq!(midi_editor::target_clip(&app), Some(midi), "the MIDI clip after a video clip is the target");
|
||||
let (_, clip) = app.engine.session().find_clip(midi).unwrap();
|
||||
assert!(matches!(clip.content, ClipContent::Midi { .. }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
//! The MIDI Editor's Quantize button snaps to the grid the editor shows.
|
||||
use egui::vec2;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
fn quantize_on(grid: &str) -> i64 {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("track.new", json!({"name": "Grid Check", "kind": "midi"})).unwrap();
|
||||
let clip = app.run("midi.new_clip", json!({"track": "Grid Check", "start": 0, "end": 96_000})).unwrap()["clip"].clone();
|
||||
app.run("midi.note_add", json!({"clip": clip, "pitch": 60, "start_ticks": 300, "length_ticks": 240})).unwrap();
|
||||
app.run("edit.select", json!({"clips": [clip]})).unwrap();
|
||||
app.run("view.grid", json!({"value": grid})).unwrap();
|
||||
app.ui.show_midi_editor = true;
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h.get_by_label("Quantize").click();
|
||||
h.run_steps(3);
|
||||
let notes = h.state_mut().run("midi.notes", json!({"clip": clip})).unwrap();
|
||||
assert_eq!(notes["notes"][0]["length"], 240, "{notes}");
|
||||
notes["notes"][0]["start"].as_i64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_follows_a_quarter_note_grid() {
|
||||
assert_eq!(quantize_on("1/4"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_follows_a_sixteenth_note_grid() {
|
||||
assert_eq!(quantize_on("1/16"), 240);
|
||||
}
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
//! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists.
|
||||
|
||||
use crate::bitstream::BitReader;
|
||||
use crate::h264::error::{Result, ensure, unsupported};
|
||||
use crate::h264::error::{Error, Result, ensure, unsupported};
|
||||
use crate::h264::tables::*;
|
||||
|
||||
/// `v + k` for a coded `*_minus*` field, or the error its range check gives.
|
||||
fn plus(v: u32, k: u32, err: &str) -> Result<u32> {
|
||||
v.checked_add(k).ok_or_else(|| Error::Invalid(err.to_string()))
|
||||
}
|
||||
|
||||
/// Scaling matrices after applying fall-back rules, stored in raster order (weightScale).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ScalingMatrices {
|
||||
|
|
@ -150,7 +155,7 @@ pub struct HrdParameters {
|
|||
}
|
||||
|
||||
fn parse_hrd(r: &mut BitReader) -> Result<HrdParameters> {
|
||||
let cpb_cnt = r.read_ue()? + 1;
|
||||
let cpb_cnt = plus(r.read_ue()?, 1, "cpb_cnt_minus1 out of range")?;
|
||||
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
|
||||
let mut h = HrdParameters { cpb_cnt, bit_rate_scale: r.read_bits(4)? as u8, cpb_size_scale: r.read_bits(4)? as u8, ..Default::default() };
|
||||
for _ in 0..cpb_cnt {
|
||||
|
|
@ -353,8 +358,8 @@ impl Sps {
|
|||
if chroma_format_idc == 3 {
|
||||
separate_colour_plane = r.read_flag()?;
|
||||
}
|
||||
bit_depth_luma = r.read_ue()? + 8;
|
||||
bit_depth_chroma = r.read_ue()? + 8;
|
||||
bit_depth_luma = plus(r.read_ue()?, 8, "bit depth out of range")?;
|
||||
bit_depth_chroma = plus(r.read_ue()?, 8, "bit depth out of range")?;
|
||||
ensure!(bit_depth_luma <= 14 && bit_depth_chroma <= 14, "bit depth out of range");
|
||||
bypass = r.read_flag()?;
|
||||
seq_scaling_matrix_present = r.read_flag()?;
|
||||
|
|
@ -363,7 +368,7 @@ impl Sps {
|
|||
scaling_lists = parse_scaling_lists(&mut r, count, None)?;
|
||||
}
|
||||
}
|
||||
let log2_max_frame_num = r.read_ue()? + 4;
|
||||
let log2_max_frame_num = plus(r.read_ue()?, 4, "log2_max_frame_num out of range")?;
|
||||
ensure!(log2_max_frame_num <= 16, "log2_max_frame_num out of range");
|
||||
let pic_order_cnt_type = r.read_ue()?;
|
||||
ensure!(pic_order_cnt_type <= 2, "pic_order_cnt_type out of range");
|
||||
|
|
@ -373,7 +378,7 @@ impl Sps {
|
|||
let mut offset_for_top_to_bottom_field = 0;
|
||||
let mut offset_for_ref_frame = Vec::new();
|
||||
if pic_order_cnt_type == 0 {
|
||||
log2_max_poc_lsb = r.read_ue()? + 4;
|
||||
log2_max_poc_lsb = plus(r.read_ue()?, 4, "log2_max_pic_order_cnt_lsb out of range")?;
|
||||
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb out of range");
|
||||
} else if pic_order_cnt_type == 1 {
|
||||
delta_pic_order_always_zero = r.read_flag()?;
|
||||
|
|
@ -388,8 +393,8 @@ impl Sps {
|
|||
let max_num_ref_frames = r.read_ue()?;
|
||||
ensure!(max_num_ref_frames <= 16, "max_num_ref_frames out of range");
|
||||
let gaps_in_frame_num_allowed = r.read_flag()?;
|
||||
let pic_width_in_mbs = r.read_ue()? + 1;
|
||||
let pic_height_in_map_units = r.read_ue()? + 1;
|
||||
let pic_width_in_mbs = plus(r.read_ue()?, 1, "picture too large")?;
|
||||
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()?;
|
||||
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
|
||||
|
|
@ -429,7 +434,8 @@ impl Sps {
|
|||
};
|
||||
if let Some((l, rr, t, b)) = sps.frame_crop {
|
||||
let (cx, cy) = sps.crop_unit();
|
||||
ensure!((l + rr) * cx < sps.width() && (t + b) * cy < sps.height(), "frame cropping exceeds picture size");
|
||||
let fits = |a: u32, b: u32, unit: u32, size: u32| a.checked_add(b).and_then(|v| v.checked_mul(unit)).is_some_and(|v| v < size);
|
||||
ensure!(fits(l, rr, cx, sps.width()) && fits(t, b, cy, sps.height()), "frame cropping exceeds picture size");
|
||||
}
|
||||
Ok(sps)
|
||||
}
|
||||
|
|
@ -466,7 +472,8 @@ impl Sps {
|
|||
None => (0, 0, self.width(), self.height()),
|
||||
Some((l, r, t, b)) => {
|
||||
let (cx, cy) = self.crop_unit();
|
||||
(l * cx, t * cy, self.width() - (l + r) * cx, self.height() - (t + b) * cy)
|
||||
let cut = |a: u32, b: u32, unit: u32| a.saturating_add(b).saturating_mul(unit);
|
||||
(l.saturating_mul(cx), t.saturating_mul(cy), self.width().saturating_sub(cut(l, r, cx)), self.height().saturating_sub(cut(t, b, cy)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -572,7 +579,7 @@ impl Pps {
|
|||
.ok_or_else(|| crate::h264::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
|
||||
let entropy_coding_mode = r.read_flag()?;
|
||||
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
|
||||
let num_slice_groups = r.read_ue()? + 1;
|
||||
let num_slice_groups = plus(r.read_ue()?, 1, "num_slice_groups out of range")?;
|
||||
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
|
||||
let mut slice_group_map_type = 0;
|
||||
if num_slice_groups > 1 {
|
||||
|
|
@ -595,7 +602,7 @@ impl Pps {
|
|||
r.read_ue()?;
|
||||
}
|
||||
6 => {
|
||||
let n = r.read_ue()? + 1;
|
||||
let n = plus(r.read_ue()?, 1, "pic_size_in_map_units_minus1 out of range")?;
|
||||
let bits = 32 - (num_slice_groups - 1).leading_zeros();
|
||||
for _ in 0..n {
|
||||
r.read_bits(bits)?;
|
||||
|
|
@ -604,13 +611,13 @@ impl Pps {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
|
||||
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
|
||||
let num_ref_idx_l0_default_active = plus(r.read_ue()?, 1, "num_ref_idx_default_active out of range")?;
|
||||
let num_ref_idx_l1_default_active = plus(r.read_ue()?, 1, "num_ref_idx_default_active out of range")?;
|
||||
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
|
||||
let weighted_pred = r.read_flag()?;
|
||||
let weighted_bipred_idc = r.read_bits(2)?;
|
||||
let pic_init_qp = 26 + r.read_se()?;
|
||||
let pic_init_qs = 26 + r.read_se()?;
|
||||
let pic_init_qp = r.read_se()?.checked_add(26).ok_or_else(|| Error::Invalid("pic_init_qp_minus26 out of range".into()))?;
|
||||
let pic_init_qs = r.read_se()?.checked_add(26).ok_or_else(|| Error::Invalid("pic_init_qs_minus26 out of range".into()))?;
|
||||
let chroma_qp_index_offset = r.read_se()?;
|
||||
ensure!((-12..=12).contains(&chroma_qp_index_offset), "chroma_qp_index_offset out of range");
|
||||
let deblocking_filter_control_present = r.read_flag()?;
|
||||
|
|
@ -716,4 +723,190 @@ mod tests {
|
|||
assert_eq!(sps.pic_order_cnt_type, 2);
|
||||
assert_eq!(sps.crop_rect(), (0, 0, 176, 144));
|
||||
}
|
||||
|
||||
/// Coded SPS fields; the default is the minimal baseline SPS above.
|
||||
#[derive(Clone, Copy)]
|
||||
struct SpsFields {
|
||||
high_profile: bool,
|
||||
bit_depth_luma_minus8: u32,
|
||||
log2_max_frame_num_minus4: u32,
|
||||
poc_type: u32,
|
||||
log2_max_poc_lsb_minus4: u32,
|
||||
width_minus1: u32,
|
||||
height_minus1: u32,
|
||||
crop: Option<(u32, u32, u32, u32)>,
|
||||
cpb_cnt_minus1: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for SpsFields {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
high_profile: false,
|
||||
bit_depth_luma_minus8: 0,
|
||||
log2_max_frame_num_minus4: 0,
|
||||
poc_type: 2,
|
||||
log2_max_poc_lsb_minus4: 0,
|
||||
width_minus1: 10,
|
||||
height_minus1: 8,
|
||||
crop: None,
|
||||
cpb_cnt_minus1: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sps(f: SpsFields) -> Result<Sps> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(if f.high_profile { 100 } else { 66 }, 8);
|
||||
w.write_bits(0, 8);
|
||||
w.write_bits(30, 8);
|
||||
w.write_ue(0);
|
||||
if f.high_profile {
|
||||
w.write_ue(1); // chroma_format_idc
|
||||
w.write_ue(f.bit_depth_luma_minus8);
|
||||
w.write_ue(0); // bit_depth_chroma_minus8
|
||||
w.write_bit(false); // qpprime_y_zero_transform_bypass
|
||||
w.write_bit(false); // seq_scaling_matrix_present
|
||||
}
|
||||
w.write_ue(f.log2_max_frame_num_minus4);
|
||||
w.write_ue(f.poc_type);
|
||||
if f.poc_type == 0 {
|
||||
w.write_ue(f.log2_max_poc_lsb_minus4);
|
||||
}
|
||||
w.write_ue(1);
|
||||
w.write_bit(false);
|
||||
w.write_ue(f.width_minus1);
|
||||
w.write_ue(f.height_minus1);
|
||||
w.write_bit(true); // frame_mbs_only
|
||||
w.write_bit(true); // direct_8x8
|
||||
w.write_bit(f.crop.is_some());
|
||||
if let Some((l, r, t, b)) = f.crop {
|
||||
for v in [l, r, t, b] {
|
||||
w.write_ue(v);
|
||||
}
|
||||
}
|
||||
w.write_bit(f.cpb_cnt_minus1.is_some()); // vui
|
||||
if let Some(n) = f.cpb_cnt_minus1 {
|
||||
for _ in 0..5 {
|
||||
w.write_bit(false); // aspect ratio, overscan, video signal, chroma loc, timing
|
||||
}
|
||||
w.write_bit(true); // nal_hrd
|
||||
w.write_ue(n);
|
||||
w.write_bits(0, 8);
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_bit(false);
|
||||
w.write_bits(0, 20);
|
||||
w.write_bit(false); // vcl_hrd
|
||||
w.write_bit(false); // low_delay_hrd
|
||||
w.write_bit(false); // pic_struct_present
|
||||
w.write_bit(false); // bitstream_restriction
|
||||
}
|
||||
w.rbsp_trailing();
|
||||
Sps::parse(&w.finish())
|
||||
}
|
||||
|
||||
fn invalid_sps(f: SpsFields) -> String {
|
||||
match sps(f) {
|
||||
Err(crate::h264::Error::Invalid(m)) => m,
|
||||
other => panic!("expected an invalid SPS, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sps_fields_of_the_test_writer_parse() {
|
||||
let s = sps(SpsFields { high_profile: true, poc_type: 0, crop: Some((0, 2, 0, 2)), cpb_cnt_minus1: Some(0), ..Default::default() }).unwrap();
|
||||
assert_eq!((s.bit_depth_luma, s.log2_max_poc_lsb, s.width(), s.height()), (8, 4, 176, 144));
|
||||
assert_eq!(s.crop_rect(), (0, 0, 172, 140));
|
||||
assert_eq!(s.vui.and_then(|v| v.nal_hrd).map(|h| h.cpb_cnt), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_bit_depth_is_an_error() {
|
||||
assert_eq!(invalid_sps(SpsFields { high_profile: true, bit_depth_luma_minus8: u32::MAX, ..Default::default() }), "bit depth out of range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_log2_max_frame_num_is_an_error() {
|
||||
assert_eq!(invalid_sps(SpsFields { log2_max_frame_num_minus4: u32::MAX, ..Default::default() }), "log2_max_frame_num out of range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_log2_max_poc_lsb_is_an_error() {
|
||||
let f = SpsFields { poc_type: 0, log2_max_poc_lsb_minus4: u32::MAX, ..Default::default() };
|
||||
assert_eq!(invalid_sps(f), "log2_max_pic_order_cnt_lsb out of range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_picture_size_is_an_error() {
|
||||
assert_eq!(invalid_sps(SpsFields { width_minus1: u32::MAX, ..Default::default() }), "picture too large");
|
||||
assert_eq!(invalid_sps(SpsFields { height_minus1: u32::MAX, ..Default::default() }), "picture too large");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_offsets_that_overflow_are_an_error() {
|
||||
let msg = "frame cropping exceeds picture size";
|
||||
// The sum of two offsets overflows, then the scaling by the crop unit does.
|
||||
assert_eq!(invalid_sps(SpsFields { crop: Some((u32::MAX, 1, 0, 0)), ..Default::default() }), msg);
|
||||
assert_eq!(invalid_sps(SpsFields { crop: Some((0, 0, 1 << 31, 0)), ..Default::default() }), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_cpb_count_is_an_error() {
|
||||
assert_eq!(invalid_sps(SpsFields { cpb_cnt_minus1: Some(u32::MAX), ..Default::default() }), "cpb_cnt_minus1 out of range");
|
||||
}
|
||||
|
||||
/// A PPS for the SPS above; `slice_groups` is (num_slice_groups_minus1, pic_size_in_map_units_minus1)
|
||||
/// coded with slice_group_map_type 6.
|
||||
fn pps(slice_groups: Option<(u32, u32)>, num_ref_idx_l0_minus1: u32, pic_init_qp_minus26: i32) -> Result<Pps> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
match slice_groups {
|
||||
Some((groups, units)) => {
|
||||
w.write_ue(groups);
|
||||
w.write_ue(6);
|
||||
w.write_ue(units);
|
||||
}
|
||||
None => w.write_ue(0),
|
||||
}
|
||||
w.write_ue(num_ref_idx_l0_minus1);
|
||||
w.write_ue(0);
|
||||
w.write_bit(false);
|
||||
w.write_bits(0, 2);
|
||||
w.write_se(pic_init_qp_minus26);
|
||||
w.write_se(0);
|
||||
w.write_se(0);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
w.write_bit(false);
|
||||
w.rbsp_trailing();
|
||||
Pps::parse(&w.finish(), &[Some(sps(SpsFields::default()).unwrap())])
|
||||
}
|
||||
|
||||
fn invalid_pps(p: Result<Pps>) -> String {
|
||||
match p {
|
||||
Err(crate::h264::Error::Invalid(m)) => m,
|
||||
other => panic!("expected an invalid PPS, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pps_fields_of_the_test_writer_parse() {
|
||||
let p = pps(None, 2, -4).unwrap();
|
||||
assert_eq!((p.num_slice_groups, p.num_ref_idx_l0_default_active, p.pic_init_qp), (1, 3, 22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_pps_counts_are_an_error() {
|
||||
assert_eq!(invalid_pps(pps(Some((u32::MAX, 0)), 0, 0)), "num_slice_groups out of range");
|
||||
assert_eq!(invalid_pps(pps(Some((1, u32::MAX)), 0, 0)), "pic_size_in_map_units_minus1 out of range");
|
||||
assert_eq!(invalid_pps(pps(None, u32::MAX, 0)), "num_ref_idx_default_active out of range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_pic_init_qp_is_an_error() {
|
||||
assert_eq!(invalid_pps(pps(None, 0, i32::MAX)), "pic_init_qp_minus26 out of range");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1392,10 +1392,8 @@ impl SampleEntry {
|
|||
CodecConfig::Aac(c) => {
|
||||
b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size));
|
||||
}
|
||||
CodecConfig::Mp3 => {
|
||||
if format == *b"mp4a" {
|
||||
b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0));
|
||||
}
|
||||
CodecConfig::Mp3 if format == *b"mp4a" => {
|
||||
b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0));
|
||||
}
|
||||
CodecConfig::Alac { cookie } => {
|
||||
let m = b.start_full(b"alac", 0, 0);
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,15 @@ impl Instance {
|
|||
v.is_finite().then(|| v.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Tells the controller about a value the host set (`setParamNormalized`).
|
||||
pub fn set_normalized(&self, id: u32, n: f64) {
|
||||
let Some(c) = &self.controller else { return };
|
||||
if n.is_finite() {
|
||||
// SAFETY: valid controller.
|
||||
unsafe { c.setParamNormalized(id, n.clamp(0.0, 1.0)) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value_text(&self, id: u32, normalized: f64) -> Option<String> {
|
||||
let c = self.controller.as_ref()?;
|
||||
let mut s: sv::String128 = [0; 128];
|
||||
|
|
|
|||
|
|
@ -294,11 +294,13 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
// Unix only: the test points a ".vst3" at a system library through a symlink.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_real_library_without_the_vst3_export_is_rejected() {
|
||||
// A system library loads fine but has no `GetPluginFactory`.
|
||||
// (macOS system libraries live only in the dyld shared cache, not as files.)
|
||||
let lib = if cfg!(target_os = "macos") || cfg!(windows) {
|
||||
let lib = if cfg!(target_os = "macos") {
|
||||
None
|
||||
} else {
|
||||
["/lib/x86_64-linux-gnu/libz.so.1", "/usr/lib/libz.so.1", "/lib64/libz.so.1"].iter().map(PathBuf::from).find(|p| p.exists())
|
||||
|
|
@ -308,12 +310,9 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
// Point a single-file ".vst3" at it via a symlink (no copying of binaries).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
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}");
|
||||
}
|
||||
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}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ pub struct Vst3Plugin {
|
|||
values: Vec<f32>,
|
||||
/// Normalized values waiting for the next block (coalesced per id; bounded).
|
||||
pending: Vec<(u32, f64)>,
|
||||
/// Host-set values the controller has not seen yet (same order as `ids`). `set_param` may
|
||||
/// run on the audio thread, so it never calls the controller; `save_state`, which already
|
||||
/// does, hands them over first.
|
||||
unsynced: Vec<bool>,
|
||||
/// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`.
|
||||
notes: Vec<NoteEvent>,
|
||||
inputs: BusBuffers,
|
||||
|
|
@ -76,6 +80,7 @@ impl Vst3Plugin {
|
|||
info,
|
||||
values: Vec::with_capacity(ids.len()),
|
||||
pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)),
|
||||
unsynced: vec![false; ids.len()],
|
||||
notes: Vec::with_capacity(EVENT_CAP),
|
||||
inputs: BusBuffers::new(&[], 0),
|
||||
outputs: BusBuffers::new(&[], 0),
|
||||
|
|
@ -145,6 +150,7 @@ impl Vst3Plugin {
|
|||
}
|
||||
}
|
||||
self.pending.clear();
|
||||
self.unsynced.fill(false);
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
|
|
@ -205,6 +211,9 @@ impl Plugin for Vst3Plugin {
|
|||
if let Some(slot) = self.values.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
if let Some(u) = self.unsynced.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
let Some(n) = self.normalized_from_plain(i, v) else { return true };
|
||||
if let Some(e) = self.pending.iter_mut().find(|(pid, _)| *pid == vid) {
|
||||
e.1 = n;
|
||||
|
|
@ -319,6 +328,17 @@ impl Plugin for Vst3Plugin {
|
|||
}
|
||||
|
||||
fn save_state(&mut self) -> Option<Vec<u8>> {
|
||||
// A parameter only the controller stores (no processor state) would otherwise be saved
|
||||
// with the controller's stale value.
|
||||
for i in 0..self.ids.len() {
|
||||
if !self.unsynced.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let (Some(&id), Some(n)) = (self.ids.get(i), self.values.get(i).and_then(|&v| self.normalized_from_plain(i, v))) {
|
||||
self.inst.set_normalized(id, n);
|
||||
}
|
||||
}
|
||||
self.unsynced.fill(false);
|
||||
self.inst.save_state()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ fn audio_bus(bus: *mut BusInfo, dir: BusDirection, name: &str) {
|
|||
b.channelCount = 2;
|
||||
copy_c16(name, &mut b.name);
|
||||
b.busType = BusTypes_::kMain as BusType;
|
||||
b.flags = BusInfo_::BusFlags_::kDefaultActive;
|
||||
b.flags = BusInfo_::BusFlags_::kDefaultActive as u32;
|
||||
}
|
||||
|
||||
unsafe fn channels<'a>(bus: &AudioBusBuffers, frames: usize) -> Vec<&'a mut [f32]> {
|
||||
|
|
@ -461,7 +461,7 @@ impl IComponentTrait for Synth {
|
|||
b.channelCount = 16;
|
||||
copy_c16("Notes", &mut b.name);
|
||||
b.busType = BusTypes_::kMain as BusType;
|
||||
b.flags = BusInfo_::BusFlags_::kDefaultActive;
|
||||
b.flags = BusInfo_::BusFlags_::kDefaultActive as u32;
|
||||
return kResultOk;
|
||||
}
|
||||
kInvalidArgument
|
||||
|
|
|
|||
|
|
@ -208,6 +208,31 @@ fn state_saves_component_and_controller_and_restores_them() {
|
|||
assert_eq!(q.param("7"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_set_controller_param_survives_state_round_trip() {
|
||||
// Freq lives only in the controller (the processor ignores it), so processing never carries
|
||||
// the host's value there: the state save has to.
|
||||
let id = id_of("Fixture Gain");
|
||||
let mut p = soundcraft_vst3_host::create(&id).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
assert!(p.set_param("7", 1.5));
|
||||
assert!(p.set_param("12", 200.0));
|
||||
let mut p = std::thread::spawn(move || {
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| (x - 0.75).abs() < 1e-4));
|
||||
p
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert!((p.param("12").unwrap() - 200.0).abs() < 0.01);
|
||||
let blob = p.save_state().expect("state");
|
||||
let mut q = soundcraft_vst3_host::create(&id).unwrap();
|
||||
assert!(q.load_state(&blob));
|
||||
assert!((q.param("7").unwrap() - 1.5).abs() < 1e-4, "{:?}", q.param("7"));
|
||||
assert!((q.param("12").unwrap() - 200.0).abs() < 0.01, "{:?}", q.param("12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
|
||||
let id = id_of("Fixture Gain");
|
||||
|
|
|
|||
|
|
@ -38,7 +38,13 @@ fn main() {
|
|||
assert!(up_front.status.success(), "shutdown before exit: {:?}\n{}", up_front.status, String::from_utf8_lossy(&up_front.stderr));
|
||||
let too_late = run("exit-handler");
|
||||
let err = String::from_utf8_lossy(&too_late.stderr);
|
||||
assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}");
|
||||
assert!(err.contains("module exit after the process exit tore down state"), "{err}");
|
||||
if cfg!(windows) {
|
||||
// Windows runs the executable's exit handlers before a DLL's own, so the fixture's late
|
||||
// state is still there when the host's exit handler runs its module exit.
|
||||
assert!(too_late.status.success(), "the exit handler alone: {:?}\n{err}", too_late.status);
|
||||
} else {
|
||||
assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}");
|
||||
assert!(err.contains("module exit after the process exit tore down state"), "{err}");
|
||||
}
|
||||
println!("module_exit_shutdown: ok");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ at 0.5–1.5 h each, features at about 200–300 tested lines per agent-hour, fr
|
|||
15–25, onboarding and user guide 15–25, save fixes 5–10.
|
||||
|
||||
The previous figure in ROADMAP.md (2026-10-07: "~65 % overall feature parity", "110–150 h to 100 %")
|
||||
measured feature *presence*. This pass measures readiness, and found new evidence that pulls the
|
||||
measured feature *presence*. This pass measures readiness, and finds new evidence that pulls the
|
||||
numbers down: 37 open GitHub issues from the first public week (about 25 of them correctness bugs
|
||||
in features counted as done), no MIDI hardware input or output at all, no audio device or buffer
|
||||
size selection, no localization, and a macOS release that cannot load signed third-party VST3s
|
||||
|
|
@ -83,7 +83,7 @@ post-production, tracking) can switch. "Ready" is depth, correctness and fidelit
|
|||
| UI/UX fidelity | 10 % | **~55 %** | estimated | 60–100 | [ui-parity.md](ui-parity.md) | Edit/Mix windows, every menu, floating windows; open UX bugs #3 #5 #6 #7 #140 #144; shortcuts only recently right on Windows/Linux (#15, #41); generic plugin UIs |
|
||||
| File formats | 10 % | **~40 %** | estimated | 70–120 | [file-format-parity.md](file-format-parity.md) | WAV/BWF/RF64/AIFF/FLAC read+write, compressed formats read; no `.ptx`, AAF, OMF, ADM BWF, MP3/AAC export, video export |
|
||||
| Hardware | 10 % | **~12 %** | estimated | 110–180 | [hardware-parity.md](hardware-parity.md) | Default output and default input device only, fixed 512-sample block, no ASIO, no MIDI devices, no control surfaces, no sync |
|
||||
| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), X11 llvmpipe failure (#107) |
|
||||
| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), an X11 llvmpipe failure (#107) |
|
||||
| Performance | 5 % | **~35 %** | estimated (unmeasured) | 40–70 | [hardware-parity.md](hardware-parity.md) | Parallel strip rendering and PDC exist; whole files decoded into RAM, recording into RAM (#84 open), no track-count or latency benchmarks |
|
||||
| Ecosystem / plugins | 4 % | **~30 %** | estimated | in Features + Hardware | [plugin-parity.md](plugin-parity.md) | CLAP/VST3/AU hosting; no AAX (Pro Tools' only format), no ARA, no sidechain, no control-surface ecosystem, no content library |
|
||||
| Platforms | 3 % | **~65 %** | estimated | 20–40 | [ROADMAP.md](../ROADMAP.md) | Builds for macOS, Windows x64/x86/arm64, Linux, FreeBSD, Web (Pro Tools: macOS, Windows), but only macOS is exercised at runtime; Web lacks file pickers (#63, #64) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue