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:
parent
755f02fc85
commit
f41c1310fc
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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", ¶ms).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, ¶ms) {
|
||||
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, ¶ms)
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 ¶ms {
|
||||
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()
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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==");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
match self.engine.execute(id, ¶ms) {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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}")}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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(¶ms, &freqs, 48_000.0)
|
||||
} else {
|
||||
soundcraft_dsp::eq1_response(¶ms, &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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}") }
|
||||
}
|
||||
Loading…
Reference in New Issue