UI, desktop app, CLI and MCP: Edit and Mix windows, playback, agent control

- soundcraft-ui-egui: Edit window (toolbar with edit modes, zoom, tools,
  counters, grid/nudge, transport; rulers; track headers; waveforms, MIDI
  and automation lanes; Smart/Selector/Grabber/Trim/Pencil/Zoom/Scrubber
  gestures), Mix window strips, menus built from the incumbent catalog,
  dialogs, floating windows, JSON control channel, offscreen ui_shot
- soundcraft app: eframe/wgpu, cpal playback, control server, drag-drop
- soundcraft-automation: MCP server (stdio) with headless and remote
  backends; agent acceptance test of 9 realistic tasks
- soundcraft-cli: info, convert, run, script, commands, app, mcp, parity
- engine: AudioSuite offline processing command

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-05 22:59:40 -04:00
parent 755f02fc85
commit f41c1310fc
29 changed files with 9843 additions and 22 deletions

3757
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[workspace]
resolver = "3"
members = ["crates/*", "xtask"]
default-members = ["crates/*", "xtask"]
members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"

View File

@ -0,0 +1,21 @@
[package]
name = "soundcraft-cli"
description = "SoundCraft command line: headless editing, conversion, bounce, MCP server, app control"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[[bin]]
name = "soundcraft-cli"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
soundcraft-engine.workspace = true
soundcraft-automation.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-dsp.workspace = true
serde_json.workspace = true

View File

@ -0,0 +1,338 @@
//! `soundcraft-cli`: SoundCraft without a window.
//!
//! ```text
//! soundcraft-cli info FILE audio file or .scraft summary
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize]
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line)
//! soundcraft-cli commands [FILTER] list commands
//! soundcraft-cli describe ID one command's details
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
//! soundcraft-cli mcp [--connect PORT] [--demo] MCP server on stdio
//! soundcraft-cli parity [--write PATH] parity report (markdown)
//! soundcraft-cli plugins list built-in plugins
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use serde_json::{Value, json};
use soundcraft_automation::{Backend, Headless, Remote, mcp::Server};
use soundcraft_engine::Engine;
use std::io::Write;
use std::process::ExitCode;
/// Print without panicking on a closed pipe.
macro_rules! outln {
($($t:tt)*) => {{
let mut o = std::io::stdout().lock();
if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
}};
}
fn arg_value(args: &[String], flag: &str) -> Option<String> {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
}
fn fail(msg: impl std::fmt::Display) -> ExitCode {
eprintln!("soundcraft-cli: {msg}");
ExitCode::FAILURE
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = args.first().cloned() else {
eprintln!("usage: soundcraft-cli <info|convert|run|script|commands|describe|app|mcp|parity|plugins> … (see --help)");
return ExitCode::FAILURE;
};
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
match cmd.as_str() {
"--version" | "-V" | "version" => {
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
"--help" | "-h" | "help" => {
outln!("{}", include_str!("main.rs").lines().skip(3).take(14).map(|l| l.trim_start_matches("//! ")).collect::<Vec<_>>().join("\n"));
ExitCode::SUCCESS
}
"info" => info(&rest),
"convert" => convert(&rest),
"run" => run(&rest),
"script" => script(&rest),
"commands" => commands(&rest),
"describe" => describe(&rest),
"app" => app(&rest),
"mcp" => mcp(&rest),
"parity" => parity(&rest),
"plugins" => {
for p in soundcraft_dsp::plugins() {
outln!("{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
}
ExitCode::SUCCESS
}
other => fail(format!("unknown subcommand `{other}`")),
}
}
fn info(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("info FILE") };
if path.ends_with(".scraft") {
let mut e = Engine::default();
if let Err(err) = soundcraft_engine::io::open_session(&mut e, path) {
return fail(err);
}
outln!("{}", soundcraft_engine::inspect::session_text(&e));
return ExitCode::SUCCESS;
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return fail(format!("{path}: {e}")),
};
let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str());
match soundcraft_audio_io::probe(&bytes, ext) {
Ok(i) => {
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
ExitCode::SUCCESS
}
Err(e) => fail(format!("{path}: {e}")),
}
}
fn convert(args: &[String]) -> ExitCode {
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
let bytes = match std::fs::read(input) {
Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")),
};
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str());
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")),
};
if let Some(rate) = arg_value(args, "--rate").and_then(|r| r.parse::<u32>().ok()) {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
}
if args.iter().any(|a| a == "--normalize") {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
let format = match out_ext.as_str() {
"aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff,
"flac" => soundcraft_audio_io::FileFormat::Flac,
_ => soundcraft_audio_io::FileFormat::Wav,
};
let bit_depth = match arg_value(args, "--bit-depth").as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
Ok(()) => {
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
ExitCode::SUCCESS
}
Err(e) => fail(e),
}
}
fn load_engine(args: &[String]) -> Result<Engine, String> {
if let Some(p) = arg_value(args, "--in") {
let mut e = Engine::default();
soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
Ok(e)
} else if args.iter().any(|a| a == "--demo" || a == "--sample") {
Ok(soundcraft_engine::demo::demo_engine())
} else {
Ok(Engine::default())
}
}
fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
if let Some(out) = arg_value(args, "--bounce") {
let mut p = json!({"path": out});
if let Some(s) = arg_value(args, "--start") {
p["start"] = time_arg(&s);
}
if let Some(s) = arg_value(args, "--end") {
p["end"] = time_arg(&s);
}
match e.execute("file.bounce_mix", &p) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
}
}
if let Some(path) = arg_value(args, "--save") {
match e.execute("session.save", &json!({"path": path})) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
}
}
if args.iter().any(|a| a == "--inspect") {
outln!("{}", serde_json::to_string_pretty(&soundcraft_engine::inspect::session(e, false)).unwrap_or_default());
}
ExitCode::SUCCESS
}
/// `12.5` → seconds; anything else is passed through as a time string.
fn time_arg(s: &str) -> Value {
s.parse::<f64>().map_or_else(|_| json!(s), |f| json!({"seconds": f}))
}
fn run(args: &[String]) -> ExitCode {
let mut e = match load_engine(args) {
Ok(e) => e,
Err(err) => return fail(err),
};
let mut i = 0;
while i < args.len() {
if args.get(i).is_some_and(|a| a == "--cmd")
&& let Some(spec) = args.get(i + 1)
{
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).unwrap_or(json!({}))),
None => (spec.clone(), json!({})),
};
match e.execute(&id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => return fail(format!("{id}: {err}")),
}
i += 1;
}
i += 1;
}
finish(&mut e, args)
}
fn script(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("script FILE") };
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => return fail(format!("{path}: {e}")),
};
let mut e = match load_engine(args) {
Ok(e) => e,
Err(err) => return fail(err),
};
let keep = args.iter().any(|a| a == "--keep-going");
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}"));
let params = match serde_json::from_str::<Value>(p.trim()) {
Ok(v) => v,
Err(err) => return fail(format!("{path}:{}: bad JSON: {err}", n + 1)),
};
match e.execute(id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => {
eprintln!("{path}:{}: {id}: {err}", n + 1);
if !keep {
return ExitCode::FAILURE;
}
}
}
}
finish(&mut e, args)
}
fn commands(args: &[String]) -> ExitCode {
let filter = args.first().cloned().unwrap_or_default();
let e = Engine::default();
for c in soundcraft_engine::command_specs() {
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
continue;
}
let i = c.info(&e);
outln!("{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
}
ExitCode::SUCCESS
}
fn describe(args: &[String]) -> ExitCode {
let Some(id) = args.first() else { return fail("describe ID") };
match soundcraft_engine::find_command(id) {
Some(c) => {
outln!("{}", serde_json::to_string_pretty(&c.info(&Engine::default())).unwrap_or_default());
ExitCode::SUCCESS
}
None => fail(format!("unknown command `{id}`")),
}
}
fn app(args: &[String]) -> ExitCode {
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
let positional: Vec<&String> = args
.iter()
.enumerate()
.filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))
.map(|(_, a)| a)
.collect();
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
let params = positional.get(1).and_then(|p| serde_json::from_str::<Value>(p).ok()).unwrap_or(json!({}));
let mut r = Remote::new(&port);
// A bare command id is shorthand for engine.execute.
let (m, p) = if method.contains('.')
&& !method.starts_with("ui.")
&& !method.starts_with("engine.")
&& !method.starts_with("session.")
&& !method.starts_with("app.")
{
("engine.execute".to_string(), json!({"command": method, "params": params}))
} else {
(method.to_string(), params)
};
match r.call(&m, p) {
Ok(v) => {
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
ExitCode::SUCCESS
}
Err(e) => fail(e),
}
}
fn mcp(args: &[String]) -> ExitCode {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let r = if let Some(port) = arg_value(args, "--connect") {
let mut s = Server::new(Remote::new(&port));
s.serve(stdin.lock(), stdout.lock())
} else {
let e = if args.iter().any(|a| a == "--demo") { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let mut s = Server::new(Headless::new(e));
s.serve(stdin.lock(), stdout.lock())
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => fail(e),
}
}
fn parity(args: &[String]) -> ExitCode {
let p = soundcraft_engine::catalog::parity_json();
let mut md = String::new();
md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`).\n");
md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n");
md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n");
md.push_str(&format!(
"**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n",
p["implemented"], p["total"], p["percent"]
));
for m in p["per_menu"].as_array().cloned().unwrap_or_default() {
md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"]));
}
md.push_str("\n## Not yet implemented\n\n");
for m in p["missing"].as_array().cloned().unwrap_or_default() {
md.push_str(&format!("- {}\n", m.as_str().unwrap_or("")));
}
if let Some(path) = arg_value(args, "--write") {
if let Err(e) = std::fs::write(&path, &md) {
return fail(format!("{path}: {e}"));
}
outln!("wrote {path}: {}% ({}/{})", p["percent"], p["implemented"], p["total"]);
} else {
outln!("{md}");
}
ExitCode::SUCCESS
}

View File

@ -0,0 +1,23 @@
[package]
name = "soundcraft"
description = "SoundCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
default-run = "soundcraft"
[lints]
workspace = true
[dependencies]
soundcraft-engine.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback.workspace = true
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui.workspace = true
serde_json.workspace = true
log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true

View File

@ -0,0 +1,66 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps.
use serde_json::{Value, json};
use soundcraft_ui_egui::ControlRequest;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}");
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
});
if let Err(e) = spawned {
eprintln!("soundcraft: control thread failed: {e}");
}
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
break;
}
}
}

177
apps/soundcraft/src/main.rs Normal file
View File

@ -0,0 +1,177 @@
//! SoundCraft desktop app.
//!
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`
//!
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use std::sync::Arc;
struct App(SoundApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
// Files dropped on the window: sessions open, audio/MIDI import.
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect());
for p in dropped {
open_path(&mut self.0, &p);
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
let title = format!("{} — SoundCraft", self.0.engine.session().name);
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}
fn on_exit(&mut self) {
save_prefs(&self.0.ui);
}
}
fn open_path(app: &mut SoundApp, p: &str) {
let lower = p.to_ascii_lowercase();
let r = if lower.ends_with(".scraft") {
app.run("session.open", serde_json::json!({"path": p}))
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
let at = app.engine.session().edit.selection.start;
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
};
if let Err(e) = r {
log::warn!("{p}: {e}");
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("soundcraft"))
};
base.map(|b| b.join("ui.json"))
}
fn load_prefs() -> Option<UiState> {
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn save_prefs(ui: &UiState) {
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let mut ui = ui.clone();
ui.plugin_windows.clear();
ui.status.clear();
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
let _ = std::fs::write(&p, bytes);
}
}
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|purpose: &str, _exts: &[&str]| {
let d = rfd::FileDialog::new();
let d = match purpose {
"session.open" => d.add_filter("SoundCraft Session", &["scraft"]),
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
_ => d.add_filter(
"Audio",
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"],
),
};
d.pick_file().map(|p| p.to_string_lossy().into_owned())
})),
pick_save: Some(Box::new(|_purpose: &str, default: &str| {
let path = std::path::Path::new(default);
let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]);
if let Some(n) = path.file_name().and_then(|n| n.to_str()) {
d = d.set_file_name(n);
}
d.save_file().map(|p| p.to_string_lossy().into_owned())
})),
}
}
fn main() -> eframe::Result {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("SoundCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if std::env::var_os("RUST_LOG").is_none() {
// Quiet by default; `RUST_LOG=info` for more.
}
let demo = args.iter().any(|a| a == "--demo" || a == "--sample");
let no_audio = args.iter().any(|a| a == "--no-audio");
let control_port: Option<u16> = args
.iter()
.position(|a| a == "--control")
.and_then(|i| args.get(i + 1))
.and_then(|p| p.parse().ok())
.or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
let files: Vec<String> = args
.iter()
.enumerate()
.filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))
.map(|(_, a)| a.clone())
.collect();
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_app_id("ai.storyteller.soundcraft"),
..Default::default()
};
eframe::run_native(
"SoundCraft",
options,
Box::new(move |cc| {
let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) };
let mut app = SoundApp::new(engine, player, services());
if let Some(ui) = load_prefs() {
app.ui = ui;
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
for f in &files {
open_path(&mut app, f);
}
Ok(Box::new(App(app)))
}),
)
}

View File

@ -0,0 +1,17 @@
[package]
name = "soundcraft-automation"
description = "SoundCraft agent control: MCP server (stdio JSON-RPC), headless engine backend and control-channel client"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[dependencies]
soundcraft-engine.workspace = true
soundcraft-time.workspace = true
serde_json.workspace = true
log.workspace = true
[lints]
workspace = true

View File

@ -0,0 +1,155 @@
//! Agent control for SoundCraft.
//!
//! - [`Backend`]: something that answers control-protocol methods (`engine.execute`,
//! `session.inspect`, `ui.*`…). [`Headless`] runs an in-process engine; [`Remote`] talks to a
//! running app's `--control` port.
//! - [`mcp::Server`]: a Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0)
//! exposing SoundCraft as tools and resources.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod mcp;
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::time::Duration;
/// Answers control-protocol methods.
pub trait Backend {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
/// True when a UI is attached (screenshots, clicks).
fn has_ui(&self) -> bool;
fn describe(&self) -> String;
}
/// An in-process engine (no UI).
pub struct Headless {
pub engine: Engine,
}
impl Headless {
pub fn new(engine: Engine) -> Self {
Headless { engine }
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
match method {
"engine.execute" | "command" => {
let id = params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).ok_or("`command` required")?;
let p = params.get("params").cloned().unwrap_or(json!({}));
let r = self.engine.execute(id, &p).map_err(|e| e.to_string());
// Headless has no audio engine: drop transport requests but keep the playhead.
self.engine.transport_requests.clear();
r
}
"engine.commands" => self.engine.execute("engine.commands", &params).map_err(|e| e.to_string()),
"engine.parity" => Ok(soundcraft_engine::catalog::parity_json()),
"session.inspect" | "document.inspect" => {
Ok(soundcraft_engine::inspect::session(&self.engine, params.get("detail").and_then(Value::as_str) == Some("full")))
}
m if m.starts_with("ui.") => {
Err(format!("`{m}` needs the desktop app: start `soundcraft --control 0` and use `soundcraft-cli mcp --connect PORT`"))
}
other => Err(format!("unknown method `{other}`")),
}
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
format!("headless engine, session `{}`", self.engine.session().name)
}
}
/// A TCP client for a running app's control channel.
pub struct Remote {
addr: String,
conn: Option<(TcpStream, BufReader<TcpStream>)>,
next_id: u64,
}
impl Remote {
/// `target` is a port (`7979`) or `host:port`.
pub fn new(target: &str) -> Self {
let addr = if target.contains(':') { target.to_string() } else { format!("127.0.0.1:{target}") };
Remote { addr, conn: None, next_id: 1 }
}
fn connect(&mut self) -> Result<(), String> {
if self.conn.is_some() {
return Ok(());
}
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?;
let s =
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?;
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
let r = s.try_clone().map_err(|e| e.to_string())?;
self.conn = Some((s, BufReader::new(r)));
Ok(())
}
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, String> {
self.connect()?;
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let (w, r) = self.conn.as_mut().ok_or("not connected")?;
writeln!(w, "{line}").map_err(|e| e.to_string())?;
let mut buf = String::new();
r.read_line(&mut buf).map_err(|e| e.to_string())?;
if buf.is_empty() {
return Err("connection closed".into());
}
let v: Value = serde_json::from_str(&buf).map_err(|e| e.to_string())?;
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
}
}
}
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
match self.roundtrip(method, &params) {
Ok(v) => Ok(v),
Err(e) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
// Retry once on a fresh connection.
self.conn = None;
self.roundtrip(method, &params)
}
Err(e) => Err(e),
}
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
format!("SoundCraft app at {}", self.addr)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headless_executes_and_inspects() {
let mut h = Headless::new(Engine::default());
let r = h.call("engine.execute", json!({"command": "track.new", "params": {"count": 2, "format": "stereo"}}));
assert!(r.is_ok(), "{r:?}");
let s = h.call("session.inspect", json!({})).unwrap();
assert_eq!(s["tracks"].as_array().map(Vec::len), Some(2));
assert!(h.call("ui.click", json!({})).is_err());
assert!(h.call("nope", json!({})).is_err());
}
#[test]
fn remote_reports_unreachable() {
let mut r = Remote::new("127.0.0.1:1");
assert!(r.call("session.inspect", json!({})).is_err());
}
}

View File

