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:
Brandon Thomas 2026-10-05 23:14:48 -04:00
parent 6cff5ff3d9
commit a28e15210c
13 changed files with 725 additions and 170 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(&params, &freqs, 48_000.0)
} else {
soundcraft_dsp::eq1_response(&params, &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()));

View File

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

67
docs/control-protocol.md Normal file
View File

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

44
docs/mcp.md Normal file
View File

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