diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 302d1eb3..7f942841 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -140,9 +140,15 @@ fn process(e: &mut Engine, p: &Value) -> Result { fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result { let sr = e.session().sample_rate; let tracks = tracks_param(e, "audiosuite.process", p)?; + let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES; // Refuse before generating anything, rather than cut the clip short. for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) { - crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?; + let channels = t.channels().max(1); + crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(channels))?; + // Each track keeps its generated buffer for the life of the command (and undo holds it), so + // cap the total samples generated across tracks, not just each one. + let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels)); + budget = budget.checked_sub(frames * channels).ok_or_else(|| bad("audiosuite.process", "too much audio to generate in one command"))?; } let mut clips = Vec::new(); for track in tracks { @@ -257,4 +263,43 @@ mod tests { let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect(); assert_eq!(ranges, vec![(0, 32), (32, 64)]); } + + #[test] + fn duplicate_track_ids_generate_once_not_per_listing() { + let mut e = Engine::new(Session::default()); + let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone")); + e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 4800})).unwrap(); + // The same track eight times over: it must generate once, not eight times (#896). + let out = e + .execute( + "audiosuite.process", + &json!({"process": "signal_generator", "tracks": [t.0, t.0, t.0, t.0, t.0, t.0, t.0, t.0], "params": {"freq": 440.0}}), + ) + .unwrap(); + assert_eq!(out["processed"], 1); + assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing"); + } + + #[test] + fn generate_refuses_a_total_budget_across_tracks() { + let mut e = Engine::new(Session::default()); + // Two tracks each near the per-track limit: the second would push the total past the budget. + for n in 0..2 { + e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("T{n}"))); + } + let t: Vec<_> = e.session().tracks.iter().map(|t| t.id.0).collect(); + // Select both tracks so signal_generator targets them. + e.execute("edit.select", &json!({"tracks": [t[0], t[1]], "start": 0, "end": 4800})).unwrap(); + e.execute("audiosuite.process", &serde_json::json!({"process": "signal_generator", "track": t[0], "params": {"freq": 440.0}})).unwrap(); + assert_eq!(e.session().tracks[0].clips().len(), 1, "one track generates one clip"); + // Two distinct tracks over the total budget are refused (MAX_RENDER_SAMPLES is f32 samples + // across all channels; a single track over the limit is already refused per-track). + let len = (soundcraft_mix::MAX_RENDER_SAMPLES as i64) / 3 * 2; + let out = e.execute( + "audiosuite.process", + &serde_json::json!({"process": "signal_generator", "tracks": [t[0], t[1]], "params": {"freq": 440.0}, "start": 0, "end": len}), + ); + let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too long to generate") || m.contains("too much audio")); + assert!(ok, "two tracks over budget, but got: {out:?}"); + } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b6f379bd..a0149556 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -224,13 +224,19 @@ pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result> { if let Some(arr) = p.get("tracks").and_then(Value::as_array) { let s = e.session(); let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); for v in arr { let id = match (v.as_u64(), v.as_str()) { (Some(n), _) => s.track(TrackId(n)).map(|t| t.id), (_, Some(name)) => s.track_by_name(name).map(|t| t.id), _ => None, }; - out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?); + let id = id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?; + // Duplicates multiply the work (each entry renders and keeps its own result), so drop + // repeated ids instead of letting them blow the budget. + if seen.insert(id) { + out.push(id); + } } return Ok(out); }