@ -0,0 +1,251 @@
//! Model Context Protocol server (stdio, newline-delimited JSON-RPC 2.0).
use crate::Backend;
use serde_json::{Value, json};
use std::io::{BufRead, Write};
pub const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \
command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \
(integers), `{\"seconds\": x}`, or time strings like \"bars_beats:5|1|000\" / \"0:12.500\". Tracks are referenced by id \
or name. Verify your work with `inspect_session` (tracks, clips, mixer, automation, markers, selection). Programmatic \
calls never open dialogs. Tools marked APP_ONLY need the desktop app (`soundcraft --control 0` + `soundcraft-cli mcp --connect PORT`).";
pub struct Server<B: Backend> {
pub backend: B,
}
fn tool(name: &str, desc: &str, props: Value, required: &[&str]) -> Value {
json!({"name": name, "description": desc, "inputSchema": {"type": "object", "properties": props, "required": required}})
}
pub fn tool_definitions() -> Vec<Value> {
vec![
tool(
"list_commands",
"List SoundCraft commands (id, label, menu path, shortcut, params doc, enabled).",
json!({"filter": {"type": "string", "description": "substring of id or label"}}),
&[],
),
tool(
"execute",
"Run one command by id with JSON params. Returns the command's result.",
json!({"command": {"type": "string"}, "params": {"type": "object"}}),
&["command"],
),
tool(
"batch",
"Run several commands in order; stops at the first error unless keep_going.",
json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
&["calls"],
),
tool(
"inspect_session",
"The whole session as JSON: tracks (clips, inserts, sends, automation), busses, markers, groups, selection, transport.",
json!({"detail": {"type": "string", "enum": ["summary", "full"]}}),
&[],
),
tool(
"new_session",
"Start a new session (optionally the built-in demo).",
json!({"name": {"type": "string"}, "sample_rate": {"type": "integer"}, "template": {"type": "string", "enum": ["blank", "demo"]}}),
&[],
),
tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]),
tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]),
tool(
"import_audio",
"Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.",
json!({"path": {"type": "string"}, "track": {"type": ["string", "integer"]}, "at": {}}),
&["path"],
),
tool(
"bounce_mix",
"Render the mix to an audio file. Returns peak dBFS and integrated LUFS.",
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": ["wav", "aiff", "flac"]}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
&["path"],
),
tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]),
tool("screenshot", "APP_ONLY. Screenshot the app window (PNG).", json!({"path": {"type": "string"}}), &[]),
tool("ui_inspect", "APP_ONLY. UI state: front window, panels, layout rectangles of tracks, dialogs.", json!({}), &[]),
tool("ui_set", "APP_ONLY. Set UI state fields, e.g. {\"window\": \"Mix\"}.", json!({"state": {"type": "object"}}), &["state"]),
tool(
"menu_invoke",
"APP_ONLY. Invoke a menu item like a click (may open its dialog), e.g. \"Track > New...\".",
json!({"path": {"type": "string"}}),
&["path"],
),
tool(
"click",
"APP_ONLY. Click at window coordinates (points).",
json!({"x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string"}, "count": {"type": "integer"}}),
&["x", "y"],
),
tool(
"drag",
"APP_ONLY. Drag with the primary button.",
json!({"x": {"type": "number"}, "y": {"type": "number"}, "to_x": {"type": "number"}, "to_y": {"type": "number"}}),
&["x", "y", "to_x", "to_y"],
),
tool(
"key",
"APP_ONLY. Press a key with modifiers.",
json!({"key": {"type": "string"}, "cmd": {"type": "boolean"}, "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "ctrl": {"type": "boolean"}}),
&["key"],
),
]
}
fn text_result(v: &Value) -> Value {
let text = serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
json!({"content": [{"type": "text", "text": text}], "isError": false})
}
fn error_result(e: &str) -> Value {
json!({"content": [{"type": "text", "text": e}], "isError": true})
}
impl<B: Backend> Server<B> {
pub fn new(backend: B) -> Self {
Server { backend }
}
fn exec(&mut self, cmd: &str, params: Value) -> Result<Value, String> {
self.backend.call("engine.execute", json!({"command": cmd, "params": params}))
}
/// Dispatch a tool call.
pub fn call_tool(&mut self, name: &str, args: &Value) -> Value {
let a = |k: &str| args.get(k).cloned();
let r: Result<Value, String> = match name {
"list_commands" => self.backend.call("engine.commands", json!({"filter": a("filter")})),
"execute" => {
let Some(cmd) = args.get("command").and_then(Value::as_str) else { return error_result("`command` required") };
self.exec(cmd, a("params").unwrap_or(json!({})))
}
"batch" => {
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
let cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({})));
match r {
Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})),
Err(e) => {
out.push(json!({"command": cmd, "ok": false, "error": e}));
failed = true;
if !keep {
break;
}
}
}
}
if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
}
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
"new_session" => self.exec("session.new", args.clone()),
"open_session" => self.exec("session.open", args.clone()),
"save_session" => self.exec("session.save", args.clone()),
"import_audio" => self.exec("file.import_audio", args.clone()),
"bounce_mix" => self.exec("file.bounce_mix", args.clone()),
"parity" => self.backend.call("engine.parity", json!({})),
"screenshot" => {
let r = self.backend.call("ui.screenshot", args.clone());
if let Ok(v) = &r
&& let Some(b64) = v.get("png_base64").and_then(Value::as_str)
{
return json!({"content": [{"type": "image", "data": b64, "mimeType": "image/png"}], "isError": false});
}
r
}
"ui_inspect" => self.backend.call("ui.inspect", json!({})),
"ui_set" => self.backend.call("ui.set", a("state").unwrap_or(json!({}))),
"menu_invoke" => self.backend.call("ui.menu.invoke", args.clone()),
"click" => self.backend.call("ui.click", args.clone()),
"drag" => self.backend.call("ui.drag", args.clone()),
"key" => self.backend.call("ui.key", args.clone()),
other => Err(format!("unknown tool `{other}`")),
};
match r {
Ok(v) => text_result(&v),
Err(e) => error_result(&e),
}
}
/// Handle one JSON-RPC message; returns the reply line (None for notifications).
pub fn handle(&mut self, msg: &Value) -> Option<Value> {
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
let id = id?;
let result: Result<Value, (i64, String)> = match method {
"initialize" => {
let want = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let ver = if SUPPORTED.contains(&want) { want } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": ver,
"capabilities": {"tools": {}, "resources": {}},
"serverInfo": {"name": "soundcraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS}\nBackend: {}.", self.backend.describe()),
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tool_definitions()})),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
Ok(self.call_tool(name, &args))
}
"resources/list" => Ok(json!({"resources": [
{"uri": "soundcraft://session", "name": "Session", "mimeType": "application/json", "description": "The open session as JSON"},
{"uri": "soundcraft://commands", "name": "Commands", "mimeType": "application/json", "description": "All commands"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
let r = match uri {
"soundcraft://session" => self.backend.call("session.inspect", json!({"detail": "full"})),
"soundcraft://commands" => self.backend.call("engine.commands", json!({})),
_ => Err(format!("unknown resource {uri}")),
};
match r {
Ok(v) => Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]})),
Err(e) => Err((-32002, e)),
}
}
"" => Err((-32600, "invalid request".into())),
other => Err((-32601, format!("method not found: {other}"))),
};
Some(match result {
Ok(r) => json!({"jsonrpc": "2.0", "id": id, "result": r}),
Err((code, m)) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": m}}),
})
}
/// Serve until EOF.
pub fn serve(&mut self, input: impl BufRead, mut out: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.iter().filter_map(|m| self.handle(m)).collect();
if replies.is_empty() { None } else { Some(Value::Array(replies)) }
}
Ok(msg) => self.handle(&msg),
Err(e) => Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("parse error: {e}")}})),
};
if let Some(r) = reply {
writeln!(out, "{r}")?;
out.flush()?;
}
}
Ok(())
}
}

View File

@ -0,0 +1,137 @@
//! An agent completes realistic DAW tasks through MCP only, and verifies each through MCP.
use serde_json::{Value, json};
use soundcraft_automation::{Headless, mcp::Server};
use soundcraft_engine::Engine;
fn rpc(s: &mut Server<Headless>, id: i64, method: &str, params: Value) -> Value {
s.handle(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap()
}
fn tool(s: &mut Server<Headless>, name: &str, args: Value) -> Value {
let r = rpc(s, 1, "tools/call", json!({"name": name, "arguments": args}));
let res = &r["result"];
assert_eq!(res["isError"], json!(false), "{name} failed: {res}");
let text = res["content"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap_or(Value::String(text.to_string()))
}
fn session(s: &mut Server<Headless>) -> Value {
tool(s, "inspect_session", json!({"detail": "full"}))
}
fn track<'a>(sess: &'a Value, name: &str) -> &'a Value {
sess["tracks"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap_or_else(|| panic!("no track {name}"))
}
#[test]
fn handshake_and_tool_list() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"}));
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
let r = rpc(&mut s, 2, "tools/list", json!({}));
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
let r = rpc(&mut s, 3, "nope", json!({}));
assert_eq!(r["error"]["code"], -32601);
}
#[test]
fn agent_builds_and_mixes_a_session() {
let mut s = Server::new(Headless::new(Engine::default()));
// 1. New demo session.
tool(&mut s, "new_session", json!({"name": "Agent Mix", "template": "demo"}));
let sess = session(&mut s);
assert_eq!(sess["name"], "Agent Mix");
assert!(sess["tracks"].as_array().unwrap().len() >= 9);
// 2. Turn the kick down and pan the hats.
tool(&mut s, "execute", json!({"command": "mix.volume", "params": {"track": "Kick", "db": -8.5}}));
tool(&mut s, "execute", json!({"command": "mix.pan", "params": {"track": "Hats", "pan": -0.5}}));
let sess = session(&mut s);
assert_eq!(track(&sess, "Kick")["volume_db"], json!(-8.5));
assert_eq!(track(&sess, "Hats")["pan"][0], json!(-0.5));
// 3. Put an EQ on the bass with a low-shelf boost, then bypass it.
let r = tool(&mut s, "execute", json!({"command": "mix.insert", "params": {"track": "Bass", "slot": "c", "plugin": "eq_7band"}}));
assert_eq!(r["slot"], 2);
let sess = session(&mut s);
assert_eq!(track(&sess, "Bass")["inserts"].as_array().unwrap().iter().filter(|i| i["plugin"] == "eq_7band").count(), 1);
tool(&mut s, "execute", json!({"command": "mix.insert_bypass", "params": {"track": "Bass", "slot": 2}}));
let sess = session(&mut s);
assert_eq!(track(&sess, "Bass")["inserts"].as_array().unwrap().iter().find(|i| i["slot"] == 2).unwrap()["bypass"], true);
// 4. New stereo aux fed from a send.
tool(
&mut s,
"batch",
json!({"calls": [
{"command": "track.new", "params": {"kind": "aux", "format": "stereo", "name": "Delay"}},
{"command": "track.input", "params": {"track": "Delay", "input": "DelayBus"}},
{"command": "mix.insert", "params": {"track": "Delay", "plugin": "mod_delay"}},
{"command": "mix.send", "params": {"track": "Lead", "slot": 1, "bus": "DelayBus", "level_db": -6}},
]}),
);
let sess = session(&mut s);
let lead = track(&sess, "Lead");
assert_eq!(lead["sends"].as_array().unwrap().iter().find(|x| x["slot"] == 1).unwrap()["level_db"], json!(-6.0));
// 5. Edit: select bars 5–9 on Snare, cut, undo, verify the clip count is restored.
let before = track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len();
tool(
&mut s,
"execute",
json!({"command": "edit.select", "params": {"tracks": ["Snare"], "start": "bars_beats:5|1|000", "end": "bars_beats:9|1|000"}}),
);
tool(&mut s, "execute", json!({"command": "edit.cut", "params": {}}));
let mid = track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len();
assert_ne!(mid, before);
tool(&mut s, "execute", json!({"command": "edit.undo", "params": {}}));
assert_eq!(track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len(), before);
// 6. Markers and tempo.
tool(&mut s, "execute", json!({"command": "markers.add", "params": {"name": "Bridge", "at": {"seconds": 30.0}}}));
tool(&mut s, "execute", json!({"command": "event.tempo", "params": {"bpm": 110.0}}));
let sess = session(&mut s);
assert!(sess["markers"].as_array().unwrap().iter().any(|m| m["name"] == "Bridge"));
assert_eq!(sess["tempo"][0]["bpm"], json!(110.0));
// 7. Volume automation ride on the pad.
tool(
&mut s,
"execute",
json!({"command": "automation.write_range", "params": {"track": "Pad", "param": "volume", "start": {"seconds": 10.0}, "end": {"seconds": 12.0}, "value": -20.0}}),
);
let pad = track(&session(&mut s), "Pad").clone();
assert!(pad["automation"].as_array().unwrap().iter().any(|l| l["param"] == "volume"));
// 8. Bounce 4 seconds and check loudness is sane.
let dir = std::env::temp_dir().join(format!("soundcraft-agent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bounce.wav");
let r =
tool(&mut s, "bounce_mix", json!({"path": path.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 14.0}, "bit_depth": "24"}));
assert!(r["peak_db"].as_f64().unwrap() > -40.0, "{r}");
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[0..4], b"RIFF");
// 9. Save, reopen, and the mix survives.
let sp = dir.join("Agent Mix").join("Agent Mix.scraft");
std::fs::create_dir_all(sp.parent().unwrap()).unwrap();
tool(&mut s, "save_session", json!({"path": sp.to_string_lossy()}));
tool(&mut s, "new_session", json!({}));
tool(&mut s, "open_session", json!({"path": sp.to_string_lossy()}));
let sess = session(&mut s);
assert_eq!(track(&sess, "Kick")["volume_db"], json!(-8.5));
assert!(sess["sources"].as_array().unwrap().len() >= 5);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn errors_are_reported_not_panics() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(&mut s, 1, "tools/call", json!({"name": "execute", "arguments": {"command": "mix.volume", "params": {"track": "Nope", "db": 0}}}));
assert_eq!(r["result"]["isError"], true);
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
}

View File

@ -63,7 +63,6 @@ pub const ALIASES: &[(&str, &str)] = &[
("View > Rulers > Meter", "view.ruler"),
("View > Rulers > Markers", "view.ruler"),
("View > Rulers > Chord Symbols", "view.ruler"),
("Window > Undo History", "engine.history"),
];
pub fn catalog() -> Vec<&'static str> {

View File

@ -0,0 +1,124 @@
//! AudioSuite-style offline processing of selected clips.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"audiosuite.process",
"AudioSuite Process",
[],
None,
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?}",
has_selection,
process
)]
}
/// Process each target clip's audio and replace the clip with one backed by a new source.
fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "process").or_else(|| str_param(p, "plugin")).ok_or_else(|| bad("audiosuite.process", "`process` required"))?.to_string();
let params: Vec<(String, f32)> = p
.get("params")
.and_then(Value::as_object)
.map(|o| o.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.clone(), x as f32))).collect())
.unwrap_or_default();
let get = |k: &str, d: f32| params.iter().find(|(n, _)| n == k).map_or(d, |(_, v)| *v);
let known = soundcraft_dsp::plugin_info(&name).is_some()
|| matches!(
name.as_str(),
"normalize" | "reverse" | "invert" | "gain" | "duplicate" | "time_stretch" | "pitch_shift" | "varispeed" | "dc_offset_removal"
);
if !known {
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
}
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("audiosuite.process", "select audio clips first"));
}
let sr = e.session().sample_rate;
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
if !clip.is_audio() {
continue;
}
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
let mut tmp = e.session().clone();
if let Some(c) = tmp.find_clip_mut(id) {
c.fade_in = Default::default();
c.fade_out = Default::default();
c.gain_db = 0.0;
c.gain_env.clear();
}
if let Some(pl) = tmp.track_mut(track).and_then(|t| t.playlist_mut()) {
pl.clips.retain(|c| c.id == id);
audio = soundcraft_mix::render_clips(&tmp, track, clip.range());
}
let srf = sr.as_f64() as f32;
let out: Vec<Vec<f32>> = match name.as_str() {
"normalize" => {
soundcraft_dsp::offline::normalize(&mut audio, get("target_db", -0.1), false);
audio
}
"reverse" => {
soundcraft_dsp::offline::reverse(&mut audio);
audio
}
"invert" => {
soundcraft_dsp::offline::invert(&mut audio);
audio
}
"gain" if soundcraft_dsp::plugin_info("gain").is_none() || params.iter().any(|(k, _)| k == "db") => {
soundcraft_dsp::offline::gain(&mut audio, get("db", 0.0));
audio
}
"duplicate" => audio,
"time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, f64::from(get("ratio", 1.0).clamp(0.25, 4.0)), srf),
"pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, get("semitones", 0.0).clamp(-24.0, 24.0), srf),
"varispeed" => {
let speed = get("speed", 1.0).clamp(0.25, 4.0);
let to = (sr.as_f64() / f64::from(speed)).round().max(1.0) as u32;
soundcraft_dsp::offline::resample(&audio, sr.hz(), to)
}
plugin => {
let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) };
pl.prepare(srf, 1024, audio.len().max(1));
for (k, v) in &params {
pl.set_param(k, *v);
}
soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf);
audio
}
};
let frames = out.first().map_or(0, Vec::len);
if frames == 0 {
continue;
}
let buf = AudioBuffer { sample_rate: sr.hz(), channels: out };
let new_name = format!("{}-{}", clip.name, short(&name));
let s = e.session_mut();
let src = crate::io::add_source(s, &new_name, buf, None, FileFormat::Wav);
let mut nc: Clip = clip.clone();
nc.name = new_name;
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.length = i64::try_from(frames).unwrap_or(nc.length);
nc.gain_db = 0.0;
nc.gain_env.clear();
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
pl.clips.retain(|c| c.id != id);
}
crate::edit::place_clip(s, track, nc);
done += 1;
}
Ok(json!({"processed": done, "process": name}))
}
fn short(n: &str) -> String {
let s: String = n.split('_').map(|w| w.chars().take(4).collect::<String>()).collect::<Vec<_>>().join("");
s.chars().take(8).collect()
}

View File

@ -6,6 +6,7 @@ use soundcraft_model::{ClipId, TrackId};
use soundcraft_time::{Range, Samples};
use std::sync::OnceLock;
mod audiosuite;
mod clip;
mod edit;
mod event;
@ -91,6 +92,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(options::specs());
v.extend(transport::specs());
v.extend(query::specs());
v.extend(audiosuite::specs());
v
})
}

29
crates/ui-egui/Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "soundcraft-ui-egui"
description = "SoundCraft's egui user interface: Edit and Mix windows, transport, dialogs, control channel"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[dependencies]
soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-midi.workspace = true
soundcraft-dsp.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
soundcraft-engine.workspace = true
soundcraft-playback.workspace = true
egui.workspace = true
serde.workspace = true
serde_json.workspace = true
log.workspace = true
image.workspace = true
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
[lints]
workspace = true

View File

