Parallel mixer, Setup windows, clip gain lines, folders, mix-strip sections, docs
- mix: independent strips process in parallel (rayon, native), parameter pushes cached, plugin sync/routing order cached per session structure; plugin and render benchmarks - UI: Playback Engine, I/O Setup (bus rename/delete), Keyboard Shortcuts windows; clip gain line + info; folder open/close; tempo changes from the ruler; Inserts/Sends F-J, EQ curve and comments sections in the Mix window - docs: control protocol and MCP guides Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6cff5ff3d9
commit
a28e15210c
|
|
@ -0,0 +1,28 @@
|
|||
//! Per-plugin realtime cost: `cargo run --release -p soundcraft-dsp --example plugin_bench`
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let sr = 48_000.0;
|
||||
let secs = 10usize;
|
||||
for info in soundcraft_dsp::plugins() {
|
||||
let Some(mut p) = soundcraft_dsp::create(info.id) else { continue };
|
||||
p.prepare(sr, 512, 2);
|
||||
let mut io = vec![vec![0.0f32; 512]; 2];
|
||||
let t0 = Instant::now();
|
||||
let mut ph = 0.0f32;
|
||||
for _ in 0..(secs * 48_000 / 512) {
|
||||
for c in io.iter_mut() {
|
||||
for x in c.iter_mut() {
|
||||
ph += 0.031;
|
||||
*x = ph.sin() * 0.3;
|
||||
}
|
||||
}
|
||||
if info.is_instrument {
|
||||
p.note_on(0, 60, 100);
|
||||
}
|
||||
p.process(&mut io, 512);
|
||||
}
|
||||
let el = t0.elapsed().as_secs_f64();
|
||||
println!("{:<20} {:>7.3}% of one core (stereo, 48 kHz)", info.id, el / secs as f64 * 100.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +118,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}
|
||||
Ok(json!({"deleted": n}))
|
||||
}),
|
||||
cmd!(noundo "track.folder_toggle", "Open/Close Folder", [], None, "{track, open?: bool}", has_tracks, |e, p| {
|
||||
let t = track_param(e, "track.folder_toggle", p, "track")?.ok_or_else(|| bad("track.folder_toggle", "`track` required"))?;
|
||||
let v = p.get("open").and_then(Value::as_bool);
|
||||
let s = e.session_mut();
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("track.folder_toggle", "no track"))?;
|
||||
tr.folder_open = v.unwrap_or(!tr.folder_open);
|
||||
Ok(json!({"open": tr.folder_open}))
|
||||
}),
|
||||
cmd!("track.input", "Track Input", [], None, "{tracks?, input: none|bus name|hardware name}", has_selection, |e, p| route(e, p, true)),
|
||||
cmd!("track.output", "Track Output", [], None, "{tracks?, output: main|none|bus name}", has_selection, |e, p| route(e, p, false)),
|
||||
cmd!("track.timebase", "Track Timebase", [], None, "{tracks?, ticks: bool}", has_selection, |e, p| flag(
|
||||
|
|
|
|||
|
|
@ -15,5 +15,8 @@ soundcraft-dsp.workspace = true
|
|||
soundcraft-model.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
//! Offline mix throughput on a synthetic many-track session:
|
||||
//! `cargo run --release -p soundcraft-mix --example render_bench -- [tracks] [seconds]`
|
||||
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, Insert, Session, SourceAudio, SourceId, TrackKind};
|
||||
use soundcraft_time::Range;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<usize> = std::env::args().skip(1).filter_map(|a| a.parse().ok()).collect();
|
||||
let tracks = args.first().copied().unwrap_or(64);
|
||||
let secs = args.get(1).copied().unwrap_or(30);
|
||||
let mut s = Session::default();
|
||||
let frames = 48_000 * secs;
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![(0..frames).map(|i| ((i as f32) * 0.01).sin() * 0.1).collect()] };
|
||||
s.pool.insert(SourceId(1_000_000), Arc::new(SourceAudio::new(buf)));
|
||||
for i in 0..tracks {
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let id = s.new_clip_id();
|
||||
let tr = s.track_mut(t).expect("track");
|
||||
if let Some(p) = tr.playlist_mut() {
|
||||
p.clips.push(Clip::audio(id, "c", SourceId(1_000_000), 0, 0, frames as i64));
|
||||
}
|
||||
// Every track gets an EQ and a compressor, every 4th a reverb.
|
||||
if std::env::var_os("NO_FX").is_none() {
|
||||
tr.mixer.inserts[0] = Some(Insert::new("eq_7band"));
|
||||
}
|
||||
if std::env::var_os("NO_FX").is_none() {
|
||||
tr.mixer.inserts[1] = Some(Insert::new("compressor"));
|
||||
}
|
||||
if i % 4 == 0 && std::env::var_os("NO_FX").is_none() {
|
||||
tr.mixer.inserts[2] = Some(Insert::new("plate_reverb"));
|
||||
}
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
let out = soundcraft_mix::render_range(&s, Range::new(0, frames as i64), 512);
|
||||
let el = t0.elapsed().as_secs_f64();
|
||||
let peak = out.iter().flat_map(|c| c.iter()).fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
println!("{tracks} tracks × {secs} s with EQ+comp (+reverb on 1/4): rendered in {el:.2} s → {:.1}× realtime (peak {peak:.2})", secs as f64 / el);
|
||||
}
|
||||
|
|
@ -27,19 +27,35 @@ pub struct StripMeter {
|
|||
struct PluginSlot {
|
||||
id: String,
|
||||
plugin: Box<dyn Plugin>,
|
||||
/// Last values pushed to the plugin, in the insert's parameter-map order.
|
||||
applied: Vec<f32>,
|
||||
}
|
||||
|
||||
struct Strip {
|
||||
buf: Vec<Vec<f32>>,
|
||||
/// Pre-fader copy for pre-fader sends.
|
||||
pre: Vec<Vec<f32>>,
|
||||
scratch: Vec<f32>,
|
||||
plugins: Vec<Option<PluginSlot>>,
|
||||
instrument: Option<PluginSlot>,
|
||||
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
|
||||
held: Vec<u8>,
|
||||
muted: bool,
|
||||
gr: f32,
|
||||
}
|
||||
|
||||
impl Strip {
|
||||
fn new() -> Self {
|
||||
Strip { buf: Vec::new(), plugins: Vec::new(), instrument: None, held: Vec::new() }
|
||||
Strip {
|
||||
buf: Vec::new(),
|
||||
pre: Vec::new(),
|
||||
scratch: Vec::new(),
|
||||
plugins: Vec::new(),
|
||||
instrument: None,
|
||||
held: Vec::new(),
|
||||
muted: false,
|
||||
gr: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,12 +66,14 @@ pub struct MixEngine {
|
|||
strips: HashMap<TrackId, Strip>,
|
||||
busses: HashMap<BusId, Vec<Vec<f32>>>,
|
||||
main: Vec<Vec<f32>>,
|
||||
scratch: Vec<f32>,
|
||||
pub meters: HashMap<TrackId, StripMeter>,
|
||||
pub main_meter: StripMeter,
|
||||
pub pan_law: PanLaw,
|
||||
/// Last rendered position end (to detect seeks).
|
||||
last_end: Samples,
|
||||
/// Snapshot key of the session the strips/order were synced to.
|
||||
synced: (usize, usize, usize),
|
||||
order: Vec<usize>,
|
||||
}
|
||||
|
||||
impl MixEngine {
|
||||
|
|
@ -67,11 +85,12 @@ impl MixEngine {
|
|||
strips: HashMap::new(),
|
||||
busses: HashMap::new(),
|
||||
main: vec![vec![0.0; max_block]; MAIN_CHANNELS],
|
||||
scratch: vec![0.0; max_block],
|
||||
meters: HashMap::new(),
|
||||
main_meter: StripMeter::default(),
|
||||
pan_law: PanLaw::Minus3,
|
||||
last_end: 0,
|
||||
synced: (0, 0, 0),
|
||||
order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +132,7 @@ impl MixEngine {
|
|||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin) => {
|
||||
*slot = soundcraft_dsp::create(&i.plugin).map(|mut p| {
|
||||
p.prepare(sr, mb, ch);
|
||||
PluginSlot { id: i.plugin.clone(), plugin: p }
|
||||
PluginSlot { id: i.plugin.clone(), plugin: p, applied: Vec::new() }
|
||||
});
|
||||
}
|
||||
None => *slot = None,
|
||||
|
|
@ -125,7 +144,7 @@ impl MixEngine {
|
|||
strip.instrument = want.and_then(|id| {
|
||||
soundcraft_dsp::create(id).map(|mut p| {
|
||||
p.prepare(sr, mb, ch.max(2));
|
||||
PluginSlot { id: id.to_string(), plugin: p }
|
||||
PluginSlot { id: id.to_string(), plugin: p, applied: Vec::new() }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
@ -149,7 +168,13 @@ impl MixEngine {
|
|||
}
|
||||
}
|
||||
self.last_end = pos.saturating_add(frames as i64);
|
||||
self.sync(s);
|
||||
// Re-sync plugin instances and routing order only when the session snapshot changes.
|
||||
let key = (structure_fingerprint(s), s.tracks.len(), s.busses.len());
|
||||
if key != self.synced {
|
||||
self.sync(s);
|
||||
self.order = processing_order(s);
|
||||
self.synced = key;
|
||||
}
|
||||
for b in self.busses.values_mut() {
|
||||
for c in b.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
|
|
@ -159,14 +184,42 @@ impl MixEngine {
|
|||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
|
||||
let order = processing_order(s);
|
||||
for idx in order {
|
||||
let Some(t) = s.tracks.get(idx) else { continue };
|
||||
if t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca {
|
||||
continue;
|
||||
let order = std::mem::take(&mut self.order);
|
||||
let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
|
||||
let mut work: Vec<(&Track, Strip)> = order
|
||||
.iter()
|
||||
.filter_map(|&i| s.tracks.get(i))
|
||||
.filter(live)
|
||||
.filter(|t| !is_aux(t))
|
||||
.filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st)))
|
||||
.collect();
|
||||
for (_, st) in work.iter_mut() {
|
||||
if st.scratch.len() < self.max_block {
|
||||
st.scratch.resize(self.max_block, 0.0);
|
||||
}
|
||||
self.render_track(s, t, pos, frames, any_solo);
|
||||
}
|
||||
let busses = std::mem::take(&mut self.busses);
|
||||
run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, pos, frames, any_solo));
|
||||
self.busses = busses;
|
||||
for (t, st) in &work {
|
||||
self.route_strip(t, st, pos, frames);
|
||||
}
|
||||
for (t, st) in work {
|
||||
self.strips.insert(t.id, st);
|
||||
}
|
||||
// Phase 2: auxes in dependency order (they read busses).
|
||||
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) {
|
||||
let Some(mut st) = self.strips.remove(&t.id) else { continue };
|
||||
if st.scratch.len() < self.max_block {
|
||||
st.scratch.resize(self.max_block, 0.0);
|
||||
}
|
||||
process_strip(s, t, &mut st, &self.busses, pos, frames, any_solo);
|
||||
self.route_strip(t, &st, pos, frames);
|
||||
self.strips.insert(t.id, st);
|
||||
}
|
||||
self.order = order;
|
||||
// Master faders process the main mix.
|
||||
for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) {
|
||||
if let Some(strip) = self.strips.get_mut(&t.id) {
|
||||
|
|
@ -219,124 +272,23 @@ impl MixEngine {
|
|||
self.main_meter = StripMeter { peak: pk, gain_reduction_db: 0.0 };
|
||||
}
|
||||
|
||||
fn render_track(&mut self, s: &Session, t: &Track, pos: Samples, frames: usize, any_solo: bool) {
|
||||
let Some(strip) = self.strips.get_mut(&t.id) else { return };
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
// 1. Source: clips, bus input or instrument.
|
||||
match t.kind {
|
||||
TrackKind::Audio => {
|
||||
for clip in t.clips() {
|
||||
if clip.muted || !clip.is_audio() {
|
||||
continue;
|
||||
}
|
||||
render_clip(s, clip, pos, frames, &mut strip.buf, &mut self.scratch);
|
||||
}
|
||||
}
|
||||
TrackKind::Aux | TrackKind::Folder => {
|
||||
if let Route::Bus(b) = &t.mixer.input
|
||||
&& let Some(src) = self.busses.get(b)
|
||||
{
|
||||
let nch = strip.buf.len();
|
||||
for (ch, dst) in strip.buf.iter_mut().enumerate() {
|
||||
for i in 0..frames {
|
||||
let v = if nch == 1 {
|
||||
0.5 * (src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0)
|
||||
+ src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0))
|
||||
} else {
|
||||
src.get(ch.min(1)).and_then(|c| c.get(i)).copied().unwrap_or(0.0)
|
||||
};
|
||||
if let Some(d) = dst.get_mut(i) {
|
||||
*d = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackKind::Instrument | TrackKind::Midi => {
|
||||
if let Some(inst) = &mut strip.instrument {
|
||||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||||
let mut tmp = std::mem::take(&mut strip.buf);
|
||||
if tmp.len() >= 2 {
|
||||
inst.plugin.process(&mut tmp, frames);
|
||||
} else if let Some(c0) = tmp.first_mut() {
|
||||
// Mono MIDI track: render the instrument in stereo into scratch and fold.
|
||||
let mut st = [std::mem::take(c0), std::mem::take(&mut self.scratch)];
|
||||
inst.plugin.process(&mut st, frames);
|
||||
let [a, b] = st;
|
||||
*c0 = a;
|
||||
self.scratch = b;
|
||||
for (x, y) in c0.iter_mut().zip(self.scratch.iter()).take(frames) {
|
||||
*x = 0.5 * (*x + *y);
|
||||
}
|
||||
}
|
||||
strip.buf = tmp;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// 2. Trim + phase.
|
||||
let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 };
|
||||
if (trim - 1.0).abs() > f32::EPSILON {
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x *= trim);
|
||||
}
|
||||
}
|
||||
// 3. Inserts.
|
||||
let mut gr = 0.0f32;
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass {
|
||||
continue;
|
||||
}
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
slot.plugin.process(&mut strip.buf, frames);
|
||||
if gr == 0.0 {
|
||||
gr = slot.plugin.gain_reduction_db();
|
||||
}
|
||||
}
|
||||
// 4. Mute (static, automation, solo).
|
||||
let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5);
|
||||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||||
let muted = t.mixer.mute || auto_mute || soloed_out;
|
||||
// Pre-fader sends.
|
||||
for (i, snd) in t.mixer.sends.iter().enumerate() {
|
||||
let Some(snd) = snd else { continue };
|
||||
if !snd.pre_fader || snd.mute || muted {
|
||||
continue;
|
||||
}
|
||||
let lvl = send_level(t, i, snd.level_db, pos);
|
||||
mix_to_bus(&strip.buf, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan);
|
||||
}
|
||||
// 5. Fader.
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, pos + frames as i64);
|
||||
let (g0, g1) = if muted { (-144.0, -144.0) } else { (v0, v1) };
|
||||
for c in strip.buf.iter_mut() {
|
||||
for i in 0..frames {
|
||||
if let Some(x) = c.get_mut(i) {
|
||||
*x *= lerp_gain(g0, g1, i, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Meters (post-fader, pre-pan).
|
||||
/// Send, meter and pan a processed strip into the busses / main mix.
|
||||
fn route_strip(&mut self, t: &Track, strip: &Strip, pos: Samples, frames: usize) {
|
||||
let mut pk = [0.0f32; 2];
|
||||
for (c, p) in pk.iter_mut().enumerate() {
|
||||
let ch = c.min(strip.buf.len().saturating_sub(1));
|
||||
*p = strip.buf.get(ch).map_or(0.0, |b| peak(b.get(..frames).unwrap_or(&[])));
|
||||
}
|
||||
self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: gr });
|
||||
// Post-fader sends.
|
||||
self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: strip.gr });
|
||||
for (i, snd) in t.mixer.sends.iter().enumerate() {
|
||||
let Some(snd) = snd else { continue };
|
||||
if snd.pre_fader || snd.mute || muted {
|
||||
if snd.mute || strip.muted {
|
||||
continue;
|
||||
}
|
||||
let lvl = send_level(t, i, snd.level_db, pos);
|
||||
mix_to_bus(&strip.buf, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan);
|
||||
let src = if snd.pre_fader { &strip.pre } else { &strip.buf };
|
||||
mix_to_bus(src, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan);
|
||||
}
|
||||
// 6. Pan to output.
|
||||
let pans: [f32; 2] = [pan_at(t, 0, pos), pan_at(t, 1, pos)];
|
||||
match &t.mixer.output {
|
||||
Route::Main => pan_into(&strip.buf, frames, &mut self.main, &pans, self.pan_law),
|
||||
|
|
@ -350,6 +302,143 @@ impl MixEngine {
|
|||
}
|
||||
}
|
||||
|
||||
/// Source → trim → inserts → (pre-fader copy) → mute/fader for one strip. Independent of other
|
||||
/// strips except that aux inputs read `busses`, so non-aux strips can run in parallel.
|
||||
fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap<BusId, Vec<Vec<f32>>>, pos: Samples, frames: usize, any_solo: bool) {
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
match t.kind {
|
||||
TrackKind::Audio => {
|
||||
for clip in t.clips() {
|
||||
if clip.muted || !clip.is_audio() {
|
||||
continue;
|
||||
}
|
||||
render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch);
|
||||
}
|
||||
}
|
||||
TrackKind::Aux | TrackKind::Folder => {
|
||||
if let Route::Bus(b) = &t.mixer.input
|
||||
&& let Some(src) = busses.get(b)
|
||||
{
|
||||
let nch = strip.buf.len();
|
||||
for (ch, dst) in strip.buf.iter_mut().enumerate() {
|
||||
for i in 0..frames {
|
||||
let v = if nch == 1 {
|
||||
0.5 * (src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0)
|
||||
+ src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0))
|
||||
} else {
|
||||
src.get(ch.min(1)).and_then(|c| c.get(i)).copied().unwrap_or(0.0)
|
||||
};
|
||||
if let Some(d) = dst.get_mut(i) {
|
||||
*d = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackKind::Instrument | TrackKind::Midi => {
|
||||
if let Some(inst) = &mut strip.instrument {
|
||||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||||
if strip.buf.len() >= 2 {
|
||||
inst.plugin.process(&mut strip.buf, frames);
|
||||
} else if let Some(c0) = strip.buf.first_mut() {
|
||||
// Mono MIDI track: render the instrument in stereo and fold.
|
||||
let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)];
|
||||
inst.plugin.process(&mut st, frames);
|
||||
let [a, b] = st;
|
||||
*c0 = a;
|
||||
strip.scratch = b;
|
||||
for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) {
|
||||
*x = 0.5 * (*x + *y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 };
|
||||
if (trim - 1.0).abs() > f32::EPSILON {
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x *= trim);
|
||||
}
|
||||
}
|
||||
let mut gr = 0.0f32;
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass {
|
||||
continue;
|
||||
}
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
slot.plugin.process(&mut strip.buf, frames);
|
||||
if gr == 0.0 {
|
||||
gr = slot.plugin.gain_reduction_db();
|
||||
}
|
||||
}
|
||||
strip.gr = gr;
|
||||
let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5);
|
||||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||||
strip.muted = t.mixer.mute || auto_mute || soloed_out;
|
||||
if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {
|
||||
strip.pre.resize_with(strip.buf.len(), Vec::new);
|
||||
for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) {
|
||||
d.clear();
|
||||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||||
}
|
||||
}
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, pos + frames as i64);
|
||||
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0, v1) };
|
||||
for c in strip.buf.iter_mut() {
|
||||
for i in 0..frames {
|
||||
if let Some(x) = c.get_mut(i) {
|
||||
*x *= lerp_gain(g0, g1, i, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash of everything `sync`/`processing_order` depend on (tracks, kinds, routing, plugins, sends).
|
||||
fn structure_fingerprint(s: &Session) -> usize {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for t in &s.tracks {
|
||||
t.id.hash(&mut h);
|
||||
t.kind.hash(&mut h);
|
||||
t.format.hash(&mut h);
|
||||
t.inactive.hash(&mut h);
|
||||
t.mixer.input.hash(&mut h);
|
||||
t.mixer.output.hash(&mut h);
|
||||
for i in &t.mixer.inserts {
|
||||
i.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
|
||||
}
|
||||
for snd in t.mixer.sends.iter().flatten() {
|
||||
snd.target.hash(&mut h);
|
||||
}
|
||||
t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
|
||||
}
|
||||
for b in &s.busses {
|
||||
b.id.hash(&mut h);
|
||||
}
|
||||
h.finish() as usize
|
||||
}
|
||||
|
||||
/// Process strips, in parallel on native targets when there are enough of them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
|
||||
use rayon::prelude::*;
|
||||
if work.len() >= 4 {
|
||||
work.par_iter_mut().for_each(f);
|
||||
} else {
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
|
||||
fn strip_channels(t: &Track) -> usize {
|
||||
match t.kind {
|
||||
TrackKind::Master => MAIN_CHANNELS,
|
||||
|
|
@ -484,18 +573,23 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
|
|||
}
|
||||
|
||||
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
|
||||
for (k, v) in &ins.params {
|
||||
let slot_u8 = u8::try_from(i).unwrap_or(0);
|
||||
let automated = t.mixer.automation_mode.reads()
|
||||
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
|
||||
if slot.applied.len() != ins.params.len() {
|
||||
slot.applied = vec![f32::NAN; ins.params.len()];
|
||||
}
|
||||
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
|
||||
let mut val = *v;
|
||||
if t.mixer.automation_mode.reads() {
|
||||
let slot_u8 = u8::try_from(i).unwrap_or(0);
|
||||
if let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
val = l.value_at(pos, val);
|
||||
}
|
||||
if automated
|
||||
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
val = l.value_at(pos, val);
|
||||
}
|
||||
if slot.plugin.param(k) != Some(val) {
|
||||
if val.to_bits() != last.to_bits() {
|
||||
slot.plugin.set_param(k, val);
|
||||
*last = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ pub const COLUMN_W: f32 = 112.0;
|
|||
/// Optional Edit-window columns (View › Edit Window Views) that are switched on.
|
||||
pub fn edit_columns(s: &Session) -> Vec<&'static str> {
|
||||
let all = s.edit.flag("edit_view.all");
|
||||
["io", "inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "comments"].into_iter().filter(|c| all || s.edit.flag(&format!("edit_view.{c}"))).collect()
|
||||
["io", "inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "comments"]
|
||||
.into_iter()
|
||||
.filter(|c| all || s.edit.flag(&format!("edit_view.{c}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn header_width(s: &Session) -> f32 {
|
||||
|
|
@ -49,7 +52,12 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui));
|
||||
egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui));
|
||||
if app.ui.show_midi_editor {
|
||||
egui::Panel::bottom("midi_editor").resizable(true).default_size(320.0).min_size(260.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| crate::midi_editor::show(app, ui));
|
||||
egui::Panel::bottom("midi_editor")
|
||||
.resizable(true)
|
||||
.default_size(320.0)
|
||||
.min_size(260.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel_bg))
|
||||
.show(ui, |ui| crate::midi_editor::show(app, ui));
|
||||
}
|
||||
if app.ui.show_tracks_list {
|
||||
egui::Panel::left("tracks_list")
|
||||
|
|
@ -67,7 +75,11 @@ fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let t = Tokens::DARK;
|
||||
let r = ui.max_rect();
|
||||
let mut x = r.min.x + 10.0;
|
||||
for (label, on, id) in [("MIDI EDITOR", app.ui.show_midi_editor, "window.midi_editor"), ("MEMORY LOCATIONS", app.ui.show_memory_locations, "window.memory_locations"), ("UNDO HISTORY", app.ui.show_undo_history, "window.undo_history")] {
|
||||
for (label, on, id) in [
|
||||
("MIDI EDITOR", app.ui.show_midi_editor, "window.midi_editor"),
|
||||
("MEMORY LOCATIONS", app.ui.show_memory_locations, "window.memory_locations"),
|
||||
("UNDO HISTORY", app.ui.show_undo_history, "window.undo_history"),
|
||||
] {
|
||||
let g = ui.painter().layout_no_wrap(label.to_string(), bold(10.5), if on { Color32::WHITE } else { t.text_dim });
|
||||
let tr = Rect::from_min_size(pos2(x, r.min.y + 2.0), vec2(g.size().x + 16.0, r.height() - 4.0));
|
||||
if on {
|
||||
|
|
@ -251,6 +263,20 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
|
|||
match id.as_str() {
|
||||
"tempo" => {
|
||||
painter.rect_filled(row.shrink2(vec2(0.0, 1.0)), 0.0, t.tempo_ruler);
|
||||
let tresp = ui.interact(row, ui.id().with("tempo_ruler"), Sense::click());
|
||||
if tresp.double_clicked()
|
||||
&& let Some(p) = tresp.interact_pointer_pos()
|
||||
{
|
||||
let at = snap(&s, sample_at(&s, tl, p.x).max(0));
|
||||
let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr));
|
||||
app.dialogs.open = Some(crate::dialogs::Dialog::Number {
|
||||
cmd: format!("event.tempo@{at}"),
|
||||
title: "Tempo Change (BPM)".into(),
|
||||
key: "bpm".into(),
|
||||
value: bpm,
|
||||
suffix: "bpm".into(),
|
||||
});
|
||||
}
|
||||
for ev in s.tempo.tempos() {
|
||||
let x = x_of(&s, tl, s.tempo.tick_to_samples(ev.tick, sr));
|
||||
painter.add(Shape::convex_polygon(
|
||||
|
|
@ -549,6 +575,13 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
|
|||
}
|
||||
}
|
||||
});
|
||||
if track.is_folder() {
|
||||
let fr = Rect::from_min_size(pos2(head.min.x + 6.0, name_r.min.y), vec2(10.0, 17.0));
|
||||
icons::draw(ui.painter(), fr, if track.folder_open { "triangle_down" } else { "triangle_right" }, t.text);
|
||||
if ui.interact(fr, ui.id().with(("fold", id.0)), Sense::click()).clicked() {
|
||||
let _ = app.run("track.folder_toggle", json!({"track": id.0}));
|
||||
}
|
||||
}
|
||||
let kind_label = match track.kind {
|
||||
TrackKind::Audio => "",
|
||||
TrackKind::Aux => "AUX",
|
||||
|
|
@ -673,14 +706,28 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R
|
|||
"inserts_ae" | "inserts_fj" => {
|
||||
let off = if col == "inserts_ae" { 0 } else { 5 };
|
||||
for i in 0..5.min(rows) {
|
||||
let name = track.mixer.inserts.get(off + i).cloned().flatten().and_then(|x| soundcraft_dsp::plugin_info(&x.plugin).map(|p| p.name)).unwrap_or("");
|
||||
let name = track
|
||||
.mixer
|
||||
.inserts
|
||||
.get(off + i)
|
||||
.cloned()
|
||||
.flatten()
|
||||
.and_then(|x| soundcraft_dsp::plugin_info(&x.plugin).map(|p| p.name))
|
||||
.unwrap_or("");
|
||||
text(ui, item(i), name, t.text);
|
||||
}
|
||||
}
|
||||
"sends_ae" | "sends_fj" => {
|
||||
let off = if col == "sends_ae" { 0 } else { 5 };
|
||||
for i in 0..5.min(rows) {
|
||||
let s = track.mixer.sends.get(off + i).cloned().flatten().map(|x| format!("{} {:.0}", route(&x.target), x.level_db.max(-99.0))).unwrap_or_default();
|
||||
let s = track
|
||||
.mixer
|
||||
.sends
|
||||
.get(off + i)
|
||||
.cloned()
|
||||
.flatten()
|
||||
.map(|x| format!("{} {:.0}", route(&x.target), x.level_db.max(-99.0)))
|
||||
.unwrap_or_default();
|
||||
text(ui, item(i), &s, t.text);
|
||||
}
|
||||
}
|
||||
|
|
@ -911,6 +958,27 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
|||
}
|
||||
}
|
||||
}
|
||||
// Clip gain line.
|
||||
let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain");
|
||||
if show_gain && body_r.height() > 12.0 {
|
||||
let gy = |db: f32| body_r.max.y - 3.0 - ((db.clamp(-36.0, 36.0) + 36.0) / 72.0) * (body_r.height() - 6.0);
|
||||
let col = Color32::from_rgba_unmultiplied(255, 255, 255, 170);
|
||||
if clip.gain_env.is_empty() {
|
||||
painter.line_segment([pos2(x0, gy(clip.gain_db)), pos2(x1, gy(clip.gain_db))], Stroke::new(1.0, col));
|
||||
} else {
|
||||
let mut pts = vec![pos2(x0, gy(clip.gain_db + clip.gain_env.first().map_or(0.0, |p| p.1)))];
|
||||
for (o, db) in &clip.gain_env {
|
||||
let p = pos2(x_of(s, tl, clip.start + o), gy(clip.gain_db + db));
|
||||
pts.push(p);
|
||||
painter.circle_filled(p, 2.5, col);
|
||||
}
|
||||
pts.push(pos2(x1, gy(clip.gain_db + clip.gain_env.last().map_or(0.0, |p| p.1))));
|
||||
painter.add(Shape::line(pts, Stroke::new(1.0, col)));
|
||||
}
|
||||
if s.edit.flag("view.clip.gain_info") || clip.gain_db.abs() > 0.01 {
|
||||
painter.text(pos2(r.max.x - 4.0, body_r.max.y - 8.0), Align2::RIGHT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col);
|
||||
}
|
||||
}
|
||||
// Fades.
|
||||
let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90);
|
||||
if clip.fade_in.len > 0 {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ pub struct UiState {
|
|||
pub show_midi_keyboard: bool,
|
||||
pub show_workspace: bool,
|
||||
pub show_configurations: bool,
|
||||
pub show_playback_engine: bool,
|
||||
pub show_io_setup: bool,
|
||||
pub show_shortcuts: bool,
|
||||
pub workspace_dir: String,
|
||||
pub configurations: Vec<(String, Value)>,
|
||||
}
|
||||
|
|
@ -104,6 +107,9 @@ impl Default for UiState {
|
|||
show_midi_keyboard: false,
|
||||
show_workspace: false,
|
||||
show_configurations: false,
|
||||
show_playback_engine: false,
|
||||
show_io_setup: false,
|
||||
show_shortcuts: false,
|
||||
workspace_dir: String::new(),
|
||||
configurations: Vec::new(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
|||
("window.arrange_cascade", "Cascade", "Window > Arrange > Cascade", None),
|
||||
("window.midi_front", "MIDI Editors to Front", "Window > MIDI Editors > Bring to Front", None),
|
||||
("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None),
|
||||
("window.playback_engine", "Playback Engine", "", None),
|
||||
("window.io_setup", "I/O Setup", "", None),
|
||||
("window.shortcuts", "Keyboard Shortcuts", "", None),
|
||||
("view.mix_section", "Mix Window View", "", None),
|
||||
("ui.new_tracks_dialog", "New Tracks…", "", None),
|
||||
("ui.bounce_dialog", "Bounce Mix…", "", None),
|
||||
|
|
@ -61,6 +64,7 @@ pub fn ui_aliases() -> Vec<(&'static str, &'static str)> {
|
|||
v.push((Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()), "view.mix_section"));
|
||||
}
|
||||
v.extend(AUDIOSUITE.iter().map(|(p, _)| (*p, "audiosuite.process")));
|
||||
v.extend(MENU_WINDOWS.iter().copied());
|
||||
v
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +204,7 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
|
|||
});
|
||||
return;
|
||||
}
|
||||
let id = catalog::implemented_by(&n.path, extra);
|
||||
let id = catalog::implemented_by(&n.path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == n.path).map(|(_, w)| w.to_string()));
|
||||
let enabled = match id.as_deref() {
|
||||
Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true,
|
||||
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
|
||||
|
|
@ -299,8 +303,23 @@ fn params_for(path: &str, id: &str) -> Value {
|
|||
}
|
||||
}
|
||||
|
||||
/// Menu items that open a SoundCraft window when clicked (programmatic calls still run the command).
|
||||
const MENU_WINDOWS: &[(&str, &str)] = &[
|
||||
("Setup > Hardware...", "window.playback_engine"),
|
||||
("Setup > Playback Engine...", "window.playback_engine"),
|
||||
("Setup > I/O...", "window.io_setup"),
|
||||
("Setup > Keyboard Shortcuts...", "window.shortcuts"),
|
||||
("Setup > Session", "setup.session"),
|
||||
];
|
||||
|
||||
/// Menu-style invocation: may open a dialog.
|
||||
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
|
||||
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) {
|
||||
if w.starts_with("window.") {
|
||||
let _ = app.run(w, json!({"value": true}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if id == "audiosuite.process" {
|
||||
let p = params_for(path, id);
|
||||
app.ui.audiosuite = p.get("process").and_then(Value::as_str).map(str::to_string);
|
||||
|
|
@ -352,6 +371,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
|||
json!({})
|
||||
}
|
||||
"window.automation" => toggle(&mut app.ui.show_automation),
|
||||
"window.playback_engine" => toggle(&mut app.ui.show_playback_engine),
|
||||
"window.io_setup" => toggle(&mut app.ui.show_io_setup),
|
||||
"window.shortcuts" => toggle(&mut app.ui.show_shortcuts),
|
||||
"window.color_palette" => toggle(&mut app.ui.show_color_palette),
|
||||
"window.disk_usage" => toggle(&mut app.ui.show_disk_usage),
|
||||
"window.system_usage" => toggle(&mut app.ui.show_system_usage),
|
||||
|
|
|
|||
|
|
@ -67,7 +67,13 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return };
|
||||
let ClipContent::Midi { sequence } = &clip.content else { return };
|
||||
let track_color = app.engine.session().track(track).map_or([120, 120, 200], |t| t.color);
|
||||
ui.painter().text(pos2(header.min.x + 110.0, header.center().y), Align2::LEFT_CENTER, format!("{} · {} notes · click: add · drag: move · edge: resize · Delete: remove", clip.name, sequence.notes.len()), regular(11.0), t.text_dim);
|
||||
ui.painter().text(
|
||||
pos2(header.min.x + 110.0, header.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("{} · {} notes · click: add · drag: move · edge: resize · Delete: remove", clip.name, sequence.notes.len()),
|
||||
regular(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
let s = app.engine.session().clone();
|
||||
let sr = s.sample_rate;
|
||||
let base_tick = s.tempo.samples_to_ticks(clip.start, sr);
|
||||
|
|
@ -117,7 +123,13 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0));
|
||||
painter.rect_filled(key, 1.0, if is_black(pitch) { Color32::from_rgb(20, 20, 20) } else { Color32::from_rgb(225, 225, 225) });
|
||||
if pitch.rem_euclid(12) == 0 && (0..=127).contains(&pitch) {
|
||||
painter.text(pos2(key.max.x - 3.0, key.center().y), Align2::RIGHT_CENTER, soundcraft_midi::note_name(pitch as u8), regular(8.0), Color32::from_rgb(40, 40, 40));
|
||||
painter.text(
|
||||
pos2(key.max.x - 3.0, key.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
soundcraft_midi::note_name(pitch as u8),
|
||||
regular(8.0),
|
||||
Color32::from_rgb(40, 40, 40),
|
||||
);
|
||||
painter.line_segment([pos2(roll.min.x, y + ROW_H), pos2(roll.max.x, y + ROW_H)], Stroke::new(1.0, Color32::from_rgb(64, 64, 68)));
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +139,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let bar = tick % (TICKS_PER_QUARTER * 4) == 0;
|
||||
let x = x_of(tick);
|
||||
if grid_ticks as f32 * px_per_tick > 4.0 || bar {
|
||||
painter.line_segment([pos2(x, roll.min.y), pos2(x, roll.max.y)], Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }));
|
||||
painter.line_segment(
|
||||
[pos2(x, roll.min.y), pos2(x, roll.max.y)],
|
||||
Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }),
|
||||
);
|
||||
}
|
||||
tick += grid_ticks;
|
||||
}
|
||||
|
|
@ -151,14 +166,21 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let r = Rect::from_min_max(pos2(x_of(st), y_of(p) + 1.0), pos2(x_of(st + ln).max(x_of(st) + 3.0), y_of(p) + ROW_H - 1.0));
|
||||
let sel = app.midi.selected.contains(&i);
|
||||
let k = 0.45 + 0.55 * f32::from(n.velocity) / 127.0;
|
||||
let fill = if sel { Color32::WHITE } else { Color32::from_rgb((f32::from(base.r()) * k) as u8, (f32::from(base.g()) * k) as u8, (f32::from(base.b()) * k) as u8) };
|
||||
let fill = if sel {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
Color32::from_rgb((f32::from(base.r()) * k) as u8, (f32::from(base.g()) * k) as u8, (f32::from(base.b()) * k) as u8)
|
||||
};
|
||||
painter.rect(r, 2.0, fill, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
|
||||
}
|
||||
// Interaction.
|
||||
let resp = ui.interact(roll, ui.id().with(("roll", cid.0)), Sense::click_and_drag());
|
||||
let hit = |pos: egui::Pos2| -> Option<(usize, bool)> {
|
||||
sequence.notes.iter().enumerate().rev().find_map(|(i, n)| {
|
||||
let r = Rect::from_min_max(pos2(x_of(n.start), y_of(i32::from(n.pitch))), pos2(x_of(n.start + n.length).max(x_of(n.start) + 3.0), y_of(i32::from(n.pitch)) + ROW_H));
|
||||
let r = Rect::from_min_max(
|
||||
pos2(x_of(n.start), y_of(i32::from(n.pitch))),
|
||||
pos2(x_of(n.start + n.length).max(x_of(n.start) + 3.0), y_of(i32::from(n.pitch)) + ROW_H),
|
||||
);
|
||||
r.contains(pos).then_some((i, pos.x > r.max.x - 5.0))
|
||||
})
|
||||
};
|
||||
|
|
@ -207,7 +229,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
None => {
|
||||
let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127);
|
||||
let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks;
|
||||
let _ = app.run("midi.note_add", json!({"clip": cid.0, "pitch": pitch, "start_ticks": start.max(0), "length_ticks": grid_ticks, "velocity": 100}));
|
||||
let _ = app.run(
|
||||
"midi.note_add",
|
||||
json!({"clip": cid.0, "pitch": pitch, "start_ticks": start.max(0), "length_ticks": grid_ticks, "velocity": 100}),
|
||||
);
|
||||
app.midi.selected.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,28 +59,68 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
|
|||
let x0 = r.min.x + 4.0;
|
||||
let views = app.ui.mix_views.clone();
|
||||
let has = |v: &str| views.iter().any(|x| x == v);
|
||||
// Inserts A-E.
|
||||
if has("inserts_ae") {
|
||||
let all = has("all");
|
||||
for (key, title, off, is_send) in [
|
||||
("inserts_ae", "INSERTS A-E", 0usize, false),
|
||||
("inserts_fj", "INSERTS F-J", 5, false),
|
||||
("sends_ae", "SENDS A-E", 0, true),
|
||||
("sends_fj", "SENDS F-J", 5, true),
|
||||
] {
|
||||
if !(all || has(key)) {
|
||||
continue;
|
||||
}
|
||||
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0));
|
||||
ui.painter().rect_filled(sec, 2.0, t.strip_section);
|
||||
section_label(ui, sec, "INSERTS A-E");
|
||||
for slot in 0..5 {
|
||||
let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + slot as f32 * 17.0), vec2(inner_w - 4.0, 15.0));
|
||||
insert_slot(app, ui, &track, slot, sr);
|
||||
section_label(ui, sec, title);
|
||||
for k in 0..5 {
|
||||
let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + k as f32 * 17.0), vec2(inner_w - 4.0, 15.0));
|
||||
if is_send {
|
||||
send_slot(app, ui, &track, off + k, sr);
|
||||
} else {
|
||||
insert_slot(app, ui, &track, off + k, sr);
|
||||
}
|
||||
}
|
||||
y = sec.max.y + 4.0;
|
||||
}
|
||||
if has("sends_ae") {
|
||||
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0));
|
||||
ui.painter().rect_filled(sec, 2.0, t.strip_section);
|
||||
section_label(ui, sec, "SENDS A-E");
|
||||
for slot in 0..5 {
|
||||
let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + slot as f32 * 17.0), vec2(inner_w - 4.0, 15.0));
|
||||
send_slot(app, ui, &track, slot, sr);
|
||||
if all || has("eq_curve") {
|
||||
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 40.0));
|
||||
ui.painter().rect_filled(sec, 2.0, Color32::from_rgb(14, 18, 22));
|
||||
let eq = track.mixer.inserts.iter().flatten().find(|i| i.plugin == "eq_7band" || i.plugin == "eq_1band");
|
||||
if let Some(ins) = eq {
|
||||
let n = 40;
|
||||
let freqs: Vec<f32> = (0..n).map(|i| 20.0 * 1000f32.powf(i as f32 / (n - 1) as f32)).collect();
|
||||
let params: Vec<(&str, f32)> = ins.params.iter().map(|(k, v)| (k.as_str(), *v)).collect();
|
||||
let resp = if ins.plugin == "eq_7band" {
|
||||
soundcraft_dsp::eq7_response(¶ms, &freqs, 48_000.0)
|
||||
} else {
|
||||
soundcraft_dsp::eq1_response(¶ms, &freqs, 48_000.0)
|
||||
};
|
||||
let pts: Vec<egui::Pos2> = resp
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, db)| {
|
||||
pos2(sec.min.x + sec.width() * i as f32 / (n - 1) as f32, sec.center().y - db.clamp(-18.0, 18.0) / 18.0 * sec.height() * 0.45)
|
||||
})
|
||||
.collect();
|
||||
ui.painter().add(egui::Shape::line(pts, Stroke::new(1.5, t.counter_text)));
|
||||
} else {
|
||||
ui.painter().line_segment(
|
||||
[pos2(sec.min.x + 2.0, sec.center().y), pos2(sec.max.x - 2.0, sec.center().y)],
|
||||
Stroke::new(1.0, Color32::from_rgb(60, 70, 80)),
|
||||
);
|
||||
}
|
||||
y = sec.max.y + 4.0;
|
||||
}
|
||||
if has("io") {
|
||||
if all || has("comments") {
|
||||
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 44.0));
|
||||
let mut c = track.comments.clone();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec));
|
||||
if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text("comments").font(regular(10.0))).changed() {
|
||||
let _ = app.engine.execute("track.comments", &json!({"track": id.0, "comments": c}));
|
||||
}
|
||||
y = sec.max.y + 4.0;
|
||||
}
|
||||
if all || has("io") {
|
||||
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0));
|
||||
ui.painter().rect_filled(sec, 2.0, t.strip_section);
|
||||
section_label(ui, sec, "I / O");
|
||||
|
|
@ -270,10 +310,9 @@ fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: R
|
|||
} else {
|
||||
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, label, regular(10.5), t.text);
|
||||
}
|
||||
if resp.clicked() && ins.is_some()
|
||||
&& !app.ui.plugin_windows.contains(&(id, slot)) {
|
||||
app.ui.plugin_windows.push((id, slot));
|
||||
}
|
||||
if resp.clicked() && ins.is_some() && !app.ui.plugin_windows.contains(&(id, slot)) {
|
||||
app.ui.plugin_windows.push((id, slot));
|
||||
}
|
||||
let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text("Click: open plugin · right-click: change") };
|
||||
let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) };
|
||||
popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some()));
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
midi_keyboard(app, ctx);
|
||||
workspace(app, ctx);
|
||||
configurations(app, ctx);
|
||||
playback_engine(app, ctx);
|
||||
io_setup(app, ctx);
|
||||
shortcuts_window(app, ctx);
|
||||
}
|
||||
|
||||
fn win(ctx: &egui::Context, open: &mut bool, title: &str, size: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) {
|
||||
|
|
@ -32,7 +35,15 @@ fn automation(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
ui.label("Write automation for the selected tracks:");
|
||||
let tracks = app.engine.session().edit.selected_tracks.clone();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for m in [AutomationMode::Off, AutomationMode::Read, AutomationMode::Touch, AutomationMode::Latch, AutomationMode::TouchLatch, AutomationMode::Write, AutomationMode::Trim] {
|
||||
for m in [
|
||||
AutomationMode::Off,
|
||||
AutomationMode::Read,
|
||||
AutomationMode::Touch,
|
||||
AutomationMode::Latch,
|
||||
AutomationMode::TouchLatch,
|
||||
AutomationMode::Write,
|
||||
AutomationMode::Trim,
|
||||
] {
|
||||
if ui.button(m.label()).clicked() {
|
||||
let ids: Vec<u64> = tracks.iter().map(|t| t.0).collect();
|
||||
let _ = app.run("mix.automation_mode", json!({"tracks": ids, "mode": m.label()}));
|
||||
|
|
@ -112,7 +123,9 @@ fn task_manager(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
let mut open = app.ui.show_task_manager;
|
||||
win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| {
|
||||
ui.label("No background tasks are running.");
|
||||
ui.label(egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.").small().color(Tokens::DARK.text_dim));
|
||||
ui.label(
|
||||
egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.").small().color(Tokens::DARK.text_dim),
|
||||
);
|
||||
});
|
||||
app.ui.show_task_manager = open;
|
||||
}
|
||||
|
|
@ -131,7 +144,10 @@ 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",
|
||||
soundcraft_time::format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, 0),
|
||||
),
|
||||
] {
|
||||
ui.label(k);
|
||||
ui.label(v);
|
||||
|
|
@ -251,15 +267,20 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
});
|
||||
ui.separator();
|
||||
let mut entries: Vec<(String, bool)> = std::fs::read_dir(&dir)
|
||||
.map(|rd| rd.flatten().filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
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())).then_some((name, is_dir))
|
||||
}).collect())
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
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()))
|
||||
.then_some((name, is_dir))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_lowercase().cmp(&b.0.to_lowercase())));
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
|
|
@ -316,3 +337,92 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
});
|
||||
app.ui.show_configurations = open;
|
||||
}
|
||||
|
||||
/// Setup › Playback Engine / Hardware.
|
||||
pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_playback_engine;
|
||||
win(ctx, &mut open, "Playback Engine", vec2(380.0, 220.0), |ui| {
|
||||
egui::Grid::new("pe").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Output device");
|
||||
ui.label(app.player.as_ref().map_or("none".to_string(), |p| p.device_name.clone()));
|
||||
ui.end_row();
|
||||
ui.label("Device rate");
|
||||
ui.label(app.player.as_ref().map_or("-".to_string(), |p| format!("{} Hz", p.device_rate)));
|
||||
ui.end_row();
|
||||
ui.label("Session rate");
|
||||
ui.label(format!("{} Hz", app.engine.session().sample_rate.hz()));
|
||||
ui.end_row();
|
||||
ui.label("Input device");
|
||||
ui.label(
|
||||
app.recorder
|
||||
.as_ref()
|
||||
.map_or("opened on first record".to_string(), |r| format!("{} ({} ch @ {} Hz)", r.device_name, r.channels, r.sample_rate)),
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("Mix block size");
|
||||
ui.label("512 samples (resampled to the device rate when they differ)");
|
||||
ui.end_row();
|
||||
});
|
||||
let mut dc = app.engine.session().edit.delay_compensation;
|
||||
if ui.checkbox(&mut dc, "Delay compensation").changed() {
|
||||
let _ = app.run("options.delay_compensation", json!({"value": dc}));
|
||||
}
|
||||
if ui.button("Reconnect audio device").clicked() {
|
||||
app.player = Some(soundcraft_playback::Player::new(app.engine.session_arc()));
|
||||
}
|
||||
});
|
||||
app.ui.show_playback_engine = open;
|
||||
}
|
||||
|
||||
/// Setup › I/O: busses and output paths.
|
||||
pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_io_setup;
|
||||
win(ctx, &mut open, "I/O Setup", vec2(420.0, 300.0), |ui| {
|
||||
ui.label(egui::RichText::new("Busses").strong());
|
||||
let busses: Vec<(String, String)> = app.engine.session().busses.iter().map(|b| (b.name.clone(), b.format.label().to_string())).collect();
|
||||
egui::Grid::new("busses").num_columns(3).striped(true).show(ui, |ui| {
|
||||
for (name, fmt) in busses {
|
||||
let key = egui::Id::new(("bus_name", name.clone()));
|
||||
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
|
||||
let r = ui.text_edit_singleline(&mut edit);
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
|
||||
if r.lost_focus() && edit != name {
|
||||
let _ = app.run("mix.bus_rename", json!({"bus": name, "name": edit}));
|
||||
}
|
||||
ui.label(fmt);
|
||||
if ui.small_button("Delete").clicked() {
|
||||
let _ = app.run("mix.bus_delete", json!({"bus": name}));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if ui.button("New Bus").clicked() {
|
||||
let n = app.engine.session().busses.len() + 1;
|
||||
let _ = app.run("mix.new_bus", json!({"name": format!("Bus {n}")}));
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Outputs").strong());
|
||||
for o in app.engine.session().outputs.clone() {
|
||||
ui.label(format!("{} — {} from channel {}", o.name, o.format.label(), o.first_channel + 1));
|
||||
}
|
||||
});
|
||||
app.ui.show_io_setup = open;
|
||||
}
|
||||
|
||||
/// Setup › Keyboard Shortcuts.
|
||||
pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_shortcuts;
|
||||
win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| {
|
||||
for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) {
|
||||
ui.label(egui::RichText::new(c.shortcut.unwrap_or("")).font(mono(11.0)));
|
||||
ui.label(c.label);
|
||||
ui.label(egui::RichText::new(c.menu.join(" › ")).small());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
app.ui.show_shortcuts = open;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
# Control protocol
|
||||
|
||||
SoundCraft can be driven completely by other programs: scripts, test harnesses and AI agents.
|
||||
Every menu item, button, mouse gesture and keystroke maps to a **command** (or to synthetic
|
||||
input), and every command is reachable from:
|
||||
|
||||
| Surface | How |
|
||||
|---|---|
|
||||
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`) |
|
||||
| CLI against a running app | `soundcraft-cli app --port <port> <method or command id> [JSON]` |
|
||||
| Headless CLI | `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav` |
|
||||
| MCP (headless) | `soundcraft-cli mcp [--demo]` |
|
||||
| MCP (bridged to the app) | `soundcraft-cli mcp --connect <port>` |
|
||||
|
||||
## Wire format
|
||||
|
||||
Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line:
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}}
|
||||
{"id": 1, "ok": true, "result": {"tracks": [12, 13]}}
|
||||
```
|
||||
|
||||
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Params | What it does |
|
||||
|---|---|---|
|
||||
| `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. |
|
||||
| `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). |
|
||||
| `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. |
|
||||
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources, selection, transport, undo label. |
|
||||
| `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). |
|
||||
| `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. |
|
||||
| `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). |
|
||||
| `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). |
|
||||
| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). |
|
||||
| `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. |
|
||||
| `ui.move` | `{x, y}` | Move the pointer. |
|
||||
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). |
|
||||
| `ui.text` | `{text}` | Type text into the focused field. |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the window (base64 in `png_base64` when no path). |
|
||||
| `app.quit` | `{}` | Quit. |
|
||||
|
||||
## Positions and references
|
||||
|
||||
- Tracks: id (number) or name (`"Kick"`).
|
||||
- Clips: id (from `session.inspect`).
|
||||
- Positions: samples (`96000`), seconds (`{"seconds": 2.5}`), or a time string in any timebase,
|
||||
optionally prefixed: `"bars_beats:5|1|000"`, `"min_secs:0:12.500"`, `"timecode:00:00:10:00"`,
|
||||
`"samples:48000"`.
|
||||
- Ranges: `start` + `end` (or `start` + `length`); omitted = the current edit selection.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
P=$(soundcraft --demo --control 0 | sed -n 's/SOUNDCRAFT_CONTROL_PORT=//p') # or read stderr
|
||||
soundcraft-cli app --port $P mix.solo '{"track":"Bass"}'
|
||||
soundcraft-cli app --port $P transport.play
|
||||
soundcraft-cli app --port $P ui.screenshot '{"path":"/tmp/shot.png"}'
|
||||
soundcraft-cli app --port $P ui.menu.invoke '{"path":"Track > New..."}'
|
||||
```
|
||||
|
||||
Offscreen (no window): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png script.jsonl`,
|
||||
where each script line is a request (`{"method": …, "params": …}`), `{"shot": "path.png"}` or
|
||||
`{"steps": n}`.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# MCP server
|
||||
|
||||
SoundCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so
|
||||
Claude and other agents can make music with it.
|
||||
|
||||
```sh
|
||||
soundcraft-cli mcp # headless engine (no window, no audio device)
|
||||
soundcraft-cli mcp --demo # headless, starting from the demo session
|
||||
soundcraft --control 7801 & # the desktop app…
|
||||
soundcraft-cli mcp --connect 7801 # …driven over MCP: same tools plus screenshots and clicks
|
||||
```
|
||||
|
||||
Claude Code:
|
||||
|
||||
```sh
|
||||
claude mcp add soundcraft -- soundcraft-cli mcp --demo
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). |
|
||||
| `execute` | Run one command with JSON params. |
|
||||
| `batch` | Run several commands in order (`keep_going` optional). |
|
||||
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection. |
|
||||
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). |
|
||||
| `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. |
|
||||
| `bounce_mix` | Render to WAV/AIFF/FLAC; returns peak dBFS and integrated LUFS. |
|
||||
| `parity` | Feature-parity report. |
|
||||
| `screenshot`, `ui_inspect`, `ui_set`, `menu_invoke`, `click`, `drag`, `key` | App only (with `--connect`). |
|
||||
|
||||
Resources: `soundcraft://session` (full session JSON), `soundcraft://commands`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Programmatic calls never open dialogs; empty params act on the current selection.
|
||||
- Positions: samples, `{"seconds": x}`, or `"bars_beats:5|1|000"`-style strings.
|
||||
- Tracks by name or id; clips by id.
|
||||
- Every command is undoable through `edit.undo` unless it is a query or a view setting.
|
||||
|
||||
The acceptance test `crates/automation/tests/agent_tasks.rs` drives a realistic session end to end
|
||||
through MCP only (mixing, plugins, routing, editing with undo, markers, tempo, automation, bounce,
|
||||
save and reopen).
|
||||
Loading…
Reference in New Issue