From a28e15210cc13da4132277226202f4f4ca344a8a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 23:14:48 -0400 Subject: [PATCH] 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 --- crates/dsp/examples/plugin_bench.rs | 28 +++ crates/engine/src/cmd/track.rs | 8 + crates/mix/Cargo.toml | 3 + crates/mix/examples/render_bench.rs | 41 ++++ crates/mix/src/lib.rs | 350 ++++++++++++++++++---------- crates/ui-egui/src/edit_window.rs | 78 ++++++- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/menus.rs | 24 +- crates/ui-egui/src/midi_editor.rs | 37 ++- crates/ui-egui/src/mix_window.rs | 75 ++++-- crates/ui-egui/src/windows.rs | 134 ++++++++++- docs/control-protocol.md | 67 ++++++ docs/mcp.md | 44 ++++ 13 files changed, 725 insertions(+), 170 deletions(-) create mode 100644 crates/dsp/examples/plugin_bench.rs create mode 100644 crates/mix/examples/render_bench.rs create mode 100644 docs/control-protocol.md create mode 100644 docs/mcp.md diff --git a/crates/dsp/examples/plugin_bench.rs b/crates/dsp/examples/plugin_bench.rs new file mode 100644 index 0000000..b6d1279 --- /dev/null +++ b/crates/dsp/examples/plugin_bench.rs @@ -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); + } +} diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2539b28..ede6ae2 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -118,6 +118,14 @@ pub fn specs() -> Vec { } 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( diff --git a/crates/mix/Cargo.toml b/crates/mix/Cargo.toml index 8884c8f..7e17890 100644 --- a/crates/mix/Cargo.toml +++ b/crates/mix/Cargo.toml @@ -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 diff --git a/crates/mix/examples/render_bench.rs b/crates/mix/examples/render_bench.rs new file mode 100644 index 0000000..96ceb46 --- /dev/null +++ b/crates/mix/examples/render_bench.rs @@ -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 = 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); +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index fd1e147..54794d4 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -27,19 +27,35 @@ pub struct StripMeter { struct PluginSlot { id: String, plugin: Box, + /// Last values pushed to the plugin, in the insert's parameter-map order. + applied: Vec, } struct Strip { buf: Vec>, + /// Pre-fader copy for pre-fader sends. + pre: Vec>, + scratch: Vec, plugins: Vec>, instrument: Option, /// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek. held: Vec, + 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, busses: HashMap>>, main: Vec>, - scratch: Vec, pub meters: HashMap, 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, } 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>>, 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(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(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; } } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index b6d2daf..785fe1f 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 15fbe19..d907637 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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(), } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8a52bb7..df0790d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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), diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index fc61096..44f140a 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -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(); } } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index a988714..2539cd6 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -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 = (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 = 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())); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index a96c9e4..7126480 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -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 = 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::().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; +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 0000000..869278d --- /dev/null +++ b/docs/control-protocol.md @@ -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 ` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`) | +| CLI against a running app | `soundcraft-cli app --port [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 ` | + +## 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}`. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..ed5c8e6 --- /dev/null +++ b/docs/mcp.md @@ -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).