@ -0,0 +1,100 @@
//! Render the real UI offscreen (no window, no focus stealing) and save PNGs.
//!
//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]`
//!
//! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}`
//! or `{"steps": n}`. The demo session is loaded unless `--blank`.
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
static READY: AtomicBool = AtomicBool::new(false);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into());
let script = args.get(1).filter(|a| !a.starts_with("--")).cloned();
let blank = args.iter().any(|a| a == "--blank");
let (w, h) = args
.iter()
.position(|a| a == "--size")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.split_once('x'))
.and_then(|(a, b)| Some((a.parse::<f32>().ok()?, b.parse::<f32>().ok()?)))
.unwrap_or((1600.0, 1000.0));
let engine = if blank { soundcraft_engine::Engine::default() } else { soundcraft_engine::demo::demo_engine() };
let (tx, rx) = mpsc::channel::<ControlRequest>();
let app = SoundApp::new(engine, None, Services::default()).with_control(rx);
let mut harness =
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
|ui, app: &mut SoundApp| {
if !READY.load(Ordering::Relaxed) {
return;
}
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
READY.store(true, Ordering::Relaxed);
let step = |h: &mut egui_kittest::Harness<SoundApp>| {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
*h.input_mut() = raw;
h.step();
};
for _ in 0..6 {
step(&mut harness);
}
let shoot = |h: &mut egui_kittest::Harness<SoundApp>, path: &str| match h.render() {
Ok(img) => match img.save(path) {
Ok(()) => println!("wrote {path}"),
Err(e) => eprintln!("{path}: {e}"),
},
Err(e) => eprintln!("render failed: {e}"),
};
if let Some(script) = script {
let text = std::fs::read_to_string(&script).unwrap_or_default();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
eprintln!("bad line: {line}");
continue;
};
if let Some(p) = v.get("shot").and_then(|x| x.as_str()) {
for _ in 0..3 {
step(&mut harness);
}
shoot(&mut harness, p);
continue;
}
if let Some(n) = v.get("steps").and_then(|x| x.as_u64()) {
for _ in 0..n {
step(&mut harness);
}
continue;
}
let method = v.get("method").and_then(|m| m.as_str()).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(serde_json::json!({}));
let (req, reply) = ControlRequest::new(method, params);
let _ = tx.send(req);
for _ in 0..40 {
step(&mut harness);
if let Ok(r) = reply.try_recv() {
let s = r.to_string();
println!("{}", if s.len() > 300 { &s[..300] } else { &s });
break;
}
}
for _ in 0..12 {
step(&mut harness);
}
}
}
for _ in 0..4 {
step(&mut harness);
}
shoot(&mut harness, &out);
}

View File

@ -0,0 +1,309 @@
//! The JSON control channel's UI side. The app's TCP server turns each request line into a
//! [`ControlRequest`]; they are handled on the UI thread here, so every command an agent sends
//! goes through exactly the same code as a click.
//!
//! Methods:
//! - `engine.execute {command, params}` (alias `command`): run any command programmatically (no dialogs)
//! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect`
//! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs)
//! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}`
//! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}`
//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`
//! - `app.quit`
use crate::SoundApp;
use serde_json::{Value, json};
use std::sync::mpsc::{Receiver, Sender, channel};
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<Value>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, Receiver<Value>) {
let (tx, rx) = channel();
(ControlRequest { method: method.into(), params, reply: tx }, rx)
}
}
pub struct PendingShot {
token: u64,
reply: Sender<Value>,
path: Option<String>,
frames: u32,
sent: bool,
deadline_frames: u32,
}
fn ok(v: Value) -> Value {
json!({"ok": true, "result": v})
}
fn err(e: impl std::fmt::Display) -> Value {
json!({"ok": false, "error": e.to_string()})
}
pub fn drain(app: &mut SoundApp, ctx: &egui::Context) {
let Some(rx) = app.control_rx.take() else { return };
let mut n = 0;
while let Ok(req) = rx.try_recv() {
n += 1;
if let Some(v) = handle(app, ctx, &req) {
let _ = req.reply.send(v);
}
if n > 64 {
break;
}
}
app.control_rx = Some(rx);
if n > 0 {
ctx.request_repaint();
}
}
fn key_from(name: &str) -> Option<egui::Key> {
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
"space" => Some(egui::Key::Space),
"enter" | "return" => Some(egui::Key::Enter),
"escape" | "esc" => Some(egui::Key::Escape),
"tab" => Some(egui::Key::Tab),
"home" => Some(egui::Key::Home),
"end" => Some(egui::Key::End),
"=" => Some(egui::Key::Equals),
_ => None,
})
}
fn mods(p: &Value) -> egui::Modifiers {
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let cmd = b("cmd") || b("command");
egui::Modifiers {
alt: b("alt"),
ctrl: b("ctrl") || (cmd && !cfg!(target_os = "macos")),
shift: b("shift"),
mac_cmd: cmd && cfg!(target_os = "macos"),
command: cmd,
}
}
fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option<Value> {
let p = &req.params;
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
Some(match req.method.as_str() {
"engine.execute" | "command" => {
let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or("");
let params = p.get("params").cloned().unwrap_or(json!({}));
match app.run(id, params) {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
"engine.commands" => {
let mut list = match app.engine.execute("engine.commands", p) {
Ok(v) => v,
Err(e) => return Some(err(e)),
};
if let Some(arr) = list.as_array_mut() {
for (id, label, path, sc) in crate::menus::UI_COMMANDS {
arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"}));
}
}
ok(list)
}
"engine.parity" => ok(crate::menus::parity()),
"session.inspect" | "document.inspect" => {
ok(soundcraft_engine::inspect::session(&app.engine, p.get("detail").and_then(Value::as_str) == Some("full")))
}
"ui.inspect" => {
let mut v = app.inspect(ctx);
v["edit_layout"] = json!(app.edit_layout);
ok(v)
}
"ui.menu.list" => ok(json!(
soundcraft_engine::catalog::catalog()
.iter()
.map(|e| json!({"path": e, "command": soundcraft_engine::catalog::implemented_by(e, &crate::menus::ui_aliases())}))
.collect::<Vec<_>>()
)),
"ui.menu.invoke" => {
let path = p.get("path").and_then(Value::as_str).unwrap_or("");
let id = p
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| soundcraft_engine::catalog::implemented_by(path, &crate::menus::ui_aliases()));
match id {
Some(id) => {
crate::menus::invoke_menu(app, &id, path);
ok(json!({"invoked": id, "dialog": app.dialogs.open_name()}))
}
None => err(format!("menu item `{path}` is not implemented yet")),
}
}
"ui.set" => match app.run("ui.set", p.clone()) {
Ok(v) => ok(v),
Err(e) => err(e),
},
"ui.move" => {
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
ok(json!({}))
}
"ui.click" => {
let pos = egui::pos2(f("x"), f("y"));
let button = match p.get("button").and_then(Value::as_str) {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
};
let m = mods(p);
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
app.synthetic.push(egui::Event::PointerMoved(pos));
for _ in 0..count {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
ok(json!({}))
}
"ui.drag" => {
let a = egui::pos2(f("x"), f("y"));
let b = egui::pos2(f("to_x"), f("to_y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
let m = mods(p);
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
for i in 1..=steps {
let k = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * k));
}
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
ok(json!({}))
}
"ui.key" => {
let name = p.get("key").and_then(Value::as_str).unwrap_or("");
let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) };
let m = mods(p);
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m });
ok(json!({}))
}
"ui.text" => {
let t = p.get("text").and_then(Value::as_str).unwrap_or("").to_string();
app.synthetic.push(egui::Event::Text(t));
ok(json!({}))
}
"ui.screenshot" => {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let token = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
app.pending_shots.push(PendingShot {
token,
reply: req.reply.clone(),
path: p.get("path").and_then(Value::as_str).map(str::to_string),
frames: 0,
sent: false,
deadline_frames: 600,
});
ctx.request_repaint();
return None;
}
"app.quit" => {
app.quit_requested = true;
ok(json!({}))
}
other => err(format!("unknown method `{other}`")),
})
}
/// Request and collect viewport screenshots for pending `ui.screenshot` calls.
pub fn collect_screenshots(app: &mut SoundApp, ctx: &egui::Context) {
if app.pending_shots.is_empty() {
return;
}
let images: Vec<(u64, std::sync::Arc<egui::ColorImage>)> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).map(|t| (*t, image.clone()))
}
_ => None,
})
.collect()
});
let mut keep = Vec::new();
for mut s in std::mem::take(&mut app.pending_shots) {
if let Some((_, img)) = images.iter().find(|(t, _)| *t == s.token) {
let _ = s.reply.send(encode(img, s.path.as_deref()));
continue;
}
s.frames += 1;
if !s.sent && s.frames >= 3 {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token)));
s.sent = true;
}
if s.frames > s.deadline_frames {
let _ = s.reply.send(err("no frame was presented (is the window visible?)"));
continue;
}
keep.push(s);
}
app.pending_shots = keep;
ctx.request_repaint();
}
pub fn png_bytes(img: &egui::ColorImage) -> Result<Vec<u8>, String> {
let [w, h] = img.size;
let mut raw = Vec::with_capacity(w * h * 4);
for c in &img.pixels {
raw.extend_from_slice(&c.to_array());
}
let buf = image::RgbaImage::from_raw(u32::try_from(w).map_err(|e| e.to_string())?, u32::try_from(h).map_err(|e| e.to_string())?, raw)
.ok_or("bad image size")?;
let mut out = std::io::Cursor::new(Vec::new());
buf.write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?;
Ok(out.into_inner())
}
fn encode(img: &egui::ColorImage, path: Option<&str>) -> Value {
let bytes = match png_bytes(img) {
Ok(b) => b,
Err(e) => return err(e),
};
match path {
Some(p) => match std::fs::write(p, &bytes) {
Ok(()) => ok(json!({"path": p, "width": img.size[0], "height": img.size[1]})),
Err(e) => err(format!("{p}: {e}")),
},
None => ok(json!({"png_base64": base64(&bytes), "width": img.size[0], "height": img.size[1]})),
}
}
/// Minimal base64 (standard alphabet) to avoid another dependency.
pub fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for i in 0..4 {
if i <= chunk.len() {
let idx = ((n >> (18 - 6 * i)) & 63) as usize;
out.push(char::from(T.get(idx).copied().unwrap_or(b'A')));
} else {
out.push('=');
}
}
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn base64_known() {
assert_eq!(super::base64(b"Man"), "TWFu");
assert_eq!(super::base64(b"Ma"), "TWE=");
assert_eq!(super::base64(b"M"), "TQ==");
}
}

View File

@ -0,0 +1,387 @@
//! Modal dialogs opened by menu-style invocations.
use crate::SoundApp;
use crate::theme::{Tokens, bold};
use egui::{Align2, vec2};
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ClipId, TrackId};
#[derive(Debug, Clone)]
pub enum Dialog {
NewTracks { count: u32, format: String, kind: String, timebase: String, name: String },
NewSession { name: String, sample_rate: u32, demo: bool },
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
RenameTrack { id: TrackId, name: String },
RenameClip { id: ClipId, name: String },
PathPrompt { cmd: String, title: String, path: String, key: String },
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
Fades { shape: String },
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { name: String },
Session { frame_rate: String, bit_depth: String },
}
#[derive(Default)]
pub struct Dialogs {
pub open: Option<Dialog>,
}
impl Dialogs {
pub fn open_name(&self) -> Option<&'static str> {
self.open.as_ref().map(|d| match d {
Dialog::NewTracks { .. } => "new_tracks",
Dialog::NewSession { .. } => "new_session",
Dialog::Bounce { .. } => "bounce",
Dialog::RenameTrack { .. } => "rename_track",
Dialog::RenameClip { .. } => "rename_clip",
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
Dialog::Fades { .. } => "fades",
Dialog::StripSilence { .. } => "strip_silence",
Dialog::Group { .. } => "group",
Dialog::Session { .. } => "session",
})
}
pub fn open_rename_track(&mut self, id: TrackId, name: &str) {
self.open = Some(Dialog::RenameTrack { id, name: name.to_string() });
}
pub fn open_rename_clip(&mut self, id: ClipId, name: &str) {
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
}
/// Open the dialog for a command; false when the command has no dialog.
pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool {
let home = default_dir();
let name = e.session().name.clone();
self.open = Some(match id {
"track.new" => {
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
}
"session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false },
"file.bounce_mix" => {
Dialog::Bounce { path: format!("{home}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false }
}
"file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() },
"file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() },
"session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() },
"session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: id.into(),
title: "Save Session As".into(),
path: format!("{home}/{name}/{name}.scraft"),
key: "path".into(),
},
"edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },
"edit.shift" => {
Dialog::Number { cmd: id.into(), title: "Shift (seconds)".into(), key: "by_seconds".into(), value: 1.0, suffix: "s".into() }
}
"event.transpose" => {
Dialog::Number { cmd: id.into(), title: "Transpose".into(), key: "semitones".into(), value: 12.0, suffix: "semitones".into() }
}
"event.quantize" => {
Dialog::Number { cmd: id.into(), title: "Quantize strength".into(), key: "strength".into(), value: 100.0, suffix: "%".into() }
}
"edit.fades_create" => Dialog::Fades { shape: "equal power".into() },
"edit.strip_silence" => Dialog::StripSilence { threshold: -48.0, min_ms: 50.0, pre_ms: 5.0, post_ms: 20.0 },
"track.group" => Dialog::Group { name: format!("Group {}", e.session().groups.len() + 1) },
"setup.session" => Dialog::Session { frame_rate: e.session().frame_rate.label().into(), bit_depth: "24".into() },
_ => {
return false;
}
});
true
}
}
fn default_dir() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| ".".into())
}
#[cfg(target_arch = "wasm32")]
{
String::new()
}
}
/// Show the open dialog, if any.
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
audiosuite_window(app, ctx);
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let title = match &d {
Dialog::NewTracks { .. } => "New Tracks",
Dialog::NewSession { .. } => "New Session",
Dialog::Bounce { .. } => "Bounce Mix",
Dialog::RenameTrack { .. } => "Rename Track",
Dialog::RenameClip { .. } => "Rename Clip",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
Dialog::Fades { .. } => "Fades",
Dialog::StripSilence { .. } => "Strip Silence",
Dialog::Group { .. } => "Create Group",
Dialog::Session { .. } => "Session Setup",
}
.to_string();
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape));
egui::Window::new(egui::RichText::new(&title).font(bold(13.0)))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0))
.show(ctx, |ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
match &mut d {
Dialog::NewTracks { count, format, kind, timebase, name } => {
ui.horizontal(|ui| {
ui.label("Create");
ui.add(egui::DragValue::new(count).range(1..=128));
ui.label("new");
egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
for f in soundcraft_model::ChannelFormat::ALL {
ui.selectable_value(format, f.label().to_string(), f.label());
}
});
egui::ComboBox::from_id_salt("nt_kind")
.selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label()))
.show_ui(ui, |ui| {
for k in soundcraft_model::TrackKind::ALL {
if ui.selectable_label(kind == k.id(), k.label()).clicked() {
*kind = k.id().to_string();
*name = k.default_name().to_string();
}
}
});
ui.label("in");
egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(
ui,
|ui| {
ui.selectable_value(timebase, "samples".to_string(), "Samples");
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
},
);
ui.label("Name:");
ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
});
if buttons(ui, "Create", enter) {
action =
Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name})));
}
}
Dialog::NewSession { name, sample_rate, demo } => {
ui.horizontal(|ui| {
ui.label("Name");
ui.text_edit_singleline(name);
});
egui::ComboBox::from_label("Sample Rate").selected_text(format!("{sample_rate} Hz")).show_ui(ui, |ui| {
for r in soundcraft_time::SampleRate::COMMON {
ui.selectable_value(sample_rate, r, format!("{r} Hz"));
}
});
ui.checkbox(demo, "Start from the demo session");
if buttons(ui, "Create", enter) {
action = Some((
"session.new".into(),
json!({"name": name, "sample_rate": sample_rate, "template": if *demo { "demo" } else { "blank" }}),
));
}
}
Dialog::Bounce { path, format, bit_depth, normalize } => {
ui.horizontal(|ui| {
ui.label("File");
ui.add(egui::TextEdit::singleline(path).desired_width(360.0));
});
ui.horizontal(|ui| {
egui::ComboBox::from_label("Format").selected_text(format.to_uppercase()).show_ui(ui, |ui| {
for f in ["wav", "aiff", "flac"] {
ui.selectable_value(format, f.to_string(), f.to_uppercase());
}
});
egui::ComboBox::from_label("Bit Depth").selected_text(format!("{bit_depth}-bit")).show_ui(ui, |ui| {
for b in ["16", "24", "32f"] {
ui.selectable_value(bit_depth, b.to_string(), format!("{b}-bit"));
}
});
});
ui.checkbox(normalize, "Normalize to -0.1 dBFS");
ui.label(
egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.")
.small()
.color(Tokens::DARK.text_dim),
);
if buttons(ui, "Bounce", enter) {
let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() });
action = Some((
"file.bounce_mix".into(),
json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}),
));
}
}
Dialog::RenameTrack { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
if buttons(ui, "OK", enter) {
action = Some(("track.rename".into(), json!({"track": id.0, "name": name})));
}
}
Dialog::RenameClip { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
if buttons(ui, "OK", enter) {
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
}
}
Dialog::PathPrompt { cmd, path, key, .. } => {
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
if let Some(pick) = &app.services.pick_open
&& cmd != "session.save_as"
&& cmd != "session.save_copy"
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(cmd, &[])
{
*path = p;
}
if let Some(pick) = &app.services.pick_save
&& (cmd == "session.save_as" || cmd == "session.save_copy")
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(cmd, path)
{
*path = p;
}
if buttons(ui, "OK", enter) {
let mut v = json!({});
v[key.as_str()] = json!(path);
action = Some((cmd.clone(), v));
}
}
Dialog::Number { cmd, key, value, suffix, .. } => {
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(value).speed(0.1));
ui.label(suffix.as_str());
});
if buttons(ui, "OK", enter) {
let v = if key == "by_seconds" { json!({"by": {"seconds": value}}) } else { json!({key.as_str(): value}) };
action = Some((cmd.clone(), v));
}
}
Dialog::Fades { shape } => {
for s in soundcraft_model::FadeShape::ALL {
ui.radio_value(shape, s.label().to_lowercase(), s.label());
}
if buttons(ui, "OK", enter) {
action = Some(("edit.fades_create".into(), json!({"shape": shape})));
}
}
Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => {
ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB"));
ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text("Min strip duration ms"));
ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms"));
ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms"));
if buttons(ui, "Strip", enter) {
action = Some((
"edit.strip_silence".into(),
json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}),
));
}
}
Dialog::Group { name } => {
ui.text_edit_singleline(name);
if buttons(ui, "OK", enter) {
action = Some(("track.group".into(), json!({"name": name})));
}
}
Dialog::Session { frame_rate, bit_depth } => {
egui::ComboBox::from_label("Timecode Rate").selected_text(frame_rate.as_str()).show_ui(ui, |ui| {
for r in soundcraft_time::FrameRate::ALL {
ui.selectable_value(frame_rate, r.label().to_string(), r.label());
}
});
egui::ComboBox::from_label("Bit Depth").selected_text(bit_depth.as_str()).show_ui(ui, |ui| {
for b in ["16", "24", "32f"] {
ui.selectable_value(bit_depth, b.to_string(), b);
}
});
if buttons(ui, "OK", enter) {
action = Some(("setup.session".into(), json!({"frame_rate": frame_rate, "bit_depth": bit_depth})));
}
}
}
});
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
keep = false;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
}
if let Some((cmd, params)) = action {
keep = false;
if let Err(e) = app.run(&cmd, params) {
app.ui.status = e;
}
}
if keep {
app.dialogs.open = Some(d);
}
}
/// Cancel/OK row; returns true when OK was pressed (or Enter).
fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
let mut pressed = enter;
ui.separator();
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true));
}
if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::DARK.accent)).clicked() {
pressed = true;
}
});
pressed
}
fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
let Some(process) = app.ui.audiosuite.clone() else { return };
let mut open = true;
let title = soundcraft_dsp::plugin_info(&process).map_or_else(|| process.replace('_', " "), |p| p.name.to_string());
egui::Window::new(format!("AudioSuite · {title}")).open(&mut open).default_width(360.0).show(ctx, |ui| {
let key = egui::Id::new(("as_params", process.clone()));
let mut params: std::collections::BTreeMap<String, f32> = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_default();
if let Some(info) = soundcraft_dsp::plugin_info(&process) {
for p in info.params {
let v = params.entry(p.id.to_string()).or_insert(p.default);
ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name));
}
} else {
match process.as_str() {
"normalize" => {
let v = params.entry("target_db".into()).or_insert(-0.1);
ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB"));
}
"time_stretch" => {
let v = params.entry("ratio".into()).or_insert(1.0);
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio"));
}
"pitch_shift" => {
let v = params.entry("semitones".into()).or_insert(0.0);
ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));
}
"varispeed" => {
let v = params.entry("speed".into()).or_insert(1.0);
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed"));
}
_ => {
ui.label("No parameters.");
}
}
}
ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone()));
ui.label(egui::RichText::new("Processes the selected clips and replaces them with rendered audio.").small());
if ui.button(egui::RichText::new("Render").strong()).clicked() {
let p: serde_json::Map<String, Value> = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
let _ = app.run("audiosuite.process", json!({"process": process, "params": p}));
}
});
if !open {
app.ui.audiosuite = None;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
//! Fonts: egui's bundled defaults, plus the operating system's own UI fonts loaded at runtime when
//! present (never redistributed). A "bold" family is always defined so the UI can use it.
use egui::{FontData, FontDefinitions, FontFamily};
use std::sync::Arc;
/// (path, ttc index) candidates for regular and bold UI text, by platform.
const REGULAR: &[(&str, u32)] = &[
("/System/Library/Fonts/HelveticaNeue.ttc", 0),
("/System/Library/Fonts/Helvetica.ttc", 0),
("C:\\Windows\\Fonts\\segoeui.ttf", 0),
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 0),
("/usr/share/fonts/TTF/DejaVuSans.ttf", 0),
("/usr/share/fonts/dejavu/DejaVuSans.ttf", 0),
("/usr/local/share/fonts/dejavu/DejaVuSans.ttf", 0),
("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", 0),
];
const BOLD: &[(&str, u32)] = &[
("/System/Library/Fonts/HelveticaNeue.ttc", 1),
("/System/Library/Fonts/Helvetica.ttc", 1),
("C:\\Windows\\Fonts\\segoeuib.ttf", 0),
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 0),
("/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", 0),
("/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0),
("/usr/local/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0),
("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0),
];
#[cfg(not(target_arch = "wasm32"))]
fn load(cands: &[(&str, u32)]) -> Option<FontData> {
if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() {
return None;
}
for (p, idx) in cands {
if let Ok(bytes) = std::fs::read(p) {
let mut fd = FontData::from_owned(bytes);
fd.index = *idx;
return Some(fd);
}
}
None
}
#[cfg(target_arch = "wasm32")]
fn load(_: &[(&str, u32)]) -> Option<FontData> {
None
}
pub fn definitions() -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut bold_family = base.clone();
if let Some(reg) = load(REGULAR) {
fonts.font_data.insert("system-regular".into(), Arc::new(reg));
if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) {
f.insert(0, "system-regular".into());
}
}
if let Some(b) = load(BOLD) {
fonts.font_data.insert("system-bold".into(), Arc::new(b));
bold_family.insert(0, "system-bold".into());
} else if fonts.font_data.contains_key("system-regular") {
bold_family.insert(0, "system-regular".into());
}
fonts.families.insert(FontFamily::Name("bold".into()), bold_family);
fonts
}
pub fn install(ctx: &egui::Context) {
ctx.set_fonts(definitions());
}

