This commit is contained in:
Brandon Thomas committed 2026-10-13 04:59:31 -04:00
commit b4f7c4af6d
5 files changed
+94 -11

No files matched your search

+5
View File
@@ -301,6 +301,11 @@ pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
plugins::REGISTRY.iter().copied().find(|i| i.id == id)
}
/// Whether a plugin makes sound instead of processing its input (it ignores the input).
pub fn is_generator(id: &str) -> bool {
id == "signal_generator"
}
/// Silence floor used by the dB helpers.
pub const MIN_DB: f32 = -144.0;
+2 -2
View File
@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::f32::consts::TAU;
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, plugin_info, plugins};
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, is_generator, plugin_info, plugins};
const SR: f32 = 48_000.0;
const BLOCK: usize = 512;
@@ -45,7 +45,7 @@ fn prepared(id: &str) -> Box<dyn Plugin> {
}
fn is_source(id: &str) -> bool {
plugin_info(id).unwrap().is_instrument || id == "signal_generator"
plugin_info(id).unwrap().is_instrument || is_generator(id)
}
#[test]
+73 -8
View File
@@ -4,7 +4,7 @@ use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent};
use soundcraft_model::{Clip, ClipContent, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AudioSuite Process",
[],
None,
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?}",
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)",
has_selection,
process
)]
@@ -35,6 +35,12 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
if !known {
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
}
if soundcraft_dsp::is_generator(&name) && p.get("clips").is_none() && p.get("clip").is_none() {
let r = range_param(e, "audiosuite.process", p)?;
if !r.is_empty() {
return generate(e, p, &name, &params, r);
}
}
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("audiosuite.process", "select audio clips first"));
@@ -95,12 +101,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
"pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf),
"varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to),
plugin => {
let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) };
pl.prepare(srf, 1024, audio.len().max(1));
for (k, v) in &params {
pl.set_param(k, *v);
}
soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf);
apply(plugin, &params, &mut audio, srf)?;
audio
}
};
@@ -131,6 +132,46 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"processed": done, "process": name}))
}
/// Fill `r` on each selected audio track with a new clip from the generator `name`, over
/// whatever was there, like Pro Tools' Signal Generator.
fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
let sr = e.session().sample_rate;
let tracks = tracks_param(e, "audiosuite.process", p)?;
// 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 mut clips = Vec::new();
for track in tracks {
let Some(t) = e.session().track(track).filter(|t| t.kind == TrackKind::Audio) else { continue };
let channels = t.channels().max(1);
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
let clip_name = format!("{}-{}", t.name, short(name));
let mut audio = vec![vec![0.0; frames]; channels];
apply(name, params, &mut audio, sr.as_f64() as f32)?;
let s = e.session_mut();
let src = crate::io::add_source(s, &clip_name, AudioBuffer { sample_rate: sr.hz(), channels: audio }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, track, Clip::audio(id, &clip_name, src, 0, r.start, i64::try_from(frames).unwrap_or(0)));
clips.push(id.0);
}
if clips.is_empty() {
return Err(bad("audiosuite.process", "select an audio track to generate on"));
}
Ok(json!({"processed": clips.len(), "process": name, "clips": clips}))
}
/// Run plugin `id` with `params` over `audio`, in place.
fn apply(id: &str, params: &[(String, f32)], audio: &mut [Vec<f32>], srf: f32) -> Result<()> {
let Some(mut pl) = soundcraft_dsp::create(id) else { return Err(bad("audiosuite.process", format!("cannot create `{id}`"))) };
pl.prepare(srf, 1024, audio.len().max(1));
for (k, v) in params {
pl.set_param(k, *v);
}
soundcraft_dsp::offline::apply_plugin(audio, pl.as_mut(), srf);
Ok(())
}
fn short(n: &str) -> String {
let s: String = n.split('_').map(|w| w.chars().take(4).collect::<String>()).collect::<Vec<_>>().join("");
s.chars().take(8).collect()
@@ -174,4 +215,28 @@ mod tests {
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
#[test]
fn signal_generator_fills_the_selection_with_a_clip() {
let (mut e, _) = gained_clip();
let tone = e.session().tracks[0].id;
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Empty"));
e.execute("edit.select", &json!({"tracks": [t.0], "start": 48_000, "end": 96_000})).unwrap();
assert!(matches!(e.execute("audiosuite.process", &json!({"process": "normalize"})), Err(crate::EngineError::BadParams(..))));
let generator = json!({"process": "signal_generator", "params": {"freq": 440.0, "level": -6.0}});
assert_eq!(e.execute("audiosuite.process", &generator).unwrap()["processed"], 1);
let c = e.session().tracks[1].clips()[0].clone();
assert_eq!((c.start, c.length), (48_000, 48_000));
let buf = &e.session().pool.get(c.source().unwrap()).unwrap().buffer;
assert_eq!(buf.channels.len(), 2);
let peak = buf.channels[1].iter().fold(0.0f32, |m, x| m.max(x.abs()));
assert!((peak - 0.5).abs() < 0.01, "-6 dB sine peaks near 0.5, got {peak}");
// Over existing audio it replaces just the selection.
e.execute("edit.select", &json!({"tracks": [tone.0], "start": 32, "end": 96})).unwrap();
e.execute("audiosuite.process", &generator).unwrap();
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
assert_eq!(ranges, vec![(0, 32), (32, 64)]);
}
}
+8
View File
@@ -47,3 +47,11 @@ fn audiosuite_refuses_results_longer_than_one_render() {
refused(&mut e, "audiosuite.process", &json!({"process": process, "params": params, "clips": [id.0]}));
}
}
#[test]
fn signal_generator_refuses_a_selection_longer_than_one_render() {
let (mut e, _) = long_clip(48_000);
let end = (MAX_RENDER_SAMPLES / 2 + 1) as i64;
e.execute("edit.select", &json!({"tracks": ["Talk"], "start": 0, "end": end})).unwrap();
refused(&mut e, "audiosuite.process", &json!({"process": "signal_generator"}));
}
+6 -1
View File
@@ -688,7 +688,12 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
}
}
ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone()));
ui.label(egui::RichText::new("Processes the selected clips and replaces them with rendered audio.").small());
let hint = if soundcraft_dsp::is_generator(&process) {
"Fills the selection on the selected tracks with a new clip."
} else {
"Processes the selected clips and replaces them with rendered audio."
};
ui.label(egui::RichText::new(hint).small());
if ui.button(egui::RichText::new("Render").strong()).clicked() {
let p: serde_json::Map<String, Value> = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
let _ = app.run("audiosuite.process", json!({"process": process, "params": p}));