Merge main into #259

# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
This commit is contained in:
Brandon Thomas 2026-10-13 04:30:18 -04:00
commit f4233dc8f7
69 changed files with 1858 additions and 225 deletions

36
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

View File

@ -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

View File

@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **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-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249)
- **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
@ -144,6 +145,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: timecode overflow (#248) |
| 2026-10-12 | trivial | Progress log: bar offset saturates (#249) |
| 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 |

View File

@ -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 {

View File

@ -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())

View File

@ -41,6 +41,9 @@ real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false

View File

@ -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,
}

View File

@ -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);

View File

@ -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]

View File

@ -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 {

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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
}
}

View File

@ -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)];

View File

@ -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");

View File

@ -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();

View File

@ -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)]);

View File

@ -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)]);
}
}

View File

@ -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)]);

View File

@ -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"));
}
}

View File

@ -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,

View File

@ -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();

View File

@ -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));
}
}

View File

@ -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)}))
}),
]
}

View File

@ -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");
}

View File

@ -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));
}
}

View File

@ -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();

View File

@ -221,6 +221,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
n += 1;
}
}
pl.sort();
n
}

View File

@ -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(),

View File

@ -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);
}

View File

@ -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));
}

View File

@ -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);
}

View File

@ -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");
}

View File

@ -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)]);
}

View File

@ -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()
}

View File

@ -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)));

View File

@ -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);

View File

@ -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};

View File

@ -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])

View File

@ -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};

View File

@ -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]
@ -157,8 +166,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}");
}
}
@ -167,9 +176,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]

View File

@ -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));
}
}

View File

@ -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": "{}"}));
}
}

View File

@ -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(&param)).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);
}
}

View File

@ -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(),
}
}

View File

@ -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!({})

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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);

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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()

View File

@ -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");
}

View File

@ -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"));
}
}

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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 { .. }));
}

View File

@ -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);
}

View File

@ -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");
}
}

View File

@ -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);

View File

@ -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];

View File

@ -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);
}
}

View File

@ -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()
}

View File

@ -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

View File

@ -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");

View File

@ -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");
}

View File

@ -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) |