160
crates/ui-egui/src/icons.rs Normal file
View File

@ -0,0 +1,160 @@
//! Icons drawn procedurally with the egui painter. Original SoundCraft artwork (MIT OR Apache-2.0);
//! no image files and nothing derived from any other product's icons.
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
fn p(r: Rect, x: f32, y: f32) -> Pos2 {
pos2(r.min.x + r.width() * x, r.min.y + r.height() * y)
}
/// Draw icon `name` inside `r` with colour `c`.
pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
let s = Stroke::new((r.width() / 14.0).clamp(1.2, 2.4), c);
match name {
"play" => {
painter.add(Shape::convex_polygon(vec![p(r, 0.28, 0.18), p(r, 0.82, 0.5), p(r, 0.28, 0.82)], c, Stroke::NONE));
}
"stop" => {
painter.rect_filled(Rect::from_min_max(p(r, 0.26, 0.26), p(r, 0.74, 0.74)), 1.0, c);
}
"record" => {
painter.circle_filled(r.center(), r.width() * 0.27, c);
}
"rtz" => {
painter.rect_filled(Rect::from_min_max(p(r, 0.24, 0.26), p(r, 0.34, 0.74)), 0.0, c);
painter.add(Shape::convex_polygon(vec![p(r, 0.76, 0.26), p(r, 0.76, 0.74), p(r, 0.36, 0.5)], c, Stroke::NONE));
}
"end" => {
painter.rect_filled(Rect::from_min_max(p(r, 0.66, 0.26), p(r, 0.76, 0.74)), 0.0, c);
painter.add(Shape::convex_polygon(vec![p(r, 0.24, 0.26), p(r, 0.64, 0.5), p(r, 0.24, 0.74)], c, Stroke::NONE));
}
"rewind" => {
for dx in [0.0, 0.26] {
painter.add(Shape::convex_polygon(vec![p(r, 0.5 + dx, 0.28), p(r, 0.5 + dx, 0.72), p(r, 0.22 + dx, 0.5)], c, Stroke::NONE));
}
}
"ffwd" => {
for dx in [0.0, 0.26] {
painter.add(Shape::convex_polygon(vec![p(r, 0.24 + dx, 0.28), p(r, 0.52 + dx, 0.5), p(r, 0.24 + dx, 0.72)], c, Stroke::NONE));
}
}
"loop" => {
let rr = Rect::from_min_max(p(r, 0.2, 0.3), p(r, 0.8, 0.7));
painter.rect_stroke(rr, rr.height() * 0.5, s, egui::StrokeKind::Middle);
painter.add(Shape::convex_polygon(vec![p(r, 0.55, 0.2), p(r, 0.68, 0.3), p(r, 0.55, 0.4)], c, Stroke::NONE));
}
"zoom" => {
painter.circle_stroke(p(r, 0.42, 0.4), r.width() * 0.2, s);
painter.line_segment([p(r, 0.56, 0.55), p(r, 0.8, 0.8)], Stroke::new(s.width * 1.6, c));
}
"trim" => {
painter.line_segment([p(r, 0.5, 0.2), p(r, 0.5, 0.8)], s);
painter.line_segment([p(r, 0.15, 0.5), p(r, 0.85, 0.5)], s);
for (a, b, d) in [(0.15, 0.5, 1.0), (0.85, 0.5, -1.0)] {
painter.add(Shape::convex_polygon(vec![p(r, a, b), p(r, a + 0.12 * d, b - 0.1), p(r, a + 0.12 * d, b + 0.1)], c, Stroke::NONE));
}
}
"selector" => {
painter.line_segment([p(r, 0.5, 0.2), p(r, 0.5, 0.8)], s);
painter.line_segment([p(r, 0.4, 0.2), p(r, 0.6, 0.2)], s);
painter.line_segment([p(r, 0.4, 0.8), p(r, 0.6, 0.8)], s);
// Waveform hint on either side.
for (i, h) in [0.1, 0.22, 0.14, 0.26, 0.12].iter().enumerate() {
let x = 0.12 + i as f32 * 0.06;
painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], Stroke::new(s.width * 0.7, c));
let x2 = 0.88 - i as f32 * 0.06;
painter.line_segment([p(r, x2, 0.5 - h), p(r, x2, 0.5 + h)], Stroke::new(s.width * 0.7, c));
}
}
"grabber" => {
// A stylised open hand: palm + four fingers + thumb.
let palm = Rect::from_min_max(p(r, 0.3, 0.45), p(r, 0.72, 0.82));
painter.rect_stroke(palm, 4.0, s, egui::StrokeKind::Middle);
for (i, top) in [0.22, 0.16, 0.18, 0.26].iter().enumerate() {
let x = 0.34 + i as f32 * 0.11;
painter.line_segment([p(r, x, 0.47), p(r, x, *top)], s);
}
painter.line_segment([p(r, 0.3, 0.62), p(r, 0.18, 0.48)], s);
}
"scrubber" => {
painter.add(Shape::convex_polygon(
vec![p(r, 0.18, 0.4), p(r, 0.3, 0.4), p(r, 0.46, 0.24), p(r, 0.46, 0.76), p(r, 0.3, 0.6), p(r, 0.18, 0.6)],
c,
Stroke::NONE,
));
for (k, rad) in [0.14f32, 0.26].iter().enumerate() {
let n = 8;
let pts: Vec<Pos2> = (0..=n)
.map(|i| {
let a = -0.8 + 1.6 * i as f32 / n as f32;
let cc = p(r, 0.46, 0.5);
pos2(cc.x + a.cos() * r.width() * rad, cc.y + a.sin() * r.height() * rad)
})
.collect();
painter.add(Shape::line(pts, Stroke::new(s.width * (1.0 - k as f32 * 0.2), c)));
}
}
"pencil" => {
painter.line_segment([p(r, 0.25, 0.75), p(r, 0.72, 0.28)], Stroke::new(s.width * 2.2, c));
painter.add(Shape::convex_polygon(vec![p(r, 0.18, 0.82), p(r, 0.22, 0.66), p(r, 0.34, 0.78)], c, Stroke::NONE));
}
"speaker" => {
painter.add(Shape::convex_polygon(
vec![p(r, 0.2, 0.4), p(r, 0.34, 0.4), p(r, 0.52, 0.22), p(r, 0.52, 0.78), p(r, 0.34, 0.6), p(r, 0.2, 0.6)],
c,
Stroke::NONE,
));
}
"click" => {
painter.add(Shape::convex_polygon(vec![p(r, 0.5, 0.15), p(r, 0.78, 0.85), p(r, 0.22, 0.85)], Color32::TRANSPARENT, s));
painter.line_segment([p(r, 0.5, 0.7), p(r, 0.7, 0.25)], s);
}
"link" => {
painter.circle_stroke(p(r, 0.38, 0.5), r.width() * 0.16, s);
painter.circle_stroke(p(r, 0.62, 0.5), r.width() * 0.16, s);
}
"plus" => {
painter.line_segment([p(r, 0.5, 0.25), p(r, 0.5, 0.75)], s);
painter.line_segment([p(r, 0.25, 0.5), p(r, 0.75, 0.5)], s);
}
"minus" => {
painter.line_segment([p(r, 0.25, 0.5), p(r, 0.75, 0.5)], s);
}
"triangle_down" => {
painter.add(Shape::convex_polygon(vec![p(r, 0.25, 0.35), p(r, 0.75, 0.35), p(r, 0.5, 0.68)], c, Stroke::NONE));
}
"triangle_right" => {
painter.add(Shape::convex_polygon(vec![p(r, 0.35, 0.25), p(r, 0.68, 0.5), p(r, 0.35, 0.75)], c, Stroke::NONE));
}
"note" => {
painter.circle_filled(p(r, 0.38, 0.72), r.width() * 0.13, c);
painter.line_segment([p(r, 0.5, 0.72), p(r, 0.5, 0.2)], s);
painter.line_segment([p(r, 0.5, 0.2), p(r, 0.72, 0.32)], s);
}
"gear" => {
painter.circle_stroke(r.center(), r.width() * 0.2, s);
for i in 0..8 {
let a = i as f32 * std::f32::consts::TAU / 8.0;
let c0 = r.center() + vec2(a.cos(), a.sin()) * r.width() * 0.22;
let c1 = r.center() + vec2(a.cos(), a.sin()) * r.width() * 0.34;
painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c));
}
}
"wave" => {
let n = 24;
let pts: Vec<Pos2> = (0..=n)
.map(|i| {
let t = i as f32 / n as f32;
pos2(
r.min.x + r.width() * (0.12 + 0.76 * t),
r.center().y - (t * 18.0).sin() * r.height() * 0.28 * (1.0 - (t - 0.5).abs() * 1.6).max(0.1),
)
})
.collect();
painter.add(Shape::line(pts, s));
}
_ => {
painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle);
}
}
}

433
crates/ui-egui/src/lib.rs Normal file
View File

