Files
soundcraft/crates/engine/src/lib.rs
T
2026-10-24 01:01:49 -04:00

554 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The SoundCraft command engine.
//!
//! Every user-visible action is a registered command ([`cmd::CommandSpec`]): menus, shortcuts, the
//! CLI, the JSON control channel and MCP all dispatch the same commands by id with JSON params.
//! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod autosave;
pub mod catalog;
pub mod catalog_more;
pub mod clip_group_file;
pub mod cmd;
pub mod demo;
pub mod edit;
pub mod file_access;
pub mod inspect;
pub mod io;
pub mod score;
use serde_json::{Value, json};
use soundcraft_model::{Clip, Session, TrackId, ZoomState};
use soundcraft_time::Samples;
use std::cell::Cell;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use soundcraft_model as model;
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
/// plugin that has been used can crash when its module exit only runs from an exit handler.
pub fn shutdown_plugin_hosts() {
soundcraft_vst3_host::shutdown();
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("`{0}` is not available: {1}")]
Disabled(String, String),
#[error("bad parameters for `{0}`: {1}")]
BadParams(String, String),
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Io(String),
#[error("unsupported: {0}")]
Unsupported(String),
#[error("internal error in `{0}`: {1}")]
Internal(String, String),
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// Requests for the transport/audio engine, drained by the host app each frame.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum TransportRequest {
/// Stop the old session's transport without applying stop-time edits to the new document.
ResetSession,
Play,
Stop,
TogglePlay,
Record,
Locate(Samples),
PlaySelection,
Pause,
HalfSpeed,
Scrub(Samples),
AllNotesOff,
}
/// Snapshot of the transport reported back by the host (playback position etc.).
#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize)]
pub struct TransportStatus {
pub playing: bool,
pub recording: bool,
pub position: Samples,
}
/// Clipboard contents: per-track clip copies relative to the copied range start.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Clipboard {
pub length: Samples,
pub tracks: Vec<Vec<Clip>>,
pub automation: Vec<Vec<soundcraft_model::AutomationLane>>,
/// Copy Special › Clip Gain: per track, (offset from the copied range start, dB) breakpoints.
pub clip_gain: Vec<Vec<(Samples, f32)>>,
/// Copy Special › Clip Effects: clip-effect settings (`param` → value) of the first copied clip.
pub clip_effects: Vec<(String, f64)>,
/// Markers inside the copied range (starts relative to it), for Paste Special › Merge Markers.
pub markers: Vec<soundcraft_model::MemoryLocation>,
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
pub grid_offset: Samples,
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
pub automation_only: bool,
}
impl Clipboard {
/// An empty clipboard for material copied from `range`.
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
}
}
#[derive(Debug, Clone)]
struct HistoryEntry {
label: String,
doc: Arc<Session>,
}
/// Engine state: the document, history, clipboard and transport plumbing.
pub struct Engine {
doc: Arc<Session>,
undo: Vec<HistoryEntry>,
redo: Vec<HistoryEntry>,
pub history_limit: usize,
pub clipboard: Clipboard,
pub transport_requests: Vec<TransportRequest>,
pub transport: TransportStatus,
/// Command journal (id, params) for macro recording / agents.
pub journal: Vec<(String, Value)>,
/// Path of the session file, if saved.
pub path: Option<String>,
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
pub revision: u64,
dirty: bool,
/// The document as last opened or saved, for `is_dirty`.
saved: Arc<Session>,
/// `is_dirty` as of a revision.
dirty_at: Cell<(u64, bool)>,
/// Gesture key for undo coalescing (see `execute_merged`).
merge_key: Option<String>,
/// The key every merged gesture takes while held (see `hold_merge`).
merge_hold: Option<String>,
/// Messages for the UI status line / agents.
pub messages: Vec<String>,
/// The edit selection (range, tracks) before the last change, for Restore Last Selection.
/// Kept out of the document so selecting never marks the session modified.
pub last_selection: Option<(soundcraft_time::Range, Vec<TrackId>)>,
}
impl Default for Engine {
fn default() -> Self {
Engine::new(Session::default())
}
}
impl Engine {
pub fn new(session: Session) -> Self {
install_panic_hook();
let doc = Arc::new(session);
Engine {
saved: Arc::clone(&doc),
doc,
undo: Vec::new(),
redo: Vec::new(),
history_limit: 200,
clipboard: Clipboard::default(),
transport_requests: Vec::new(),
transport: TransportStatus::default(),
journal: Vec::new(),
path: None,
revision: 1,
dirty: false,
dirty_at: Cell::new((0, false)),
merge_key: None,
merge_hold: None,
messages: Vec::new(),
last_selection: None,
}
}
pub fn session(&self) -> &Session {
&self.doc
}
pub fn session_arc(&self) -> Arc<Session> {
Arc::clone(&self.doc)
}
/// Mutable access (copy-on-write). Callers outside commands should prefer `execute`.
pub fn session_mut(&mut self) -> &mut Session {
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
Arc::make_mut(&mut self.doc)
}
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
pub fn zoom_mut(&mut self) -> &mut ZoomState {
self.dirty = true;
&mut Arc::make_mut(&mut self.doc).edit.zoom
}
/// Replace the whole document (open / new); clears history.
pub fn replace_session(&mut self, s: Session) {
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
self.transport_requests.clear();
self.transport_requests.push(TransportRequest::ResetSession);
self.doc = Arc::new(s);
self.saved = Arc::clone(&self.doc);
self.undo.clear();
self.redo.clear();
self.last_selection = None;
self.revision = self.revision.wrapping_add(1);
self.dirty = false;
}
/// Whether the document changed since it was opened or saved. Selecting, locating and zooming
/// don't count, and undoing back to the saved state makes it clean again.
pub fn is_dirty(&self) -> bool {
if !self.dirty {
return false;
}
let (rev, dirty) = self.dirty_at.get();
if rev == self.revision {
return dirty;
}
let dirty = !same_document(&self.saved, &self.doc);
self.dirty_at.set((self.revision, dirty));
dirty
}
pub fn mark_clean(&mut self) {
self.dirty = false;
self.saved = Arc::clone(&self.doc);
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo_label(&self) -> Option<&str> {
self.undo.last().map(|h| h.label.as_str())
}
pub fn redo_label(&self) -> Option<&str> {
self.redo.last().map(|h| h.label.as_str())
}
pub fn undo_history(&self) -> Vec<String> {
self.undo.iter().map(|h| h.label.clone()).collect()
}
pub fn undo(&mut self) -> bool {
let Some(h) = self.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.redo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
true
}
pub fn redo(&mut self) -> bool {
let Some(h) = self.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.undo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
true
}
/// Undo and Redo never change the Edit mode or its settings (Shuffle Lock above all).
fn keep_edit_mode(&mut self, from: &Session) {
use edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID};
let flags = [SNAP_TO_GRID, SHUFFLE_LOCK, RELATIVE_GRID];
let ed = &self.doc.edit;
if ed.edit_mode != from.edit.edit_mode || flags.iter().any(|f| ed.flag(f) != from.edit.flag(f)) {
let ed = &mut Arc::make_mut(&mut self.doc).edit;
ed.edit_mode = from.edit.edit_mode;
for f in flags {
ed.set_flag(f, from.edit.flag(f));
}
}
}
/// Record an undo point manually (for interactive gestures committed by the UI).
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
if Arc::ptr_eq(&before, &self.doc) {
return;
}
self.undo.push(HistoryEntry { label: label.to_string(), doc: before });
self.redo.clear();
if self.undo.len() > self.history_limit {
let excess = self.undo.len() - self.history_limit;
self.undo.drain(..excess);
}
}
/// Whether command `id` must wait for the take being recorded: nothing may replace the
/// session or quit under it.
pub fn blocked_while_recording(&self, id: &str) -> bool {
self.transport.recording && matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
}
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
/// document is restored.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if self.blocked_while_recording(id) {
return Err(EngineError::Disabled(id.to_string(), "stop recording first".into()));
}
if let Err(reason) = (spec.enabled)(self) {
// A call that names its targets (tracks, clips, a range) does not need a selection;
// the command itself validates them.
let names_targets = ["track", "tracks", "clip", "clips", "sources", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
if !names_targets {
return Err(EngineError::Disabled(id.to_string(), reason));
}
}
if spec.view {
return self.execute_view(spec, params);
}
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
let side_effects = (self.clipboard.clone(), self.messages.clone(), self.transport_requests.clone(), self.last_selection.clone());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match result {
Ok(r) => r,
Err(p) => {
self.doc = before;
self.revision = rev.wrapping_add(1);
self.dirty = dirty;
(self.clipboard, self.messages, self.transport_requests, self.last_selection) = side_effects;
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
}
};
if !Arc::ptr_eq(&before, &self.doc) {
// Only a change to the document ends a gesture, so Stop doesn't split the pass it ends.
self.merge_key = None;
}
match result {
Ok(v) => {
cmd::remember_selection(self, &before);
if spec.undoable && !Arc::ptr_eq(&before, &self.doc) {
self.push_undo(spec.label, before);
}
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
Ok(v)
}
Err(e) => {
// Commands must not leave half-applied edits behind.
if !Arc::ptr_eq(&before, &self.doc) {
self.doc = before;
self.revision = self.revision.wrapping_add(1);
self.dirty = dirty;
}
Err(e)
}
}
}
fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result<Value> {
let zoom = self.doc.edit.zoom;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)))
.unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p))));
if result.is_err() && self.doc.edit.zoom != zoom {
Arc::make_mut(&mut self.doc).edit.zoom = zoom;
}
result
}
/// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls
/// with the same `key` collapse into a single undo step.
pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result<Value> {
let key = self.merge_hold.clone().unwrap_or_else(|| key.to_string());
let continuing = self.merge_key.as_ref() == Some(&key);
let before_len = self.undo.len();
// The step pushed for a continuing tick is dropped again below, so it must not trim the
// oldest entry first when the history is full.
let limit = self.history_limit;
if continuing && before_len > 0 {
self.history_limit = limit.max(before_len.saturating_add(1));
}
let r = self.execute(id, params);
self.history_limit = limit;
let r = r?;
if continuing && self.undo.len() == before_len + 1 && before_len > 0 {
// Drop the step just pushed; the earlier one still holds the pre-gesture document.
self.undo.pop();
}
self.merge_key = Some(key);
Ok(r)
}
/// While `key` is held, every `execute_merged` gesture takes it instead of its own, so they
/// all join one undo step (an automation pass and whatever is dragged during it).
pub fn hold_merge(&mut self, key: Option<&str>) {
if self.merge_hold.as_deref() != key {
self.merge_hold = key.map(str::to_string);
}
}
/// The gesture `execute_merged` is continuing, if any.
pub fn merge_key(&self) -> Option<&str> {
self.merge_key.as_deref()
}
/// End a gesture started with `execute_merged`.
pub fn end_merge(&mut self) {
self.merge_key = None;
}
/// Convenience: execute with `{}` params.
pub fn run(&mut self, id: &str) -> Result<Value> {
self.execute(id, &json!({}))
}
pub fn message(&mut self, m: impl Into<String>) {
let m = m.into();
log::info!("{m}");
self.messages.push(m);
if self.messages.len() > 100 {
self.messages.remove(0);
}
}
/// Tracks targeted by an edit: the selected tracks, or all tracks with playlists if none.
pub fn target_tracks(&self) -> Vec<TrackId> {
let s = self.session();
if s.edit.selected_tracks.is_empty() {
Vec::new()
} else {
s.tracks.iter().filter(|t| s.edit.selected_tracks.contains(&t.id)).map(|t| t.id).collect()
}
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
log::error!("panic: {info}\n{}", std::backtrace::Backtrace::force_capture());
prev(info);
}));
});
}
fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
#[cfg(test)]
mod merge_tests {
use super::*;
#[test]
fn merged_gesture_is_one_undo_step() {
let mut e = Engine::default();
let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
for db in [-1.0, -2.0, -3.0, -4.0] {
e.execute_merged("mix.volume", &json!({"track": t.0, "db": db}), "fader").unwrap();
}
e.end_merge();
assert_eq!(e.undo_history().len(), 1);
e.undo();
assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0);
}
#[test]
fn a_held_merge_joins_other_gestures_into_its_step() {
let mut e = Engine::default();
let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.hold_merge(Some("pass"));
for db in [-1.0, -2.0, -3.0] {
e.execute_merged("mix.volume", &json!({"track": t.0, "db": db}), "fader").unwrap();
e.execute_merged("mix.pan", &json!({"track": t.0, "pan": db / 4.0}), "pan").unwrap();
}
assert_eq!(e.undo_history().len(), 1);
e.hold_merge(None);
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -6.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 2);
}
#[test]
fn only_a_change_to_the_document_ends_a_gesture() {
let mut e = Engine::default();
let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -1.0}), "fader").unwrap();
e.execute("transport.stop", &json!({})).unwrap();
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -2.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 1);
e.execute("mix.mute", &json!({"track": t.0})).unwrap();
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -3.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 3);
}
}
#[cfg(test)]
mod dirty_tests {
use super::*;
#[test]
fn selecting_locating_and_zooming_leave_the_session_clean() {
let mut e = crate::demo::demo_engine();
for (id, p) in [
("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})),
("edit.select_none", json!({})),
("transport.locate", json!({"at": 96_000})),
("view.zoom_in", json!({})),
("view.scroll", json!({"to": 48_000})),
] {
e.execute(id, &p).unwrap();
assert!(!e.is_dirty(), "{id}");
}
e.execute("mix.mute", &json!({"track": "Kick"})).unwrap();
assert!(e.is_dirty());
e.undo();
assert!(!e.is_dirty());
}
}
#[cfg(test)]
mod panic_tests {
use super::*;
#[test]
fn a_panicking_command_restores_side_effect_state() {
let mut e = Engine::default();
// Non-default values, so clearing them instead of restoring them would fail.
e.clipboard.length = 42;
e.messages.push("prior".into());
e.transport_requests.push(TransportRequest::Stop);
e.last_selection = Some((soundcraft_time::Range::new(20, 30), Vec::new()));
let (clipboard, messages, requests, selection) =
(e.clipboard.clone(), e.messages.clone(), e.transport_requests.clone(), e.last_selection.clone());
let r = e.execute("test.panic_probe", &json!({"panic": true}));
assert!(matches!(r, Err(EngineError::Internal(..))));
assert_eq!(e.clipboard, clipboard);
assert_eq!(e.messages, messages);
assert_eq!(e.transport_requests, requests);
assert_eq!(e.last_selection, selection);
}
}