@ -0,0 +1,433 @@
//! SoundCraft's egui user interface.
//!
//! The UI is a thin shell over [`soundcraft_engine::Engine`]: it draws engine state and acts by
//! executing commands. It can be replaced without touching anything below it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod control;
pub mod dialogs;
pub mod edit_window;
pub mod fonts;
pub mod icons;
pub mod menus;
pub mod mix_window;
pub mod panels;
pub mod shortcuts;
pub mod theme;
pub mod toolbar;
pub mod widgets;
use serde_json::{Value, json};
use soundcraft_engine::{Engine, TransportRequest};
use soundcraft_model::TrackId;
use soundcraft_playback::Player;
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
pub use control::ControlRequest;
/// Which main window is in front (Window › Mix / Edit).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum MainWindow {
Edit,
Mix,
}
/// UI state (serde, so agents can read and set it).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UiState {
pub window: MainWindow,
pub show_tracks_list: bool,
pub show_clip_list: bool,
pub show_transport: bool,
pub show_memory_locations: bool,
pub show_big_counter: bool,
pub show_undo_history: bool,
pub show_session_info: bool,
pub show_about: bool,
pub show_midi_editor: bool,
pub show_universe: bool,
pub narrow_mix: bool,
/// Mix window sections: inserts_ae, inserts_fj, sends_ae, sends_fj, io, comments, eq_curve, color.
pub mix_views: Vec<String>,
/// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color.
pub edit_views: Vec<String>,
/// Open plugin windows (track, slot).
pub plugin_windows: Vec<(TrackId, usize)>,
pub audiosuite: Option<String>,
pub status: String,
}
impl Default for UiState {
fn default() -> Self {
UiState {
window: MainWindow::Edit,
show_tracks_list: true,
show_clip_list: true,
show_transport: false,
show_memory_locations: false,
show_big_counter: false,
show_undo_history: false,
show_session_info: false,
show_about: false,
show_midi_editor: false,
show_universe: false,
narrow_mix: false,
mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()],
edit_views: vec!["color".into()],
plugin_windows: Vec::new(),
audiosuite: None,
status: String::new(),
}
}
}
/// Host services (file dialogs) injected by the app; absent in tests and on the web.
#[derive(Default)]
pub struct Services {
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
}
/// Ballistic meter display state per strip.
#[derive(Debug, Clone, Copy, Default)]
pub struct MeterDisplay {
pub level: [f32; 2],
pub hold: [f32; 2],
pub hold_age: f32,
pub clip: bool,
pub gr: f32,
}
/// An in-progress mouse gesture in the Edit window.
#[derive(Debug, Clone)]
pub enum Gesture {
Select { track: TrackId, anchor: Samples, tracks: Vec<TrackId> },
MoveClips { clips: Vec<soundcraft_model::ClipId>, grab_at: Samples, delta: Samples, from_track: TrackId, to_track: TrackId },
TrimStart { clip: soundcraft_model::ClipId, track: TrackId, to: Samples },
TrimEnd { clip: soundcraft_model::ClipId, track: TrackId, to: Samples },
Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples },
Scrub { last: Samples },
Fader { track: TrackId, start_db: f32 },
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
ZoomBox { start: f32 },
}
pub struct SoundApp {
pub engine: Engine,
pub player: Option<Player>,
pub ui: UiState,
pub services: Services,
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
pub gesture: Option<Gesture>,
pub edit_layout: edit_window::EditLayout,
pub synthetic: Vec<egui::Event>,
control_rx: Option<Receiver<ControlRequest>>,
pending_shots: Vec<control::PendingShot>,
last_rev: u64,
fonts_ready: bool,
fonts_installed: bool,
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
pub quit_requested: bool,
pub frame_ms: f32,
last_frame: Option<f64>,
}
impl SoundApp {
pub fn new(engine: Engine, player: Option<Player>, services: Services) -> Self {
SoundApp {
engine,
player,
ui: UiState::default(),
services,
dialogs: dialogs::Dialogs::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
gesture: None,
edit_layout: edit_window::EditLayout::default(),
synthetic: Vec::new(),
control_rx: None,
pending_shots: Vec::new(),
last_rev: 0,
fonts_ready: false,
fonts_installed: false,
sim: None,
quit_requested: false,
frame_ms: 0.0,
last_frame: None,
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
/// Run a command; errors go to the status line. Returns the result.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
match self.engine.execute(id, &params) {
Ok(v) => {
if id == "app.quit" {
self.quit_requested = true;
}
Ok(v)
}
Err(e) => {
self.ui.status = e.to_string();
log::warn!("{id}: {e}");
Err(e.to_string())
}
}
}
pub fn is_playing(&self) -> bool {
self.engine.transport.playing
}
/// Current transport position (playhead while playing, else the edit insertion).
pub fn position(&self) -> Samples {
if self.engine.transport.playing { self.engine.transport.position } else { self.engine.session().edit.selection.start }
}
fn start_play(&mut self, from: Samples, end: Option<Samples>, looped: Option<Range>) {
if let Some(p) = &self.player {
p.update_session(self.engine.session_arc());
p.play(from, end, looped);
} else {
self.sim = Some((from, end, looped));
}
self.engine.transport.playing = true;
self.engine.transport.position = from;
}
fn stop_play(&mut self) {
if let Some(p) = &self.player {
p.stop();
}
self.sim = None;
self.engine.transport.playing = false;
self.engine.transport.recording = false;
let s = self.engine.session();
if s.edit.insertion_follows_playback {
let pos = self.engine.transport.position;
self.engine.session_mut().edit.selection = Range::point(pos);
}
}
/// Play from the selection, honouring loop playback and selection end.
pub fn play_from_selection(&mut self) {
let s = self.engine.session();
let sel = s.edit.selection;
let preroll = if s.edit.pre_post_roll { s.edit.pre_roll } else { 0 };
let postroll = if s.edit.pre_post_roll { s.edit.post_roll } else { 0 };
if s.edit.loop_playback && !sel.is_empty() {
self.start_play(sel.start, None, Some(sel));
} else if !sel.is_empty() {
self.start_play((sel.start - preroll).max(0), Some(sel.end + postroll), None);
} else {
self.start_play((sel.start - preroll).max(0), None, None);
}
}
fn handle_transport(&mut self, dt: f32) {
let reqs: Vec<TransportRequest> = std::mem::take(&mut self.engine.transport_requests);
for r in reqs {
match r {
TransportRequest::Play => {
if !self.is_playing() {
self.play_from_selection();
}
}
TransportRequest::Stop | TransportRequest::Pause => self.stop_play(),
TransportRequest::TogglePlay => {
if self.is_playing() {
self.stop_play();
} else {
self.play_from_selection();
}
}
TransportRequest::Record => {
self.engine.transport.recording = true;
if !self.is_playing() {
self.play_from_selection();
}
}
TransportRequest::Locate(at) => {
if self.is_playing() {
if let Some(p) = &self.player {
p.locate(at);
}
if let Some(sim) = &mut self.sim {
sim.0 = at;
}
}
self.engine.transport.position = at;
}
TransportRequest::PlaySelection => {
let sel = self.engine.session().edit.selection;
self.start_play(sel.start, Some(sel.end.max(sel.start + 1)), None);
}
TransportRequest::HalfSpeed => {
if let Some(p) = &self.player {
p.set_speed(0.5);
}
self.play_from_selection();
}
TransportRequest::Scrub(at) => self.engine.transport.position = at,
TransportRequest::AllNotesOff => {}
}
}
// Read back the position.
if let Some(p) = &self.player {
let playing = p.is_playing();
if self.engine.transport.playing && !playing {
self.engine.transport.playing = false;
p.set_speed(1.0);
}
if self.engine.transport.playing {
self.engine.transport.position = p.position();
}
let snap = p.meters();
for (id, m) in &snap.tracks {
let d = self.meters.entry(*id).or_default();
feed_meter(d, m.peak, m.gain_reduction_db, dt);
}
let main = snap.main.peak;
feed_meter(&mut self.main_meter, main, 0.0, dt);
} else if let Some((pos, end, looped)) = &mut self.sim {
let sr = self.engine.session().sample_rate.as_f64();
*pos += (f64::from(dt) * sr) as i64;
if let Some(l) = looped
&& *pos >= l.end
{
*pos = l.start;
}
let stop = end.is_some_and(|e| *pos >= e);
self.engine.transport.position = *pos;
if stop {
self.stop_play();
}
}
if !self.engine.transport.playing {
for d in self.meters.values_mut() {
feed_meter(d, [0.0, 0.0], 0.0, dt);
}
feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt);
}
}
/// Per-frame logic: control channel, transport, document sync.
pub fn logic(&mut self, ctx: &egui::Context) {
// Fonts set now take effect next frame, so draw only from the frame after.
if !self.fonts_ready {
if self.fonts_installed {
self.fonts_ready = true;
} else {
fonts::install(ctx);
theme::apply(ctx);
self.fonts_installed = true;
ctx.request_repaint();
}
}
let now = ctx.input(|i| i.time);
let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.last_frame = Some(now);
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
control::drain(self, ctx);
if self.engine.revision != self.last_rev {
self.last_rev = self.engine.revision;
if let Some(p) = &self.player {
p.update_session(self.engine.session_arc());
}
}
self.handle_transport(dt);
if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() {
ctx.request_repaint();
}
control::collect_screenshots(self, ctx);
}
/// Feed queued synthetic input (from the control channel) into egui.
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
return;
}
let mut n = 0;
for e in &self.synthetic {
n += 1;
if matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. }) {
break;
}
if matches!(e, egui::Event::PointerButton { pressed: true, .. }) {
break;
}
}
raw.events.extend(self.synthetic.drain(..n.min(self.synthetic.len())));
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
return;
}
shortcuts::handle(self, &ctx);
menus::menu_bar(self, ui);
match self.ui.window {
MainWindow::Edit => edit_window::show(self, ui),
MainWindow::Mix => mix_window::show(self, ui),
}
panels::floating(self, &ctx);
dialogs::show(self, &ctx);
}
/// UI state as JSON (control channel `ui.inspect`).
pub fn inspect(&self, ctx: &egui::Context) -> Value {
let size = ctx.content_rect().size();
json!({
"ui": self.ui,
"window_size": [size.x, size.y],
"playing": self.is_playing(),
"position": self.position(),
"dialog": self.dialogs.open_name(),
"frame_ms": self.frame_ms,
"audio_device": self.player.as_ref().map(|p| p.device_name.clone()),
"status": self.ui.status,
})
}
}
fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {
// Instant attack, ~26 dB/s release, 2 s peak hold.
let fall = 10f32.powf(-26.0 * dt / 20.0);
for c in 0..2 {
let v = peak.get(c).copied().unwrap_or(0.0);
let lvl = d.level.get(c).copied().unwrap_or(0.0);
let nv = if v >= lvl { v } else { (lvl * fall).max(v) };
if let Some(x) = d.level.get_mut(c) {
*x = if nv < 1e-5 { 0.0 } else { nv };
}
if v >= d.hold.get(c).copied().unwrap_or(0.0) {
if let Some(h) = d.hold.get_mut(c) {
*h = v;
}
d.hold_age = 0.0;
}
if v >= 1.0 {
d.clip = true;
}
}
d.hold_age += dt;
if d.hold_age > 2.0 {
d.hold = d.level;
}
d.gr = if gr > d.gr { gr } else { d.gr * fall };
}

392
crates/ui-egui/src/menus.rs Normal file
View File

@ -0,0 +1,392 @@
//! The menu bar, built from the incumbent's menu catalog so every item has its place. Items
//! that map to a command are live; the rest are shown disabled (and listed in the parity report).
use crate::{MainWindow, SoundApp};
use serde_json::{Value, json};
use soundcraft_engine::catalog;
/// UI-layer commands: (id, label, catalog path or "", shortcut).
pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
("window.edit", "Edit", "Window > Edit", Some("Cmd+=")),
("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")),
("window.transport", "Transport", "Window > Transport", Some("Cmd+1")),
("window.big_counter", "Big Counter", "Window > Big Counter", Some("Cmd+3")),
("window.memory_locations", "Memory Locations", "Window > Memory Locations", Some("Cmd+5")),
("window.undo_history", "Undo History", "Window > Undo History", None),
("window.clip_list", "Clip List", "Window > Clip List", None),
("window.track_list", "Track List", "View > Other Displays > Track List", None),
("window.clip_list_view", "Clip List", "View > Other Displays > Clip List", None),
("window.narrow_mix", "Narrow Mix", "View > Narrow Mix", Some("Cmd+Alt+M")),
("window.session_info", "Session Info", "", None),
("window.about", "About SoundCraft", "", None),
("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")),
("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")),
("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None),
("window.universe", "Universe", "View > Other Displays > Universe", None),
("view.mix_section", "Mix Window View", "", None),
("ui.new_tracks_dialog", "New Tracks…", "", None),
("ui.bounce_dialog", "Bounce Mix…", "", None),
("ui.set", "Set UI State", "", None),
];
/// Extra catalog mappings handled by the UI layer.
pub fn ui_aliases() -> Vec<(&'static str, &'static str)> {
let mut v: Vec<(&str, &str)> = UI_COMMANDS.iter().filter(|c| !c.2.is_empty()).map(|c| (c.2, c.0)).collect();
for sec in
["Inserts A-E", "Inserts F-J", "Sends A-E", "Sends F-J", "I/O", "Track Color", "Comments", "EQ Curve", "Meters and Faders", "All", "Minimal"]
{
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
}
/// AudioSuite catalog entries → our processes (functional equivalents, our own plugins).
pub const AUDIOSUITE: &[(&str, &str)] = &[
("AudioSuite > EQ > Channel Strip", "channel_strip"),
("AudioSuite > EQ > EQ3 1-Band", "eq_1band"),
("AudioSuite > EQ > EQ3 7-Band", "eq_7band"),
("AudioSuite > Dynamics > BF-76", "compressor"),
("AudioSuite > Dynamics > Channel Strip", "channel_strip"),
("AudioSuite > Dynamics > Dyn3 Compressor/Limiter", "compressor"),
("AudioSuite > Dynamics > Dyn3 De-Esser", "de_esser"),
("AudioSuite > Dynamics > Dyn3 Expander/Gate", "expander_gate"),
("AudioSuite > Dynamics > Maxim", "maximizer"),
("AudioSuite > Pitch Shift > Pitch II", "pitch_shift"),
("AudioSuite > Pitch Shift > Pitch Shift Legacy", "pitch_shift"),
("AudioSuite > Pitch Shift > Time Shift", "time_stretch"),
("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"),
("AudioSuite > Reverb > D-Verb", "room_reverb"),
("AudioSuite > Delay > Mod Delay III", "mod_delay"),
("AudioSuite > Modulation > Sci-Fi", "flanger"),
("AudioSuite > Harmonic > Eleven Lite", "saturator"),
("AudioSuite > Harmonic > Lo-Fi", "lofi"),
("AudioSuite > Harmonic > Recti-Fi", "rectifier"),
("AudioSuite > Harmonic > SansAmp PSA-1", "saturator"),
("AudioSuite > Other > DC Offset Removal", "dc_offset_removal"),
("AudioSuite > Other > Duplicate", "duplicate"),
("AudioSuite > Other > Gain", "gain"),
("AudioSuite > Other > Invert", "invert"),
("AudioSuite > Other > Normalize", "normalize"),
("AudioSuite > Other > Reverse", "reverse"),
("AudioSuite > Other > Signal Generator", "signal_generator"),
("AudioSuite > Other > Time Compression Expansion", "time_stretch"),
];
/// Commands that open a dialog when invoked from a menu (programmatic calls never do).
const DIALOG_COMMANDS: &[&str] = &[
"track.new",
"file.bounce_mix",
"session.new",
"file.import_audio",
"file.import_midi",
"session.open",
"session.save_as",
"session.save_copy",
"track.group",
"edit.repeat",
"edit.shift",
"edit.fades_create",
"setup.session",
"event.quantize",
"event.transpose",
"edit.strip_silence",
];
struct MenuNode {
label: String,
path: String,
children: Vec<MenuNode>,
}
fn tree() -> Vec<MenuNode> {
let mut roots: Vec<MenuNode> = Vec::new();
for line in catalog::catalog() {
let parts: Vec<&str> = line.split(" > ").collect();
let mut level = &mut roots;
let mut path = String::new();
for (i, p) in parts.iter().enumerate() {
if i > 0 {
path.push_str(" > ");
}
path.push_str(p);
let idx = match level.iter().position(|n| n.label == *p) {
Some(i) => i,
None => {
level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() });
level.len() - 1
}
};
let Some(node) = level.get_mut(idx) else { break };
level = &mut node.children;
}
}
roots
}
pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::DARK;
egui::Panel::top("menu_bar")
.exact_size(24.0)
.frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(22, 22, 23)).inner_margin(egui::Margin::symmetric(8, 2)))
.show(ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("SoundCraft", |ui| {
if ui.button("About SoundCraft").clicked() {
app.ui.show_about = true;
}
if ui.button("Session Info").clicked() {
app.ui.show_session_info = true;
}
ui.separator();
if ui.button("Quit").clicked() {
let _ = app.run("app.quit", json!({}));
}
});
let extra = ui_aliases();
for root in tree() {
ui.menu_button(&root.label, |ui| {
for c in &root.children {
menu_node(app, ui, c, &extra);
}
if root.label == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
});
}
let title = format!(
"{}{} — {}",
app.engine.session().name,
if app.engine.is_dirty() { " *" } else { "" },
if app.ui.window == MainWindow::Edit { "Edit" } else { "Mix" }
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(title).color(t.text_dim));
});
});
});
}
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
if !n.children.is_empty() {
ui.menu_button(&n.label, |ui| {
for c in &n.children {
menu_node(app, ui, c, extra);
}
});
return;
}
let id = catalog::implemented_by(&n.path, extra);
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()),
None => false,
};
let checked = checked_state(app, &n.path, id.as_deref());
let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() };
let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or("");
let btn = egui::Button::new(label).shortcut_text(shortcut);
if ui.add_enabled(enabled, btn).clicked()
&& let Some(id) = id
{
invoke_menu(app, &id, &n.path);
ui.close();
}
}
fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool {
let e = &app.engine.session().edit;
match id {
Some("options.loop_playback") => e.loop_playback,
Some("options.loop_record") => e.loop_record,
Some("options.quickpunch") => e.quickpunch,
Some("options.pre_post_roll") => e.pre_post_roll,
Some("options.link_timeline_edit") => e.link_timeline_edit,
Some("options.link_track_edit") => e.link_track_edit,
Some("options.insertion_follows_playback") => e.insertion_follows_playback,
Some("options.tab_to_transient") => e.tab_to_transient,
Some("options.automation_follows_edit") => e.automation_follows_edit,
Some("options.markers_follow_edit") => e.markers_follow_edit,
Some("options.click") => e.click,
Some("options.delay_compensation") => e.delay_compensation,
Some("options.pre_fader_metering") => e.pre_fader_metering,
Some("view.ruler") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.rulers.iter().any(|x| x == r)),
Some("view.main_counter") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.main_counter.id() == r),
Some("window.mix") => app.ui.window == MainWindow::Mix,
Some("window.edit") => app.ui.window == MainWindow::Edit,
Some("window.track_list") => app.ui.show_tracks_list,
Some("window.clip_list_view") | Some("window.clip_list") => app.ui.show_clip_list,
Some("window.narrow_mix") => app.ui.narrow_mix,
Some("view.mix_section") => path.rsplit(" > ").next().map(mix_section_id).is_some_and(|s| app.ui.mix_views.iter().any(|v| v == s)),
_ => false,
}
}
fn ruler_id(label: &str) -> &'static str {
match label {
"Bars|Beats" => "bars_beats",
"Min:Secs" => "min_secs",
"Timecode" => "timecode",
"Feet+Frames" => "feet_frames",
"Samples" => "samples",
"Tempo" => "tempo",
"Meter" => "meter",
"Markers" => "markers",
"Key Signature" => "key",
"Chord Symbols" => "chords",
_ => "",
}
}
fn mix_section_id(label: &str) -> &'static str {
match label {
"Inserts A-E" => "inserts_ae",
"Inserts F-J" => "inserts_fj",
"Sends A-E" => "sends_ae",
"Sends F-J" => "sends_fj",
"I/O" => "io",
"Track Color" => "color",
"Comments" => "comments",
"EQ Curve" => "eq_curve",
"Meters and Faders" => "meters",
"All" => "all",
"Minimal" => "minimal",
_ => "",
}
}
/// Parameters implied by the menu item a command was reached through.
fn params_for(path: &str, id: &str) -> Value {
let leaf = path.rsplit(" > ").next().unwrap_or("");
match id {
"view.ruler" => json!({"ruler": ruler_id(leaf)}),
"view.main_counter" => json!({"format": ruler_id(leaf)}),
"options.scrolling" => {
json!({"mode": match leaf { "No Scrolling" => "none", "After Playback" => "after_playback", "Continuous" => "continuous", "Center Playhead" => "center", _ => "page" }})
}
"options.solo_mode" => {
json!({"mode": if leaf.starts_with("AFL") { "afl" } else if leaf.starts_with("PFL") { "pfl" } else { "sip" }, "xor": leaf.starts_with("X-OR")})
}
"clip.rating" => json!({"rating": leaf.parse::<u8>().unwrap_or(0)}),
"track.change_width" => json!({"format": leaf}),
"view.mix_section" => json!({"section": mix_section_id(leaf)}),
"audiosuite.process" => json!({"process": AUDIOSUITE.iter().find(|(p, _)| *p == path).map_or("", |(_, x)| *x)}),
_ => json!({}),
}
}
/// Menu-style invocation: may open a dialog.
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
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);
return;
}
if DIALOG_COMMANDS.contains(&id) && app.dialogs.open_for_command(&app.engine, id) {
return;
}
let params = params_for(path, id);
let _ = app.run(id, params);
}
/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled.
pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool {
DIALOG_COMMANDS.contains(&id) && app.dialogs.open_for_command(&app.engine, id)
}
/// Handle UI-layer commands. Returns None when `id` is not a UI command.
pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let toggle = |v: &mut bool| {
*v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v);
json!({"value": *v})
};
let r = match id {
"window.mix" => {
app.ui.window = MainWindow::Mix;
json!({"window": "mix"})
}
"window.edit" => {
app.ui.window = MainWindow::Edit;
json!({"window": "edit"})
}
"window.toggle_mix_edit" => {
app.ui.window = if app.ui.window == MainWindow::Edit { MainWindow::Mix } else { MainWindow::Edit };
json!({"window": app.ui.window})
}
"window.transport" => toggle(&mut app.ui.show_transport),
"window.big_counter" => toggle(&mut app.ui.show_big_counter),
"window.memory_locations" => toggle(&mut app.ui.show_memory_locations),
"window.undo_history" => toggle(&mut app.ui.show_undo_history),
"window.clip_list" | "window.clip_list_view" => toggle(&mut app.ui.show_clip_list),
"window.track_list" => toggle(&mut app.ui.show_tracks_list),
"window.narrow_mix" => toggle(&mut app.ui.narrow_mix),
"window.session_info" => toggle(&mut app.ui.show_session_info),
"window.about" => toggle(&mut app.ui.show_about),
"window.midi_editor" => toggle(&mut app.ui.show_midi_editor),
"window.universe" => toggle(&mut app.ui.show_universe),
"window.close" | "window.hide_floating" => {
app.ui.show_transport = false;
app.ui.show_big_counter = false;
app.ui.show_memory_locations = false;
app.ui.show_undo_history = false;
app.ui.show_session_info = false;
app.ui.plugin_windows.clear();
app.ui.audiosuite = None;
json!({})
}
"view.mix_section" => {
let sec = p.get("section").and_then(Value::as_str).unwrap_or("");
match sec {
"all" => {
app.ui.mix_views =
["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(|s| s.to_string()).collect()
}
"minimal" => app.ui.mix_views.clear(),
"" => return Some(Err("`section` required".into())),
s => {
if app.ui.mix_views.iter().any(|v| v == s) {
app.ui.mix_views.retain(|v| v != s);
} else {
app.ui.mix_views.push(s.to_string());
}
}
}
json!({"mix_views": app.ui.mix_views})
}
"ui.new_tracks_dialog" => {
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
json!({})
}
"ui.bounce_dialog" => {
let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix");
json!({})
}
"ui.set" => match serde_json::to_value(&app.ui) {
Ok(mut cur) => {
if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) {
for (k, v) in patch {
obj.insert(k.clone(), v.clone());
}
}
match serde_json::from_value(cur) {
Ok(ns) => {
app.ui = ns;
json!({"ui": app.ui})
}
Err(e) => return Some(Err(format!("bad ui state: {e}"))),
}
}
Err(e) => return Some(Err(e.to_string())),
},
_ => return None,
};
Some(Ok(r))
}
/// Parity including UI-layer commands.
pub fn parity() -> Value {
let extra = ui_aliases();
catalog::parity_with(&extra)
}

View File

@ -0,0 +1,394 @@
//! The Mix window: one channel strip per track.
use crate::theme::{Tokens, bold, regular, rgb};
use crate::widgets::{db_text, fader, meter, pan_knob, pan_text, rec_toggle, selector_box, text_toggle};
use crate::{SoundApp, panels};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{Route, Track, TrackId, TrackKind};
pub fn strip_width(narrow: bool) -> f32 {
if narrow { 64.0 } else { 98.0 }
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
if app.ui.show_tracks_list {
egui::Panel::left("mix_tracks_list")
.exact_size(168.0)
.frame(egui::Frame::NONE.fill(t.panel_bg))
.show(ui, |ui| panels::tracks_and_groups(app, ui));
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| {
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden).map(|x| x.id).collect();
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
for id in ids {
strip(app, ui, id);
}
});
});
});
}
fn section_label(ui: &Ui, r: Rect, text: &str) {
ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Color32::from_rgb(200, 200, 200));
}
fn route_name(app: &SoundApp, r: &Route) -> String {
match r {
Route::None => "no output".into(),
Route::Main => "Out 1-2".into(),
Route::Bus(b) => app.engine.session().bus(*b).map_or_else(|| "bus?".into(), |b| b.name.clone()),
Route::Hardware(h) => h.clone(),
}
}
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
let t = Tokens::DARK;
let Some(track) = app.engine.session().track(id).cloned() else { return };
let narrow = app.ui.narrow_mix;
let w = strip_width(narrow);
let h = ui.available_height().max(560.0);
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
let selected = app.engine.session().edit.selected_tracks.contains(&id);
ui.painter().rect_filled(r, 0.0, if selected { Color32::from_rgb(54, 58, 64) } else { t.strip_bg });
let mut y = r.min.y + 4.0;
let inner_w = w - 8.0;
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 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);
}
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);
}
y = sec.max.y + 4.0;
}
if 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");
let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0));
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
let input =
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) };
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(in_r));
let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text);
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, true));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(out_r));
let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text);
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, false));
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim);
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(am_r));
let mode = track.mixer.automation_mode;
let col = if mode == soundcraft_model::AutomationMode::Read {
t.auto_read
} else if mode == soundcraft_model::AutomationMode::Off {
t.text_dim
} else {
t.auto_write
};
let resp = selector_box(&mut c, am_r.width(), am_r.height(), &format!("auto {}", mode.label()), col);
egui::Popup::menu(&resp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()}));
}
}
});
y = sec.max.y + 4.0;
}
// Group selector.
let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or("no group".to_string(), |g| g.name.clone());
let gr = Rect::from_min_size(pos2(x0 + 2.0, y), vec2(inner_w - 4.0, 16.0));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr));
let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim);
y += 22.0;
// Pan.
if !track.mixer.pan.is_empty() && track.kind != TrackKind::Vca {
let n = track.mixer.pan.len().min(2);
let ks = if n == 2 { (inner_w / 2.0 - 6.0).min(32.0) } else { 34.0 };
for i in 0..n {
let cx = if n == 2 { x0 + inner_w * (0.25 + 0.5 * i as f32) } else { x0 + inner_w * 0.5 };
let kr = Rect::from_center_size(pos2(cx, y + ks * 0.5), vec2(ks, ks));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(kr));
let mut v = track.mixer.pan.get(i).copied().unwrap_or(0.0);
let before = v;
let resp = pan_knob(&mut c, ks, &mut v, "Pan (drag; double-click centres)");
if (v - before).abs() > f32::EPSILON {
let _ = app.engine.execute("mix.pan", &json!({"track": id.0, "pan": v, "index": i}));
}
if resp.drag_stopped() {
app.engine.push_undo("Pan", app.engine.session_arc());
}
let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0));
ui.painter().rect_filled(pr, 1.0, t.counter_bg);
ui.painter().text(pr.center(), Align2::CENTER_CENTER, pan_text(v), regular(10.0), t.counter_text);
}
y += ks + 20.0;
}
// Rec / Input / Solo / Mute.
let bw = (inner_w - 6.0) / 2.0;
let mut row = ui.new_child(
egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(x0, y), vec2(inner_w, 20.0))).layout(egui::Layout::left_to_right(egui::Align::Min)),
);
row.spacing_mut().item_spacing.x = 6.0;
if track.kind.has_playlist() {
if text_toggle(&mut row, vec2(bw, 18.0), "I", track.mixer.input_monitor, t.input, "Input monitoring").clicked() {
let _ = app.run("mix.input_monitor", json!({"track": id.0}));
}
if rec_toggle(&mut row, vec2(bw, 18.0), track.mixer.record_arm, "Record enable").clicked() {
let _ = app.run("mix.record_arm", json!({"track": id.0}));
}
}
y += 22.0;
let mut row = ui.new_child(
egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(x0, y), vec2(inner_w, 20.0))).layout(egui::Layout::left_to_right(egui::Align::Min)),
);
row.spacing_mut().item_spacing.x = 6.0;
if track.kind != TrackKind::Master {
if text_toggle(&mut row, vec2(bw, 18.0), "S", track.mixer.solo, t.solo, "Solo").clicked() {
let _ = app.run("mix.solo", json!({"track": id.0}));
}
if text_toggle(&mut row, vec2(bw, 18.0), "M", track.mixer.mute, t.mute, "Mute").clicked() {
let _ = app.run("mix.mute", json!({"track": id.0}));
}
}
y += 26.0;
// Fader + meter.
let bottom_h = 64.0;
let fh = (r.max.y - bottom_h - y).max(120.0);
let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * 0.6, fh));
if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) {
if app.gesture.is_none() {
app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db });
app.ui.status = String::new();
}
let before = app.engine.session_arc();
let _ = app.engine.execute("mix.volume", &json!({"track": id.0, "db": db}));
let _ = before;
}
if !ui.input(|i| i.pointer.any_down())
&& let Some(crate::Gesture::Fader { track: ft, start_db }) = app.gesture.clone()
&& ft == id
{
app.gesture = None;
// Collapse the drag into a single undo step.
let end = app.engine.session().track(id).map_or(start_db, |t| t.mixer.volume_db);
if (end - start_db).abs() > 0.001 {
let _ = app.engine.execute("mix.volume", &json!({"track": id.0, "db": start_db}));
let _ = app.run("mix.volume", json!({"track": id.0, "db": end}));
}
}
let md = app.meters.get(&id).copied().unwrap_or_default();
let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, fr.min.y + 6.0), pos2(x0 + inner_w - 2.0, fr.max.y - 6.0));
let chans = if track.kind == TrackKind::Master { 2 } else { track.channels().min(2) };
if chans >= 2 {
let mw = mr.width() / 2.0 - 1.0;
meter(ui, Rect::from_min_size(mr.min, vec2(mw, mr.height())), md.level[0], md.hold[0], md.clip);
meter(ui, Rect::from_min_size(pos2(mr.min.x + mw + 2.0, mr.min.y), vec2(mw, mr.height())), md.level[1], md.hold[1], md.clip);
} else {
meter(ui, mr, md.level[0], md.hold[0], md.clip);
}
if md.gr > 0.1 {
ui.painter().text(pos2(mr.center().x, mr.min.y - 3.0), Align2::CENTER_BOTTOM, format!("-{:.0}", md.gr), regular(8.0), t.meter_yellow);
}
// Volume readout and name.
let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0));
ui.painter().rect_filled(vr, 1.0, t.counter_bg);
ui.painter().text(vr.center(), Align2::CENTER_CENTER, db_text(track.mixer.volume_db), regular(11.0), t.counter_text);
let kind = match track.kind {
TrackKind::Audio => "audio",
TrackKind::Aux => "aux",
TrackKind::Master => "master",
TrackKind::Midi => "midi",
TrackKind::Instrument => "inst",
TrackKind::Vca => "vca",
TrackKind::Folder => "folder",
};
ui.painter().text(pos2(vr.center().x, vr.max.y + 9.0), Align2::CENTER_CENTER, kind, regular(9.5), t.text_dim);
let nr = Rect::from_min_size(pos2(x0, r.max.y - 26.0), vec2(inner_w, 18.0));
ui.painter().rect(
nr,
CornerRadius::same(2),
if selected { t.name_field_sel } else { t.name_field },
Stroke::new(1.0, Color32::BLACK),
StrokeKind::Inside,
);
ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark);
let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click());
if nresp.clicked() {
let _ = app.run("edit.select", json!({"tracks": [id.0]}));
}
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
}
if app.ui.mix_views.iter().any(|v| v == "color") {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x, r.max.y - 5.0), vec2(w, 5.0)), 0.0, rgb(track.color));
}
ui.painter().line_segment([pos2(r.max.x, r.min.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border));
}
fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::DARK;
let id = track.id;
let ins = track.mixer.inserts.get(slot).cloned().flatten();
let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click());
let fill = match &ins {
Some(i) if i.bypass => Color32::from_rgb(70, 56, 30),
Some(_) => Color32::from_rgb(52, 62, 80),
None => t.slot_bg,
};
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
StrokeKind::Inside,
);
let label = ins.as_ref().and_then(|i| soundcraft_dsp::plugin_info(&i.plugin)).map_or("", |p| p.short_name);
if label.is_empty() {
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
} 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));
}
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()));
}
pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, occupied: bool) {
if occupied {
if ui.button("Bypass").clicked() {
let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot}));
}
if ui.button("no insert").clicked() {
let _ = app.run("mix.insert_remove", json!({"track": id.0, "slot": slot}));
}
ui.separator();
}
let mut cats: Vec<soundcraft_dsp::Category> = Vec::new();
for p in soundcraft_dsp::plugins() {
if !p.is_instrument && !cats.contains(&p.category) {
cats.push(p.category);
}
}
for c in cats {
ui.menu_button(format!("{c:?}"), |ui| {
for p in soundcraft_dsp::plugins().iter().filter(|p| p.category == c && !p.is_instrument) {
if ui.button(p.name).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id}));
}
}
});
}
}
fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::DARK;
let id = track.id;
let snd = track.mixer.sends.get(slot).cloned().flatten();
let resp = ui.interact(r, ui.id().with(("snd", id.0, slot)), Sense::click_and_drag());
let fill = if snd.is_some() { Color32::from_rgb(46, 66, 56) } else { t.slot_bg };
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, Color32::from_rgb(16, 16, 16)), StrokeKind::Inside);
match &snd {
Some(s) => {
let name = route_name(app, &s.target);
// Level bar.
let k = soundcraft_model::fader_db_to_pos(s.level_db);
ui.painter().rect_filled(
Rect::from_min_max(pos2(r.min.x + 1.0, r.max.y - 3.0), pos2(r.min.x + 1.0 + (r.width() - 2.0) * k, r.max.y - 1.0)),
0.0,
t.counter_text,
);
ui.painter().with_clip_rect(r).text(
r.center() - vec2(0.0, 1.0),
Align2::CENTER_CENTER,
name,
regular(10.0),
if s.mute { t.mute } else { t.text },
);
if resp.dragged() {
let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0);
let _ = app.engine.execute("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}));
}
if resp.double_clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
}
let _ = resp.clone().on_hover_text(format!("Send {}: {} dB (drag to change)", (b'A' + slot as u8) as char, db_text(s.level_db)));
}
None => {
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
}
}
let popup = if snd.is_none() { egui::Popup::menu(&resp) } else { egui::Popup::context_menu(&resp) };
popup.show(|ui| {
if snd.is_some() && ui.button("no send").clicked() {
let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
}
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
for b in busses {
if ui.button(&b).clicked() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": b}));
}
}
ui.separator();
if ui.button("new bus...").clicked() {
let n = app.engine.session().busses.len() + 1;
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
}
});
}
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
let key = if input { "input" } else { "output" };
let cmd = if input { "track.input" } else { "track.output" };
if ui.button(if input { "no input" } else { "no output" }).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
}
if !input && ui.button("Out 1-2").clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "main"}));
}
if input {
for i in 1..=8 {
if ui.button(format!("In {i}")).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
}
}
}
ui.separator();
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
ui.menu_button("bus", |ui| {
for b in &busses {
if ui.button(b).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: b}));
}
}
if ui.button("new bus...").clicked() {
let n = busses.len() + 1;
let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")}));
}
});
}

View File

@ -0,0 +1,359 @@
//! Side panels (Tracks, Groups, Clips) and floating windows.
use crate::SoundApp;
use crate::theme::{Tokens, bold, mono, regular, rgb};
use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_time::{TimeFormat, format_position};
fn panel_header(ui: &mut Ui, title: &str) {
let t = Tokens::DARK;
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, t.panel_bg2);
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, title, bold(11.5), t.header_text);
ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border));
}
pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let total = ui.available_height();
panel_header(ui, "TRACKS");
let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect()
};
egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).show(ui, |ui| {
for (id, name, shown, color, sel) in tracks {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
if sel {
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96));
}
let dot = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(8.0, 8.0));
let dresp = ui.interact(dot.expand(3.0), ui.id().with(("vis", id)), Sense::click());
ui.painter().circle(
dot.center(),
3.5,
if shown { Color32::from_rgb(200, 200, 200) } else { Color32::TRANSPARENT },
Stroke::new(1.0, t.text_dim),
);
if dresp.clicked() {
let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown}));
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim });
if resp.clicked() {
let _ = app.run("edit.select", json!({"tracks": [id]}));
}
}
});
ui.add_space(4.0);
panel_header(ui, "GROUPS");
let groups: Vec<(u64, char, String, bool, [u8; 3])> =
app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect();
egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).show(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover());
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "<ALL>", regular(11.5).clone(), t.text);
for (id, letter, name, active, color) in groups {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
if active {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 2.0), vec2(14.0, 13.0)), 1.0, rgb(color));
}
ui.painter().text(
pos2(r.min.x + 11.0, r.center().y),
Align2::CENTER_CENTER,
letter.to_string(),
bold(10.0),
if active { t.text_dark } else { t.text_dim },
);
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text);
if resp.clicked() {
let _ = app.run("track.group_toggle", json!({"group": id}));
}
if resp.double_clicked() {
let members: Vec<u64> =
app.engine.session().groups.iter().find(|g| g.id.0 == id).map(|g| g.members.iter().map(|m| m.0).collect()).unwrap_or_default();
let _ = app.run("edit.select", json!({"tracks": members}));
}
}
});
}
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
panel_header(ui, "CLIPS");
let items: Vec<(Option<u64>, String, bool, [u8; 3])> = {
let s = app.engine.session();
let mut v: Vec<(Option<u64>, String, bool, [u8; 3])> = Vec::new();
// Whole files first (bold), then clips on tracks.
for src in &s.sources {
v.push((None, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150]));
}
for tr in &s.tracks {
for c in tr.clips() {
v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color)));
}
}
v
};
let selected: Vec<u64> = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect();
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
for (id, name, whole, color) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
if id.is_some_and(|i| selected.contains(&i)) {
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96));
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color));
ui.painter().with_clip_rect(r).text(
pos2(r.min.x + 22.0, r.center().y),
Align2::LEFT_CENTER,
&name,
if whole { bold(11.0) } else { regular(11.0) },
t.text,
);
if resp.clicked()
&& let Some(i) = id
{
let _ = app.run("edit.select", json!({"clips": [i]}));
}
}
});
}
/// Floating windows.
pub fn floating(app: &mut SoundApp, ctx: &egui::Context) {
transport_window(app, ctx);
memory_locations(app, ctx);
big_counter(app, ctx);
undo_history(app, ctx);
plugin_windows(app, ctx);
about(app, ctx);
session_info(app, ctx);
}
fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_transport;
if !open {
return;
}
egui::Window::new("Transport").open(&mut open).resizable(false).default_pos(pos2(400.0, 500.0)).show(ctx, |ui| {
ui.horizontal(|ui| {
for (icon, cmd) in [
("rtz", "transport.rtz"),
("rewind", "transport.rewind"),
("stop", "transport.stop"),
("play", "transport.play"),
("ffwd", "transport.fast_forward"),
("end", "transport.go_to_end"),
("record", "transport.record"),
] {
if crate::widgets::icon_button(ui, vec2(34.0, 26.0), icon, false, cmd).clicked() {
let _ = app.run(cmd, json!({}));
}
}
});
let s = app.engine.session();
let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
ui.label(egui::RichText::new(txt).font(mono(20.0)).color(Tokens::DARK.counter_text));
});
app.ui.show_transport = open;
}
fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_big_counter;
if !open {
return;
}
egui::Window::new("Big Counter").open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| {
let s = app.engine.session();
let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let r = ui.available_rect_before_wrap();
ui.painter().rect_filled(r, 4.0, Color32::BLACK);
ui.painter().text(r.center(), Align2::CENTER_CENTER, txt, mono((r.height() * 0.6).clamp(20.0, 120.0)), Tokens::DARK.counter_text);
});
app.ui.show_big_counter = open;
}
fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_memory_locations;
if !open {
return;
}
egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(420.0, 260.0)).show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("+ New").clicked() {
let _ = app.run("markers.add", json!({}));
}
});
let markers: Vec<(u32, String, i64, String)> = {
let s = app.engine.session();
s.markers
.iter()
.map(|m| {
(m.number, m.name.clone(), m.start, format_position(m.start, TimeFormat::BarsBeats, s.sample_rate, &s.tempo, s.frame_rate, 0))
})
.collect()
};
egui::Grid::new("mem_grid").striped(true).num_columns(4).show(ui, |ui| {
ui.label(egui::RichText::new("#").strong());
ui.label(egui::RichText::new("Name").strong());
ui.label(egui::RichText::new("Location").strong());
ui.label("");
ui.end_row();
for (n, name, _, loc) in markers {
if ui.button(n.to_string()).clicked() {
let _ = app.run("markers.recall", json!({"number": n}));
}
ui.label(name);
ui.label(loc);
if ui.small_button("✕").clicked() {
let _ = app.run("markers.delete", json!({"number": n}));
}
ui.end_row();
}
});
});
app.ui.show_memory_locations = open;
}
fn undo_history(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_undo_history;
if !open {
return;
}
egui::Window::new("Undo History").open(&mut open).default_size(vec2(260.0, 300.0)).show(ctx, |ui| {
let hist = app.engine.undo_history();
let n = hist.len();
egui::ScrollArea::vertical().show(ui, |ui| {
for (i, h) in hist.iter().enumerate() {
if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() {
for _ in 0..(n - i - 1) {
let _ = app.run("edit.undo", json!({}));
}
}
}
if let Some(r) = app.engine.redo_label() {
ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::DARK.text_dim));
}
});
});
app.ui.show_undo_history = open;
}
fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
let wins = app.ui.plugin_windows.clone();
let mut keep = Vec::new();
for (tid, slot) in wins {
let Some((tname, ins)) =
app.engine.session().track(tid).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten().map(|i| (t.name.clone(), i)))
else {
continue;
};
let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin) else { continue };
let mut open = true;
egui::Window::new(format!("{tname} · {} · {}", (b'a' + slot as u8) as char, info.name))
.id(egui::Id::new(("plugin", tid.0, slot)))
.open(&mut open)
.default_width(340.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim));
let label = if ins.bypass { "BYPASSED" } else { "Bypass" };
if ui.button(label).clicked() {
let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot}));
}
});
if info.id == "eq_7band" || info.id == "eq_1band" {
eq_curve(ui, info.id, &ins);
}
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, c) in p.choices.iter().enumerate() {
ui.selectable_value(&mut idx, i, *c);
}
});
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
};
if (x - v).abs() > f32::EPSILON {
let _ = app.engine.execute("mix.insert_param", &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}));
}
let _ = resp;
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
ui.end_row();
}
});
});
if open {
keep.push((tid, slot));
}
}
app.ui.plugin_windows = keep;
}
fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) {
let t = Tokens::DARK;
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().max(300.0), 110.0), Sense::hover());
ui.painter().rect_filled(r, 4.0, Color32::from_rgb(14, 18, 22));
let n = 160;
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 id == "eq_7band" {
soundcraft_dsp::eq7_response(&params, &freqs, 48_000.0)
} else {
soundcraft_dsp::eq1_response(&params, &freqs, 48_000.0)
};
for db in [-12.0f32, 0.0, 12.0] {
let y = r.center().y - db / 18.0 * r.height() * 0.5;
ui.painter().line_segment([pos2(r.min.x, y), pos2(r.max.x, y)], Stroke::new(1.0, Color32::from_rgb(40, 50, 60)));
}
let pts: Vec<egui::Pos2> = resp
.iter()
.enumerate()
.map(|(i, db)| pos2(r.min.x + r.width() * i as f32 / (n - 1) as f32, r.center().y - db.clamp(-18.0, 18.0) / 18.0 * r.height() * 0.5))
.collect();
ui.painter().add(egui::Shape::line(pts, Stroke::new(2.0, t.counter_text)));
}
fn about(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_about;
if !open {
return;
}
egui::Window::new("About SoundCraft").open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| {
ui.label(egui::RichText::new("SoundCraft").font(bold(22.0)));
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
ui.label("An open-source digital audio workstation from the ArtCraft team.");
ui.label("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui.");
ui.hyperlink_to("getartcraft.com/apps/soundcraft", "https://getartcraft.com/apps/soundcraft");
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
});
app.ui.show_about = open;
}
fn session_info(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_session_info;
if !open {
return;
}
egui::Window::new("Session Info").open(&mut open).default_size(vec2(480.0, 360.0)).show(ctx, |ui| {
let text = soundcraft_engine::inspect::session_text(&app.engine);
egui::ScrollArea::vertical().show(ui, |ui| {
ui.label(egui::RichText::new(text).font(mono(11.0)));
});
});
app.ui.show_session_info = open;
}

View File

@ -0,0 +1,156 @@
//! Keyboard shortcuts. Most come from the command registry's `shortcut` strings; a few
//! context-sensitive keys (Space, Enter, F-keys, nudge, Tab) are handled here.
use crate::{MainWindow, SoundApp};
use egui::{Key, Modifiers};
use serde_json::json;
/// Parse "Cmd+Shift+X" style strings. Returns None for descriptive ones ("F1-F4", "Plus/Minus").
pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
let mut m = Modifiers::NONE;
let mut key = None;
for part in s.split('+') {
match part.trim() {
"Cmd" => m.command = true,
"Shift" => m.shift = true,
"Alt" => m.alt = true,
"Ctrl" => m.ctrl = true,
"" => {}
k => {
if key.is_some() {
return None;
}
key = Some(match k {
"Space" => Key::Space,
"Enter" => Key::Enter,
"Home" => Key::Home,
"End" => Key::End,
"Tab" => Key::Tab,
"[" => Key::OpenBracket,
"]" => Key::CloseBracket,
"," => Key::Comma,
"." => Key::Period,
"/" => Key::Slash,
"'" => Key::Quote,
"=" => Key::Equals,
other => Key::from_name(other)?,
});
}
}
}
key.map(|k| (m, k))
}
fn mods_match(want: Modifiers, got: Modifiers) -> bool {
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd)
}
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
return;
}
let events: Vec<(Key, Modifiers)> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
.collect()
});
for (key, mods) in events {
if fixed(app, key, mods) {
continue;
}
let hit =
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods)));
if let Some(c) = hit {
let id = c.id;
if crate::menus::invoke_shortcut_dialog(app, id) {
continue;
}
let _ = app.run(id, json!({}));
}
}
}
/// Keys with context-dependent meaning. Returns true when handled.
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
match key {
Key::Space if !m.command && !m.alt => {
let id = if m.shift { "transport.half_speed" } else { "transport.toggle" };
let _ = app.run(id, json!({}));
true
}
Key::Space if m.command => {
let _ = app.run("transport.record", json!({}));
true
}
Key::Equals if m.command => {
let _ = app.run("window.toggle_mix_edit", json!({}));
true
}
Key::Enter if plain => {
let _ = app.run("markers.add", json!({}));
true
}
Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => {
let mode = match key {
Key::F1 => "shuffle",
Key::F2 => "slip",
Key::F3 => "spot",
_ => "grid",
};
let _ = app.run("edit.mode", json!({"mode": mode}));
true
}
Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => {
let tool = match key {
Key::F5 => "zoom",
Key::F6 => "trim",
Key::F7 => "selector",
Key::F8 => "grabber",
Key::F9 => "scrubber",
_ => "pencil",
};
let _ = app.run("edit.tool", json!({"tool": tool}));
true
}
Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => {
let dir = if key == Key::Plus { 1 } else { -1 };
let _ = app.run("edit.nudge", json!({"direction": dir}));
true
}
Key::Tab if plain || m.alt => {
// Tab to next clip boundary (or transient when enabled).
let s = app.engine.session();
let at = s.edit.selection.start;
let back = m.alt;
let mut edges: Vec<i64> = s
.tracks
.iter()
.filter(|t| s.edit.selected_tracks.contains(&t.id))
.flat_map(|t| t.clips().iter().flat_map(|c| [c.start, c.end()]))
.collect();
edges.sort_unstable();
let next = if back { edges.iter().rev().find(|e| **e < at).copied() } else { edges.iter().find(|e| **e > at).copied() };
if let Some(n) = next {
let _ = app.run("transport.locate", json!({"at": n}));
}
true
}
Key::ArrowUp | Key::ArrowDown if m.ctrl => {
let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" };
let _ = app.run(id, json!({}));
true
}
Key::R | Key::T if plain => {
// Zoom out / in on the timeline (Commands Focus style).
let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" };
let _ = app.run(id, json!({}));
true
}
_ => false,
}
}

174
crates/ui-egui/src/theme.rs Normal file
View File

@ -0,0 +1,174 @@
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
use egui::{Color32, FontFamily, FontId};
#[derive(Clone, Copy, Debug)]
pub struct Tokens {
pub window_bg: Color32,
pub panel_bg: Color32,
pub panel_bg2: Color32,
pub toolbar_bg: Color32,
pub toolbar_group: Color32,
pub border: Color32,
pub border_light: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_dark: Color32,
pub header_text: Color32,
pub counter_bg: Color32,
pub counter_text: Color32,
pub counter_label: Color32,
pub accent: Color32,
pub accent_dark: Color32,
pub mode_on: Color32,
pub mode_on_text: Color32,
pub mode_off_text: Color32,
pub button: Color32,
pub button_hi: Color32,
pub button_border: Color32,
pub name_field: Color32,
pub name_field_sel: Color32,
pub ruler_bg: Color32,
pub ruler_label_bg: Color32,
pub ruler_text: Color32,
pub ruler_tick: Color32,
pub tempo_ruler: Color32,
pub meter_ruler: Color32,
pub marker_ruler: Color32,
pub playlist_bg: Color32,
pub playlist_alt: Color32,
pub grid_line: Color32,
pub bar_line: Color32,
pub selection: Color32,
pub playhead: Color32,
pub insertion: Color32,
pub solo: Color32,
pub mute: Color32,
pub rec: Color32,
pub input: Color32,
pub auto_read: Color32,
pub auto_write: Color32,
pub auto_touch: Color32,
pub auto_latch: Color32,
pub meter_green: Color32,
pub meter_yellow: Color32,
pub meter_red: Color32,
pub fader_track: Color32,
pub strip_bg: Color32,
pub strip_section: Color32,
pub slot_bg: Color32,
pub automation_line: Color32,
}
impl Tokens {
pub const DARK: Tokens = Tokens {
window_bg: Color32::from_rgb(30, 30, 30),
panel_bg: Color32::from_rgb(37, 37, 38),
panel_bg2: Color32::from_rgb(44, 44, 45),
toolbar_bg: Color32::from_rgb(30, 30, 31),
toolbar_group: Color32::from_rgb(24, 24, 25),
border: Color32::from_rgb(14, 14, 14),
border_light: Color32::from_rgb(64, 64, 66),
text: Color32::from_rgb(214, 214, 214),
text_dim: Color32::from_rgb(150, 150, 152),
text_dark: Color32::from_rgb(16, 16, 16),
header_text: Color32::from_rgb(196, 196, 198),
counter_bg: Color32::from_rgb(4, 4, 4),
counter_text: Color32::from_rgb(104, 220, 120),
counter_label: Color32::from_rgb(200, 200, 200),
accent: Color32::from_rgb(64, 132, 196),
accent_dark: Color32::from_rgb(40, 92, 146),
mode_on: Color32::from_rgb(92, 196, 108),
mode_on_text: Color32::from_rgb(10, 30, 12),
mode_off_text: Color32::from_rgb(98, 196, 112),
button: Color32::from_rgb(58, 58, 60),
button_hi: Color32::from_rgb(78, 78, 80),
button_border: Color32::from_rgb(20, 20, 20),
name_field: Color32::from_rgb(208, 208, 208),
name_field_sel: Color32::from_rgb(232, 232, 232),
ruler_bg: Color32::from_rgb(40, 40, 41),
ruler_label_bg: Color32::from_rgb(40, 40, 41),
ruler_text: Color32::from_rgb(178, 178, 180),
ruler_tick: Color32::from_rgb(96, 96, 98),
tempo_ruler: Color32::from_rgb(52, 128, 88),
meter_ruler: Color32::from_rgb(44, 108, 140),
marker_ruler: Color32::from_rgb(46, 46, 48),
playlist_bg: Color32::from_rgb(36, 36, 37),
playlist_alt: Color32::from_rgb(40, 40, 41),
grid_line: Color32::from_rgb(52, 52, 54),
bar_line: Color32::from_rgb(66, 66, 70),
selection: Color32::from_rgba_premultiplied(40, 70, 110, 110),
playhead: Color32::from_rgb(232, 60, 50),
insertion: Color32::from_rgb(230, 230, 230),
solo: Color32::from_rgb(222, 196, 52),
mute: Color32::from_rgb(232, 148, 40),
rec: Color32::from_rgb(214, 52, 46),
input: Color32::from_rgb(70, 170, 90),
auto_read: Color32::from_rgb(96, 200, 110),
auto_write: Color32::from_rgb(220, 70, 60),
auto_touch: Color32::from_rgb(230, 170, 50),
auto_latch: Color32::from_rgb(160, 120, 220),
meter_green: Color32::from_rgb(60, 200, 80),
meter_yellow: Color32::from_rgb(230, 210, 60),
meter_red: Color32::from_rgb(230, 50, 40),
fader_track: Color32::from_rgb(12, 12, 12),
strip_bg: Color32::from_rgb(46, 46, 48),
strip_section: Color32::from_rgb(36, 36, 38),
slot_bg: Color32::from_rgb(28, 28, 29),
automation_line: Color32::from_rgb(240, 240, 240),
};
pub fn get(_ctx: &egui::Context) -> Tokens {
Tokens::DARK
}
}
/// Track colours tinted for clip bodies.
pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) {
let [r, g, b] = rgb;
let k = if selected { 0.95 } else { 0.62 };
let body = Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8);
let bar = Color32::from_rgb((f32::from(r) * 0.9) as u8, (f32::from(g) * 0.9) as u8, (f32::from(b) * 0.9) as u8);
let wave = if selected { Color32::from_rgb(20, 20, 24) } else { Color32::from_rgb(12, 12, 14) };
(body, bar, wave)
}
pub fn rgb(c: [u8; 3]) -> Color32 {
Color32::from_rgb(c[0], c[1], c[2])
}
pub fn bold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("bold".into()))
}
pub fn regular(size: f32) -> FontId {
FontId::new(size, FontFamily::Proportional)
}
pub fn mono(size: f32) -> FontId {
FontId::new(size, FontFamily::Monospace)
}
/// Apply visuals to the context.
pub fn apply(ctx: &egui::Context) {
let t = Tokens::DARK;
let mut v = egui::Visuals::dark();
v.panel_fill = t.panel_bg;
v.window_fill = t.panel_bg2;
v.extreme_bg_color = t.slot_bg;
v.faint_bg_color = t.panel_bg2;
v.selection.bg_fill = t.accent;
v.widgets.inactive.weak_bg_fill = t.button;
v.widgets.inactive.bg_fill = t.button;
v.widgets.hovered.weak_bg_fill = t.button_hi;
v.widgets.noninteractive.fg_stroke.color = t.text;
v.widgets.inactive.fg_stroke.color = t.text;
v.window_corner_radius = egui::CornerRadius::same(6);
v.menu_corner_radius = egui::CornerRadius::same(4);
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 2.0);
s.interaction.tooltip_delay = 0.4;
});
}

View File

@ -0,0 +1,385 @@
//! The Edit window toolbar.
use crate::theme::{Tokens, bold, mono, regular};
use crate::widgets::icon_button;
use crate::{SoundApp, icons};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{EditMode, Tool};
use soundcraft_time::{TimeFormat, format_length, format_position};
pub const HEIGHT: f32 = 78.0;
fn group(ui: &mut Ui, w: f32, h: f32, add: impl FnOnce(&mut Ui, Rect)) {
let t = Tokens::DARK;
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.toolbar_group, Stroke::new(1.0, Color32::from_rgb(8, 8, 8)), StrokeKind::Inside);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(5.0)).layout(egui::Layout::left_to_right(egui::Align::Min)));
add(&mut child, r);
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.toolbar_bg);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.spacing_mut().item_spacing.x = 8.0;
edit_modes(app, ui);
zoom_group(app, ui);
tools(app, ui);
counters(app, ui);
grid_nudge(app, ui);
transport(app, ui);
tempo_meter(app, ui);
output_meter(app, ui);
});
}
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let cur = app.engine.session().edit.edit_mode;
group(ui, 112.0, 52.0, |ui, r| {
let inner = r.shrink(6.0);
let cw = inner.width() / 2.0;
let ch = inner.height() / 2.0;
let modes = [
(EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"),
(EditMode::Spot, 1, 0, "SPOT", "F3"),
(EditMode::Slip, 0, 1, "SLIP", "F2"),
(EditMode::Grid, 1, 1, "GRID", "F4"),
];
for (m, cx, cy, label, key) in modes {
let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0);
let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click());
let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative);
ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { Color32::from_rgb(20, 36, 22) });
let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label };
ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text });
if resp.clicked() {
let id = match m {
EditMode::Grid if cur == EditMode::Grid => "relative_grid",
EditMode::Shuffle => "shuffle",
EditMode::Spot => "spot",
EditMode::Slip => "slip",
_ => "grid",
};
let _ = app.run("edit.mode", json!({"mode": id}));
}
resp.on_hover_text(format!("{} mode ({key})", label.to_lowercase()));
}
});
}
fn zoom_group(app: &mut SoundApp, ui: &mut Ui) {
group(ui, 128.0, 52.0, |ui, _| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 3.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
if icon_button(ui, vec2(28.0, 22.0), "minus", false, "Zoom out (Cmd+[)").clicked() {
let _ = app.run("view.zoom_out", json!({}));
}
if icon_button(ui, vec2(30.0, 22.0), "wave", false, "Waveform zoom in (Cmd+Alt+])").clicked() {
let _ = app.run("view.waveform_zoom", json!({"factor": 2.0}));
}
if icon_button(ui, vec2(28.0, 22.0), "plus", false, "Zoom in (Cmd+])").clicked() {
let _ = app.run("view.zoom_in", json!({}));
}
if icon_button(ui, vec2(28.0, 22.0), "zoom", false, "Fill window with session (Alt+A)").clicked() {
let w = ui.ctx().content_rect().width() - 420.0;
let _ = app.run("view.zoom_fit", json!({"width_px": w}));
}
});
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for i in 1..=5 {
let r = crate::widgets::text_toggle(
ui,
vec2(22.0, 18.0),
&i.to_string(),
false,
Color32::GRAY,
"Zoom preset (click to recall, Cmd-click to store)",
);
if r.clicked() {
let store = ui.input(|x| x.modifiers.command);
let _ = app.run("view.zoom_preset", json!({"preset": i, "store": store}));
}
}
});
});
});
}
fn tools(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let cur = app.engine.session().edit.tool;
group(ui, 268.0, 52.0, |ui, r| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 3.0);
let row = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (tool, icon) in [
(Tool::Zoom, "zoom"),
(Tool::Trim, "trim"),
(Tool::Selector, "selector"),
(Tool::Grabber, "grabber"),
(Tool::Scrubber, "scrubber"),
(Tool::Pencil, "pencil"),
] {
let smart_member = cur == Tool::Smart && matches!(tool, Tool::Trim | Tool::Selector | Tool::Grabber);
let tip = format!("{}{}", tool.label(), tool.fkey().map(|k| format!(" ({k})")).unwrap_or_default());
if icon_button(ui, vec2(36.0, 26.0), icon, cur == tool || smart_member, &tip).clicked() {
let _ = app.run("edit.tool", json!({"tool": tool.id()}));
}
}
});
// Smart tool bracket above Trim/Selector/Grabber.
let rr = row.response.rect;
let bx0 = rr.min.x + 38.0;
let bx1 = bx0 + 38.0 * 3.0 - 2.0;
let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0));
let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click());
ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { Color32::from_rgb(64, 64, 66) });
if resp.on_hover_text("Smart Tool (F6+F7 / F7+F8)").clicked() {
let _ = app.run("edit.tool", json!({"tool": "smart"}));
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let e = app.engine.session().edit.clone();
let toggles: [(&str, &str, bool, &str); 5] = [
("options.tab_to_transient", "selector", e.tab_to_transient, "Tab to Transient"),
("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),
("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"),
("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),
("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"),
];
for (id, icon, on, tip) in toggles {
if icon_button(ui, vec2(36.0, 18.0), icon, on, tip).clicked() {
let _ = app.run(id, json!({}));
}
}
});
});
});
}
fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
let s = app.engine.session();
format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)
}
fn counters(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let (main_fmt, sub_fmt, sel) = {
let s = app.engine.session();
(s.edit.main_counter, s.edit.sub_counter.unwrap_or(TimeFormat::Samples), s.edit.selection)
};
let pos = app.position();
let (r, _) = ui.allocate_exact_size(vec2(318.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside);
let main_r = Rect::from_min_max(r.min, pos2(r.min.x + 180.0, r.min.y + 44.0));
let txt = fmt_pos(app, pos, main_fmt);
ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text);
let resp = ui.interact(main_r, ui.id().with("main_counter"), Sense::click());
icons::draw(ui.painter(), Rect::from_center_size(pos2(main_r.max.x - 9.0, main_r.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
egui::Popup::menu(&resp).show(|ui| {
for f in TimeFormat::ALL {
if ui.selectable_label(main_fmt == f, f.label()).clicked() {
let _ = app.run("view.main_counter", json!({"format": f.id()}));
}
}
});
// Start / End / Length.
let s = app.engine.session();
let rows = [
("Start", fmt_pos(app, sel.start, main_fmt)),
("End", fmt_pos(app, sel.end, main_fmt)),
("Length", format_length(sel.start, sel.len(), main_fmt, s.sample_rate, &s.tempo, s.frame_rate)),
];
for (i, (label, val)) in rows.iter().enumerate() {
let y = r.min.y + 10.0 + i as f32 * 13.0;
ui.painter().text(pos2(r.min.x + 214.0, y), Align2::RIGHT_CENTER, *label, regular(11.0), t.counter_label);
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, val, mono(11.0), t.counter_text);
}
// Sub counter row.
let y = r.max.y - 11.0;
ui.painter()
.line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, Color32::from_rgb(40, 40, 40)));
ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, "Sub", regular(11.0), t.counter_label);
ui.painter().text(pos2(r.min.x + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text);
let st = if app.engine.is_dirty() { "modified" } else { "saved" };
let dev = app.player.as_ref().map_or("no audio engine", |p| if p.silent { "silent clock" } else { "audio on" });
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
}
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let (grid, nudge, lines) = {
let e = &app.engine.session().edit;
(e.grid, e.nudge, e.grid_lines)
};
let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside);
let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0));
ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { Color32::from_rgb(30, 50, 32) });
ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text });
if ui.interact(gl, ui.id().with("grid_lines"), Sense::click()).on_hover_text("Show grid lines").clicked() {
let _ = app.run("view.grid_lines", json!({}));
}
let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text);
let gresp = ui.interact(gr, ui.id().with("grid_value"), Sense::click());
value_menu(app, &gresp, "view.grid");
ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label);
let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0));
ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text);
let nresp = ui.interact(nr, ui.id().with("nudge_value"), Sense::click());
value_menu(app, &nresp, "view.nudge");
for rr in [gr, nr] {
icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
}
}
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) {
egui::Popup::menu(resp).show(|ui| {
for v in ["1 bar", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/8t", "1/16t", "1/4."] {
if ui.button(v).clicked() {
let _ = app.run(cmd, json!({"value": v}));
}
}
ui.separator();
for (label, v) in [
("1 msec", json!({"seconds": 0.001})),
("10 msec", json!({"seconds": 0.01})),
("100 msec", json!({"seconds": 0.1})),
("1 second", json!({"seconds": 1.0})),
("1 frame", json!({"frames": 1})),
("100 samples", json!({"samples": 100})),
] {
if ui.button(label).clicked() {
let _ = app.run(cmd, json!({"value": v}));
}
}
});
}
fn transport(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let playing = app.is_playing();
let recording = app.engine.transport.recording;
let looped = app.engine.session().edit.loop_playback;
group(ui, 176.0, 64.0, |ui, _| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 4.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
if icon_button(ui, vec2(38.0, 28.0), "loop", looped, "Loop Playback (Cmd+Shift+L)").clicked() {
let _ = app.run("options.loop_playback", json!({}));
}
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(3),
if resp.hovered() { t.button_hi } else { t.button },
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
icons::draw(ui.painter(), r.shrink(5.0), "stop", if playing { t.text } else { t.accent });
if resp.on_hover_text("Stop (Space)").clicked() {
let _ = app.run("transport.stop", json!({}));
}
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(3),
if playing {
Color32::from_rgb(40, 90, 50)
} else if resp.hovered() {
t.button_hi
} else {
t.button
},
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
icons::draw(ui.painter(), r.shrink(5.0), "play", if playing { Color32::from_rgb(120, 255, 140) } else { t.counter_text });
if resp.on_hover_text("Play (Space)").clicked() {
let _ = app.run("transport.play", json!({}));
}
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(3),
if recording {
Color32::from_rgb(110, 30, 30)
} else if resp.hovered() {
t.button_hi
} else {
t.button
},
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec);
if resp.on_hover_text("Record (Cmd+Space)").clicked() {
let _ = app.run("transport.record", json!({}));
}
});
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for (icon, cmd, tip) in [
("rtz", "transport.rtz", "Return to Zero (Home)"),
("rewind", "transport.rewind", "Rewind"),
("ffwd", "transport.fast_forward", "Fast Forward"),
("end", "transport.go_to_end", "Go to End (End)"),
] {
if icon_button(ui, vec2(38.0, 22.0), icon, false, tip).clicked() {
let _ = app.run(cmd, json!({}));
}
}
});
});
});
}
fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let (bpm, meter, countoff, click, bars) = {
let s = app.engine.session();
let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate);
let m = s.tempo.meter_at_tick(tick);
(s.tempo.tempo_at_tick(tick), format!("{}/{}", m.numerator, m.denominator), s.edit.countoff, s.edit.click, s.edit.countoff_bars)
};
let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside);
let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0));
ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { Color32::from_rgb(30, 50, 32) });
ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text });
if ui.interact(co, ui.id().with("countoff"), Sense::click()).clicked() {
let _ = app.run("options.countoff", json!({}));
}
ui.painter().text(pos2(r.max.x - 8.0, co.center().y), Align2::RIGHT_CENTER, format!("{bars} bars"), mono(11.0), t.counter_text);
ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 32.0), Align2::RIGHT_CENTER, "Meter", regular(11.0), t.counter_label);
ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 32.0), Align2::RIGHT_CENTER, meter, mono(11.0), t.counter_text);
ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 48.0), Align2::RIGHT_CENTER, "Tempo", regular(11.0), t.counter_label);
icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 82.0, r.min.y + 48.0), vec2(13.0, 13.0)), "note", t.counter_text);
ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 48.0), Align2::RIGHT_CENTER, format!("{bpm:.4}"), mono(11.0), t.counter_text);
let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0));
icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim });
if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() {
let _ = app.run("options.click", json!({}));
}
}
fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover());
let m = app.main_meter;
let w = (r.width() - 6.0) / 2.0;
for c in 0..2 {
let rr = Rect::from_min_size(pos2(r.min.x + 2.0 + c as f32 * (w + 2.0), r.min.y), vec2(w, r.height()));
crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip);
}
}

View File

@ -0,0 +1,215 @@
//! Small custom widgets in the studio style.
use crate::icons;
use crate::theme::{Tokens, bold, regular};
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
/// A toolbar button with an icon; `on` draws the blue selected state.
pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response {
let t = Tokens::DARK;
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if on {
t.accent
} else if resp.hovered() {
t.button_hi
} else {
t.button
};
ui.painter().rect(r, CornerRadius::same(3), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
let col = if on { Color32::WHITE } else { t.text };
let s = r.height().min(r.width()) * 0.78;
icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, col);
resp.on_hover_text(tip)
}
/// Square text toggle used for S / M / I / record etc.
pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response {
let t = Tokens::DARK;
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if on {
on_color
} else if resp.hovered() {
t.button_hi
} else {
t.button
};
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
let col = if on { t.text_dark } else { t.text };
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, bold((size.y * 0.62).clamp(8.0, 13.0)), col);
resp.on_hover_text(tip)
}
/// Record-enable button (red dot).
pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response {
let t = Tokens::DARK;
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if on {
t.rec
} else if resp.hovered() {
t.button_hi
} else {
t.button
};
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
ui.painter().circle_filled(r.center(), size.y * 0.24, if on { Color32::WHITE } else { Color32::from_rgb(190, 190, 190) });
resp.on_hover_text(tip)
}
/// A dropdown-looking selector box. Returns the response (caller opens a menu on click).
pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response {
let t = Tokens::DARK;
let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { t.button_hi } else { t.button },
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max);
icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim);
let galley = ui.painter().layout(text.to_string(), regular((height * 0.62).clamp(8.0, 12.0)), color, (width - height).max(10.0));
let pos = pos2(r.min.x + (r.width() - height * 0.6 - galley.size().x) * 0.5, r.center().y - galley.size().y * 0.5);
ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color);
resp
}
/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets.
pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response {
let t = Tokens::DARK;
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag());
if resp.dragged() {
let d = resp.drag_delta();
*value = (*value + (d.x - d.y) * 0.01).clamp(-1.0, 1.0);
}
if resp.double_clicked() {
*value = 0.0;
}
let c = r.center();
let rad = size * 0.42;
ui.painter().circle(c, rad, Color32::from_rgb(26, 26, 28), Stroke::new(1.0, Color32::from_rgb(90, 90, 94)));
let n = 24;
let start = std::f32::consts::PI * 0.75;
let sweep = std::f32::consts::PI * 1.5;
let arc = |a0: f32, a1: f32| -> Vec<egui::Pos2> {
(0..=n)
.map(|i| {
let a = a0 + (a1 - a0) * i as f32 / n as f32;
c + vec2(a.cos(), a.sin()) * (rad + 2.5)
})
.collect()
};
let mid = start + sweep * 0.5;
let at = start + sweep * (*value * 0.5 + 0.5);
let (a0, a1) = if at < mid { (at, mid) } else { (mid, at) };
ui.painter().add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text)));
let tip_pos = c + vec2(at.cos(), at.sin()) * rad * 0.85;
ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, Color32::from_rgb(230, 230, 230)));
resp.on_hover_text(tip)
}
/// Format a pan value like a console: "<45", "0", "45>".
pub fn pan_text(v: f32) -> String {
let n = (v * 100.0).round() as i32;
match n {
0 => "0".into(),
n if n < 0 => format!("<{}", -n),
n => format!("{n}>"),
}
}
/// Vertical peak meter with green/yellow/red zones; values are linear peaks.
pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) {
let t = Tokens::DARK;
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(8, 8, 8));
let pos = |v: f32| -> f32 {
let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() };
meter_pos(db)
};
let h = r.height() - 4.0;
let inner = Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 3.0), pos2(r.max.x - 1.0, r.max.y - 1.0));
let lv = pos(level);
if lv > 0.0 {
let top = inner.max.y - h * lv;
// Draw in zones.
let zones =
[(0.0, meter_pos(-12.0), t.meter_green), (meter_pos(-12.0), meter_pos(-3.0), t.meter_yellow), (meter_pos(-3.0), 1.0, t.meter_red)];
for (a, b, col) in zones {
let y0 = inner.max.y - h * a;
let y1 = (inner.max.y - h * b).max(top);
if y1 < y0 {
ui.painter().rect_filled(Rect::from_min_max(pos2(inner.min.x, y1), pos2(inner.max.x, y0)), 0.0, col);
}
}
}
let hv = pos(hold);
if hv > 0.01 {
let y = inner.max.y - h * hv;
ui.painter().line_segment(
[pos2(inner.min.x, y), pos2(inner.max.x, y)],
Stroke::new(1.0, if hv > meter_pos(-3.0) { t.meter_red } else { t.meter_yellow }),
);
}
let clip_r = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 2.5));
ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { Color32::from_rgb(60, 20, 20) });
}
/// dB → 0..1 meter position (logarithmic-ish scale like hardware meters).
pub fn meter_pos(db: f32) -> f32 {
if !db.is_finite() || db <= -80.0 {
return 0.0;
}
if db >= 0.0 {
return 1.0;
}
// -80..-40: bottom 10 %, -40..-20: 20 %, -20..0: 70 %.
if db < -40.0 {
(db + 80.0) / 40.0 * 0.1
} else if db < -20.0 {
0.1 + (db + 40.0) / 20.0 * 0.2
} else {
0.3 + (db + 20.0) / 20.0 * 0.7
}
}
/// A fader: returns the new dB value when dragged. `db` in -144..12.
pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option<f32> {
let t = Tokens::DARK;
let resp = ui.interact(r, id, Sense::click_and_drag());
let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - 14.0));
ui.painter().rect_filled(slot, 2.0, t.fader_track);
// Scale marks.
for mark in [12.0f32, 6.0, 0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0] {
let y = slot.max.y - slot.height() * soundcraft_model::fader_db_to_pos(mark);
ui.painter().line_segment([pos2(r.min.x + 2.0, y), pos2(slot.min.x - 3.0, y)], Stroke::new(1.0, Color32::from_rgb(110, 110, 110)));
ui.painter().text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), Color32::from_rgb(150, 150, 150));
}
let pos = soundcraft_model::fader_db_to_pos(db);
let y = slot.max.y - slot.height() * pos;
let cap = Rect::from_center_size(pos2(slot.center().x, y), vec2(r.width() * 0.62, 22.0));
ui.painter().rect(cap, 2.0, Color32::from_rgb(70, 70, 72), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside);
ui.painter().line_segment(
[pos2(cap.min.x + 2.0, cap.center().y), pos2(cap.max.x - 2.0, cap.center().y)],
Stroke::new(2.0, Color32::from_rgb(230, 230, 230)),
);
for dy in [-6.0, -3.0, 3.0, 6.0] {
ui.painter().line_segment(
[pos2(cap.min.x + 3.0, cap.center().y + dy), pos2(cap.max.x - 3.0, cap.center().y + dy)],
Stroke::new(1.0, Color32::from_rgb(40, 40, 40)),
);
}
if resp.double_clicked() || (resp.clicked() && ui.input(|i| i.modifiers.alt)) {
return Some(0.0);
}
if resp.dragged() {
let fine = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
let dy = resp.drag_delta().y / slot.height() * if fine { 0.1 } else { 1.0 };
let np = (pos - dy).clamp(0.0, 1.0);
return Some(soundcraft_model::fader_pos_to_db(np).clamp(-144.0, 12.0));
}
None
}
pub fn db_text(db: f32) -> String {
if db <= -143.9 { "-inf".into() } else { format!("{db:.1}") }
}