diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 5840b6d..bb7628a 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -188,7 +188,7 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode { } } if let Some(path) = arg_value(args, "--save") { - match e.execute("session.save", &json!({"path": path})) { + match e.execute("session.save", &json!({"path": path, "replace": true})) { Ok(v) => outln!("{v}"), Err(err) => return fail(err), } diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 8004acf..61f20bd 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -49,6 +49,13 @@ impl eframe::App for App { if self.0.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + soundcraft_ui_egui::fit_window_to_screen(ctx); + let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false))); + self.0.ui.window_maximized = maximized; + if let Some(r) = inner.filter(|_| !maximized) { + self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into(); + } + self.0.ui.ui_scale = self.0.extra.ui_scale; // With the system menu bar there is no egui bar showing the window and dirty state, so the // title carries them; elsewhere the egui menu bar already does. let title = if self.0.native_menu_bar { @@ -157,6 +164,9 @@ fn services() -> Services { } d.save_file().map(|p| p.to_string_lossy().into_owned()) })), + pick_folder: Some(Box::new(|start: &str| { + rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned()) + })), } } @@ -213,10 +223,12 @@ fn main() -> eframe::Result { } let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; + let prefs = load_prefs().unwrap_or_default(); let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("SoundCraft") - .with_inner_size([1600.0, 1000.0]) + .with_inner_size(sane_window_size(prefs.window_size)) + .with_maximized(prefs.window_maximized) .with_min_inner_size([960.0, 600.0]) .with_drag_and_drop(true) .with_app_id("ai.storyteller.soundcraft"), @@ -236,9 +248,8 @@ fn main() -> eframe::Result { let mut app = SoundApp::new(engine, player, services()); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); - if let Some(ui) = load_prefs() { - app.ui = ui; - } + app.extra.ui_scale = prefs.ui_scale; + app.ui = prefs; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); @@ -267,3 +278,8 @@ fn main() -> eframe::Result { }), ) } + +/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny). +fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] { + if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] } +} diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 782cb9a..87241c6 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -8,6 +8,7 @@ //! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`. //! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in //! encoder), with optional TPDF dither. +//! * [`wav_float_header`] heads a float WAV written while it grows (recording). //! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -289,3 +290,13 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u pub fn wav_channel_mask(bytes: &[u8]) -> Option { wav::read_channel_mask(bytes) } + +/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its +/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it +/// switches to RF64 above 4 GiB. +pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result> { + if channels == 0 || sample_rate == 0 { + return Err(AudioError::Encode("a WAV needs channels and a sample rate".into())); + } + wav::header(channels, sample_rate, (32, true), None, None, frames, false) +} diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index cf14fb7..df56010 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -276,60 +276,8 @@ pub(crate) fn encode( BitDepth::Float32 => (32, true), }; let bytes = usize::from(bits / 8); - let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?; - let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?; - let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; - let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels)); - let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM }; - - let mut fmt = Vec::with_capacity(40); - fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes()); - fmt.extend_from_slice(&(channels as u16).to_le_bytes()); - fmt.extend_from_slice(&buf.sample_rate.to_le_bytes()); - fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes()); - fmt.extend_from_slice(&block_align.to_le_bytes()); - fmt.extend_from_slice(&bits.to_le_bytes()); - if extensible { - fmt.extend_from_slice(&22u16.to_le_bytes()); - fmt.extend_from_slice(&bits.to_le_bytes()); - fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes()); - fmt.extend_from_slice(&fmt_tag.to_le_bytes()); - fmt.extend_from_slice(&GUID_TAIL); - } - - let mut head = Vec::with_capacity(1024); - let rf64_body_len = 28usize; - // Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place). - let mut meta = Vec::new(); - if let Some(bwf) = bwf { - push_chunk(&mut meta, b"bext", &bext_chunk(bwf)); - } - push_chunk(&mut meta, b"fmt ", &fmt); - if is_float { - push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes()); - } - let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1); - let rf64 = force_rf64 || u32::try_from(total).is_err(); - - if rf64 { - head.extend_from_slice(b"RF64"); - head.extend_from_slice(&u32::MAX.to_le_bytes()); - head.extend_from_slice(b"WAVE"); - let mut ds64 = Vec::with_capacity(rf64_body_len); - ds64.extend_from_slice(&(total as u64).to_le_bytes()); - ds64.extend_from_slice(&(data_len as u64).to_le_bytes()); - ds64.extend_from_slice(&(frames as u64).to_le_bytes()); - ds64.extend_from_slice(&0u32.to_le_bytes()); - push_chunk(&mut head, b"ds64", &ds64); - } else { - head.extend_from_slice(b"RIFF"); - head.extend_from_slice(&(total as u32).to_le_bytes()); - head.extend_from_slice(b"WAVE"); - push_chunk(&mut head, b"JUNK", &[0u8; 28]); - } - head.extend_from_slice(&meta); - head.extend_from_slice(b"data"); - head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes()); + let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?; + let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; let mut out = Vec::new(); out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1)) @@ -352,3 +300,70 @@ pub(crate) fn encode( } Ok(out) } + +/// Everything before the sample data of a WAV holding `frames` frames. +pub(crate) fn header( + channels: usize, + sample_rate: u32, + (bits, is_float): (u16, bool), + bwf: Option<&BwfInfo>, + mask: Option, + frames: u64, + force_rf64: bool, +) -> Result> { + let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?; + let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?; + let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; + let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels)); + let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM }; + + let mut fmt = Vec::with_capacity(40); + fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes()); + fmt.extend_from_slice(&(channels as u16).to_le_bytes()); + fmt.extend_from_slice(&sample_rate.to_le_bytes()); + fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes()); + fmt.extend_from_slice(&block_align.to_le_bytes()); + fmt.extend_from_slice(&bits.to_le_bytes()); + if extensible { + fmt.extend_from_slice(&22u16.to_le_bytes()); + fmt.extend_from_slice(&bits.to_le_bytes()); + fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes()); + fmt.extend_from_slice(&fmt_tag.to_le_bytes()); + fmt.extend_from_slice(&GUID_TAIL); + } + + let mut head = Vec::with_capacity(1024); + let rf64_body_len = 28usize; + // Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place). + let mut meta = Vec::new(); + if let Some(bwf) = bwf { + push_chunk(&mut meta, b"bext", &bext_chunk(bwf)); + } + push_chunk(&mut meta, b"fmt ", &fmt); + if is_float { + push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes()); + } + let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1); + let rf64 = force_rf64 || u32::try_from(total).is_err(); + + if rf64 { + head.extend_from_slice(b"RF64"); + head.extend_from_slice(&u32::MAX.to_le_bytes()); + head.extend_from_slice(b"WAVE"); + let mut ds64 = Vec::with_capacity(rf64_body_len); + ds64.extend_from_slice(&total.to_le_bytes()); + ds64.extend_from_slice(&data_len.to_le_bytes()); + ds64.extend_from_slice(&frames.to_le_bytes()); + ds64.extend_from_slice(&0u32.to_le_bytes()); + push_chunk(&mut head, b"ds64", &ds64); + } else { + head.extend_from_slice(b"RIFF"); + head.extend_from_slice(&(total as u32).to_le_bytes()); + head.extend_from_slice(b"WAVE"); + push_chunk(&mut head, b"JUNK", &[0u8; 28]); + } + head.extend_from_slice(&meta); + head.extend_from_slice(b"data"); + head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes()); + Ok(head) +} diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/roundtrip.rs index b2291e1..a53c272 100644 --- a/crates/audio-io/tests/roundtrip.rs +++ b/crates/audio-io/tests/roundtrip.rs @@ -1,5 +1,5 @@ use soundcraft_audio_io::{ - AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, + AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header, }; fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer { @@ -268,3 +268,19 @@ fn multichannel_wav_speaker_masks() { let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap(); assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None); } + +#[test] +fn float_wav_header_is_rewritten_in_place_as_the_file_grows() { + let take = sine(48_000, 2, 1_000); + let mut file = wav_float_header(2, 48_000, 0).unwrap(); + let len = file.len(); + file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes())); + assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000); + file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap()); + assert_eq!(decode(&file, None).unwrap().1.frames(), 600); + file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap()); + assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap()); + let big = wav_float_header(2, 48_000, 600_000_000).unwrap(); + assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len)); + assert!(wav_float_header(0, 48_000, 0).is_err()); +} diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 19fe91a..cacd9a0 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -54,7 +54,12 @@ pub fn tool_definitions() -> Vec { &[], ), 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( + "save_session", + "Save the session (path required the first time; replace: true to save over another existing file).", + json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}), + &[], + ), tool( "import_audio", "Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.", diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 0c566d7..520e537 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -185,10 +185,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result { let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed"); // Start from the unwarped original if this clip was warped before. let key = |k: &str| format!("elastic.{}.{k}", id.0); - let orig = match (s.edit.values.get(&key("src")), s.edit.values.get(&key("off")), s.edit.values.get(&key("len"))) { - (Some(src), Some(off), Some(len)) => Some((soundcraft_model::SourceId(*src as u64), *off as i64, *len as i64)), - _ => None, - }; + let orig = warp_original(s, *id); let mut base = c.clone(); if let Some((src, off, len)) = orig { base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off }; @@ -229,12 +226,12 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result { let mut n = 0; for id in &ids { let key = |k: &str| format!("elastic.{}.{k}", id.0); - let orig = (s.edit.values.get(&key("src")).copied(), s.edit.values.get(&key("off")).copied(), s.edit.values.get(&key("len")).copied()); + let orig = warp_original(s, *id); if let Some(c) = s.find_clip_mut(*id) { c.stretch = 1.0; - if let (Some(src), Some(off), Some(len)) = orig { - c.content = soundcraft_model::ClipContent::Audio { source: soundcraft_model::SourceId(src as u64), offset: off as i64 }; - c.length = (len as i64).max(1); + if let Some((src, off, len)) = orig { + c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off }; + c.length = len.max(1); c.fade_out.len = c.fade_out.len.min(c.length); n += 1; } @@ -246,6 +243,14 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result { Ok(json!({"restored": n})) } +/// The audio a warped clip was rendered from, while that file is still in the session. +fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> { + let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied(); + let src = soundcraft_model::SourceId(v("src")? as u64); + s.source(src)?; + Some((src, v("off")? as i64, v("len")? as i64)) +} + fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result { let ids = clip_ids_param(e, p); let s = e.session_mut(); @@ -373,6 +378,20 @@ mod tests { assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0); } + #[test] + fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() { + let mut e = crate::demo::demo_engine(); + let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap(); + e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap(); + let clip = e.session().track(pad).unwrap().clips()[0].clone(); + e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap(); + e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap(); + let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap(); + assert_eq!(r["restored"], 0); + let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap(); + assert!(e.session().source(src).is_some()); + } + #[test] fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/clip_list.rs b/crates/engine/src/cmd/clip_list.rs new file mode 100644 index 0000000..a852988 --- /dev/null +++ b/crates/engine/src/cmd/clip_list.rs @@ -0,0 +1,160 @@ +//! Clip List commands. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::SourceId; +use std::collections::BTreeSet; + +const CLEAR: &str = "clip.clear"; + +pub fn specs() -> Vec { + vec![cmd!( + "clip.clear", + "Clear Clips", + [], + Some("Cmd+Shift+B"), + "{clips?: [id], sources?: [id], with_clips?: bool} — removes clips and audio files from the session (files stay on disk); default: the Clip List selection. A file still used on the timeline needs `with_clips: true`, which removes those clips too", + has_list_selection, + clear + )] +} + +fn has_list_selection(e: &Engine) -> std::result::Result<(), String> { + let ed = &e.session().edit; + if ed.selected_clips.is_empty() && ed.selected_sources.is_empty() { Err("select clips or files in the Clip List".into()) } else { Ok(()) } +} + +fn ids<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result> { + p.get(key).map(|v| T::deserialize(v).map_err(|_| bad(CLEAR, format!("`{key}` must be an array of ids")))).transpose() +} + +fn clear(e: &mut Engine, p: &Value) -> Result { + let s = e.session(); + let (clips, sources): (BTreeSet, BTreeSet) = match (ids(p, "clips")?, ids(p, "sources")?) { + (None, None) => (s.edit.selected_clips.iter().copied().collect(), s.edit.selected_sources.iter().copied().collect()), + (c, f) => (c.unwrap_or_default(), f.unwrap_or_default()), + }; + let known: BTreeSet = s.all_clips().map(|c| c.id).collect(); + if let Some(c) = clips.iter().find(|c| !known.contains(c)) { + return Err(bad(CLEAR, format!("no clip with id {c}"))); + } + let files = sources.iter().map(|f| s.source(*f).ok_or_else(|| bad(CLEAR, format!("no audio file with id {f}")))).collect::>>()?; + if clips.is_empty() && files.is_empty() { + return Err(bad(CLEAR, "nothing to clear: pass `clips` or `sources`, or select them in the Clip List")); + } + if !bool_or(p, "with_clips", false) + && let Some((f, n)) = files.iter().map(|f| (f, s.clips_using(f.id).filter(|c| !clips.contains(&c.id)).count())).find(|(_, n)| *n > 0) + { + let plural = if n == 1 { "" } else { "s" }; + return Err(bad(CLEAR, format!("`{}` is used by {n} clip{plural} on the timeline; pass `with_clips: true` to remove them too", f.name))); + } + let gone: BTreeSet = clips.iter().copied().chain(sources.iter().flat_map(|f| s.clips_using(*f).map(|c| c.id))).collect(); + let s = e.session_mut(); + for pl in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()) { + pl.clips.retain(|c| !gone.contains(&c.id)); + } + s.sources.retain(|f| !sources.contains(&f.id)); + for f in &sources { + s.pool.remove(*f); + } + s.edit.selected_clips.retain(|c| !gone.contains(c)); + s.edit.selected_sources.retain(|f| !sources.contains(f)); + Ok(json!({"clips": gone.len(), "sources": sources.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::{AudioBuffer, FileFormat}; + + fn kick(e: &Engine) -> (SourceId, Vec) { + let s = e.session(); + let f = s.sources.iter().find(|x| x.name == "Kick").map(|x| x.id).unwrap(); + (f, s.clips_using(f).map(|c| c.id).collect()) + } + + #[test] + fn clearing_a_clip_takes_it_off_the_timeline_and_keeps_its_file() { + let mut e = crate::demo::demo_engine(); + let (f, clips) = kick(&e); + let r = e.execute("clip.clear", &json!({"clips": [clips[0]]})).unwrap(); + assert_eq!(r, json!({"clips": 1, "sources": 0})); + assert!(e.session().find_clip(clips[0]).is_none()); + assert!(e.session().source(f).is_some()); + assert!(e.undo()); + assert!(e.session().find_clip(clips[0]).is_some()); + } + + #[test] + fn a_file_in_use_stays_unless_its_clips_go_too() { + let mut e = crate::demo::demo_engine(); + e.execute("track.playlist_duplicate", &json!({"track": "Kick"})).unwrap(); + let (f, clips) = kick(&e); + assert_eq!(clips.len(), 6); + let err = e.execute("clip.clear", &json!({"sources": [f]})).unwrap_err(); + assert!(err.to_string().contains("used by 6 clips"), "{err}"); + let r = e.execute("clip.clear", &json!({"sources": [f], "with_clips": true})).unwrap(); + assert_eq!(r, json!({"clips": 6, "sources": 1})); + let s = e.session(); + assert!(s.source(f).is_none() && !s.pool.contains(f)); + assert_eq!(s.clips_using(f).count(), 0); + assert_eq!(e.undo_label(), Some("Clear Clips")); + assert!(e.undo()); + assert!(e.session().pool.contains(f)); + assert_eq!(e.session().clips_using(f).count(), 6); + } + + #[test] + fn listing_a_files_clips_too_is_enough() { + let mut e = crate::demo::demo_engine(); + let (f, clips) = kick(&e); + let r = e.execute("clip.clear", &json!({"clips": clips, "sources": [f]})).unwrap(); + assert_eq!(r, json!({"clips": 3, "sources": 1})); + } + + #[test] + fn the_clip_list_selection_is_the_default_target() { + let mut e = crate::demo::demo_engine(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4_800]] }; + let f = crate::io::add_source(e.session_mut(), "Spare", buf, None, FileFormat::Wav); + let (_, clips) = kick(&e); + e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap(); + let r = e.execute("edit.select", &json!({"sources": [f, f, 999_999]})).unwrap(); + assert_eq!(r["sources"], json!([f])); + assert_eq!(r["clips"], json!([])); + let r = e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap(); + assert_eq!(r["sources"], json!([])); + e.execute("clip.clear", &json!({})).unwrap(); + assert!(e.session().find_clip(clips[0]).is_none()); + assert!(e.session().edit.selected_clips.is_empty()); + e.execute("edit.select", &json!({"sources": [f]})).unwrap(); + e.execute("clip.clear", &json!({})).unwrap(); + assert!(e.session().source(f).is_none()); + assert!(e.session().edit.selected_sources.is_empty()); + } + + #[test] + fn bad_targets_are_errors() { + let mut e = crate::demo::demo_engine(); + for p in [ + json!({}), + json!({"clips": []}), + json!({"clips": [999_999]}), + json!({"sources": [999_999]}), + json!({"sources": "Kick"}), + json!({"clips": [-1]}), + ] { + assert!(e.execute("clip.clear", &p).is_err(), "{p}"); + } + } + + #[test] + fn deselect_all_clears_the_clip_list_selection() { + let mut e = crate::demo::demo_engine(); + let (f, _) = kick(&e); + e.execute("edit.select", &json!({"sources": [f]})).unwrap(); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(e.session().edit.selected_sources.is_empty()); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0000852..776fe02 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -49,11 +49,12 @@ pub fn specs() -> Vec { repeat_to_fill ), cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all), - cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select), + cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select), cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| { let s = e.session_mut(); s.edit.selected_tracks.clear(); s.edit.selected_clips.clear(); + s.edit.selected_sources.clear(); s.edit.selection.end = s.edit.selection.start; Ok(json!({})) }), @@ -351,6 +352,9 @@ fn select(e: &mut Engine, p: &Value) -> Result { let en = position_param(e, "edit.select", p, "end")?; let clips = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); + let sources = + p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::>()); + let list_changes = clips.is_some() || sources.is_some() || st.is_some(); let exact = bool_or(p, "exact", false); let s = e.session(); let mut ed = s.edit.clone(); @@ -391,13 +395,17 @@ fn select(e: &mut Engine, p: &Value) -> Result { ed.selected_tracks = trs; } ed.selected_clips = c; - } else if st.is_some() { + } else if list_changes { ed.selected_clips.clear(); } + if list_changes { + let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect(); + ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect(); + } if ed.link_timeline_edit { ed.timeline_selection = ed.selection; } - let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips}); + let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources}); if ed != s.edit { e.session_mut().edit = ed; } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 30145e9..805acb0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,30 +15,35 @@ pub fn specs() -> Vec { Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing})) }), cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }), - cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| { + cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?, replace?: false}", always, |e, p| { let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?; + refuse_replacing(e, "session.save", &crate::io::session_file(&path), p)?; let written = crate::io::save_session(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), - cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| { + cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path, replace?: false}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string(); + refuse_replacing(e, "session.save_as", &crate::io::session_file(&path), p)?; let written = crate::io::save_session(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), - cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| { + cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path, replace?: false}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string(); + refuse_replacing(e, "session.save_copy", &crate::io::session_file(&path), p)?; let written = crate::io::save_session_copy(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), - cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| { + cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path, replace?: false}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string(); + let file = crate::io::session_file(&path); + refuse_replacing(e, "session.save_template", &file, p)?; let mut s = e.session().clone(); for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } } s.sources.clear(); s.videos.clear(); let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; - Ok(json!({"path": path})) + crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?; + Ok(json!({"path": file})) }), cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?; @@ -108,14 +113,18 @@ pub fn specs() -> Vec { } Ok(json!({"text": text})) }), - cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| { + cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| { let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string(); let ids = clip_ids_param(e, p); + if ids.is_empty() { + return Err(bad("file.export_clips", "no clips selected")); + } let n = crate::io::export_clips(e, &ids, &dir)?; - Ok(json!({"written": n})) + Ok(json!({"written": n, "dir": dir})) }), - cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| { + cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?, replace?: false}", has_selection, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string(); + refuse_replacing(e, "file.export_selected_tracks_as_session", &crate::io::session_file(&path), p)?; let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?; let mut s = e.session().clone(); s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master); @@ -179,6 +188,14 @@ pub fn specs() -> Vec { ] } +/// Writing a session over an existing file needs `replace: true` (see `io::replaces`). +fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> { + if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) { + return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display()))); + } + Ok(()) +} + fn new_session(e: &mut Engine, p: &Value) -> Result { let name = str_param(p, "name").unwrap_or("Untitled").to_string(); let sr = @@ -438,6 +455,7 @@ mod tests { fn session_open_reports_missing_media() { let mut e = crate::demo::demo_engine(); let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); let path = dir.join("Gone.scraft").to_string_lossy().into_owned(); e.execute("session.save_as", &json!({"path": path})).unwrap(); let mut ok = Engine::default(); @@ -486,6 +504,20 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn exports_the_selected_clips_and_refuses_an_empty_selection() { + let mut e = crate::demo::demo_engine(); + let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id())); + let p = json!({"dir": dir.to_string_lossy()}); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(e.execute("file.export_clips", &p).is_err()); + e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap(); + let r = e.execute("file.export_clips", &p).unwrap(); + let files = std::fs::read_dir(&dir).map_or(0, Iterator::count); + let _ = std::fs::remove_dir_all(&dir); + assert!(files >= 1 && r["written"] == files, "{r}, {files} files"); + } + fn midi_engine() -> Engine { use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; use soundcraft_model::{ChannelFormat, Clip, TrackKind}; diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 3a01d3d..d77e12b 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { insert_params ), cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move), - cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send), + cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}", has_selection, send), cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| { let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?; if let Some(tr) = e.session_mut().track_mut(t) @@ -390,15 +390,17 @@ fn send(e: &mut Engine, p: &Value) -> Result { let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string(); let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?; let explicit = slot_of(p, "slot", SEND_SLOTS); - let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0); - let pre = bool_or(p, "pre_fader", false); let s = e.session_mut(); let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo); let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?; let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?; - let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid)); - sl.level_db = level; - sl.pre_fader = pre; + // Re-targeting an existing send keeps its level, pan, mute and pre/post setting. + let mut sl = match tr.mixer.sends.get(slot).cloned().flatten() { + Some(old) => SendSlot { target: soundcraft_model::Route::Bus(bid), ..old }, + None => SendSlot::new(soundcraft_model::Route::Bus(bid)), + }; + sl.level_db = f32_or(p, "level_db", sl.level_db).clamp(-144.0, 12.0); + sl.pre_fader = bool_or(p, "pre_fader", sl.pre_fader); if let Some(x) = tr.mixer.sends.get_mut(slot) { *x = Some(sl); } @@ -791,4 +793,17 @@ mod tests { assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB"); } } + + #[test] + fn retargeting_a_send_keeps_its_settings() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap(); + e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap(); + e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap(); + e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap(); + let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap(); + let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id; + assert_eq!(sl.target, soundcraft_model::Route::Bus(delay)); + assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true)); + } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3a69106..729dae8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -10,6 +10,7 @@ mod audio_midi; mod audiosuite; mod clap; mod clip; +mod clip_list; mod clip_more; mod edit; mod edit_more; @@ -116,6 +117,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(view_more::specs()); v.extend(track_more::specs()); v.extend(clip_more::specs()); + v.extend(clip_list::specs()); v.extend(event_more::specs()); v.extend(setup_more::specs()); v.extend(clap::specs()); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 18f1de6..a13e1f8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -520,7 +520,7 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result { } /// Write a copy of the session (and any audio it needs) without touching the open document — -/// used for autosave / crash recovery. Audio files in the target folder are overwritten in place. +/// used for autosave / crash recovery. Audio already written for a source is reused. pub fn save_copy(e: &Engine, path: &str) -> Result { write_session(e.session(), path, true).map(|(_, n, _)| n) } @@ -534,61 +534,172 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result { /// Write `session` to `path`; returns the session with updated media paths, the number of audio /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { - let p = PathBuf::from(path); - let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }; + let p = session_file(path); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); + let from = s.folder.clone(); + let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir)); let mut written = 0; let shared_paths: Vec = s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect(); for (i, src) in s.sources.iter_mut().enumerate() { - let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); - let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); + if !same_dir + && s.pool.get(src.id).is_none() + && Path::new(&src.path).is_relative() + && let Some(f) = &from + { + src.path = f.join(&src.path).to_string_lossy().into_owned(); + } + let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); let shared = shared_paths.get(i).copied().unwrap_or(false); if exists_rel && !src.unsaved && !shared { continue; } + let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string); + if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) { + src.path = format!("Audio Files/{f}"); + continue; + } let Some(audio) = s.pool.get(src.id) else { continue }; std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; - let mut target = rel_target.clone(); + let mut stem = sanitize_name(&src.name); if overwrite { // One file per source id so autosaves never collide or pile up. - target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0); - if dir.join(&target).exists() { - src.path = target; + stem = format!("{stem}_{}", src.id.0); + if audio_dir.join(format!("{stem}.wav")).exists() { + src.path = format!("Audio Files/{stem}.wav"); src.unsaved = false; continue; } - } else { - let mut k = 1; - while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) { - target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); - k += 1; - if k > 999 { - break; - } - } } let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?; - src.path = target; + let (name, new) = store_wav(&audio_dir, &stem, &bytes)?; + src.path = format!("Audio Files/{name}"); src.unsaved = false; src.format = FileFormat::Wav; - written += 1; + written += usize::from(new); } + if written > 0 { + sync_dir(&audio_dir); + } + s.folder = Some(dir.clone()); if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; // Atomic-ish write: temp file then rename. let tmp = p.with_extension("scraft.tmp"); - std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?; - std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?; + if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) { + back_up(&p); + } + replace_file(&tmp, &p, text.as_bytes())?; Ok((s, written, p)) } +/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the +/// old or the new contents, never a mix. +pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> { + if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) { + let _ = std::fs::remove_file(tmp); + return Err(EngineError::Io(format!("{}: {err}", file.display()))); + } + sync_dir(file.parent().unwrap_or(Path::new(""))); + Ok(()) +} + +fn sync_dir(dir: &Path) { + let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; + let _ = std::fs::File::open(dir).and_then(|d| d.sync_all()); +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut f = std::fs::File::create(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +/// Copy the session file about to be replaced to `Backups/ backup .`, keeping the +/// newest ten. +fn back_up(file: &Path) { + let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str())) + else { + return; + }; + let backups = dir.join("Backups"); + let prefix = format!("{stem} backup "); + let suffix = format!(".{ext}"); + let numbers: Vec = std::fs::read_dir(&backups) + .into_iter() + .flatten() + .flatten() + .filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok()) + .collect(); + let next = numbers.iter().max().map_or(1, |n| n.saturating_add(1)); + let copied = std::fs::create_dir_all(&backups).and_then(|()| std::fs::copy(file, backups.join(format!("{prefix}{next}{suffix}")))); + if let Err(err) = copied { + log::warn!("no backup of {}: {err}", file.display()); + return; + } + for n in numbers.into_iter().filter(|n| n.saturating_add(10) <= next) { + let _ = std::fs::remove_file(backups.join(format!("{prefix}{n}{suffix}"))); + } +} + +/// The file a session saved to `path` goes to: `path`, with `.scraft` added when it has no +/// extension and isn't an existing file. +pub fn session_file(path: &str) -> PathBuf { + let p = PathBuf::from(path); + if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p } +} + +/// True when `command` would write a session over the existing `file`. Save, Save As and Save +/// Copy In may write the open session's own file; other commands never ask. +pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool { + match command { + "session.save" | "session.save_as" | "session.save_copy" => { + file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file)) + } + "session.save_template" | "file.export_selected_tracks_as_session" => file.exists(), + _ => false, + } +} + +fn same_path(a: &Path, b: &Path) -> bool { + let full = |p: &Path| if p.as_os_str().is_empty() { Path::new(".").canonicalize() } else { p.canonicalize() }; + a == b || matches!((full(a), full(b)), (Ok(x), Ok(y)) if x == y) +} + +/// Store `bytes` as the first of `.wav`, `_01.wav`, … in `dir` that is free or already +/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was +/// written. +fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> { + use std::io::Write; + for k in 0..10_000u32 { + let name = if k == 0 { format!("{stem}.wav") } else { format!("{stem}_{k:02}.wav") }; + let path = dir.join(&name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut f) => { + if let Err(err) = f.write_all(bytes).and_then(|()| f.sync_all()) { + let _ = std::fs::remove_file(&path); + return Err(EngineError::Io(format!("{}: {err}", path.display()))); + } + return Ok((name, true)); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) { + return Ok((name, false)); + } + } + Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))), + } + } + Err(EngineError::Io(format!("{}: no free file name for {stem}", dir.display()))) +} + /// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal). pub fn open_session(e: &mut Engine, path: &str) -> Result> { let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; @@ -612,6 +723,7 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { } } missing.extend(relink_videos(&mut s, &dir)); + s.folder = Some(dir); e.replace_session(s); e.path = Some(path.to_string()); for m in &missing { @@ -620,17 +732,14 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { Ok(missing) } -/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record"). -/// `channels` is the planar input capture at `rate`; each track takes the input channels its -/// input route names ("In 3" → channel 2), defaulting to the first channels. -pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec>, rate: u32) -> Result> { - let frames = channels.first().map_or(0, Vec::len); - if frames == 0 { - return Ok(Vec::new()); - } - let armed: Vec<(TrackId, usize, usize, String)> = e - .session() - .tracks +/// The record-enabled audio tracks, each with the input channels it records (those its input +/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file +/// `_.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved). +pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<(TrackId, Vec, PathBuf)> { + let dir = e.path.as_deref().and_then(|p| Path::new(p).parent()).unwrap_or(unsaved_dir); + let s = e.session(); + let take_no = s.sources.len() + 1; + s.tracks .iter() .filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive) .map(|t| { @@ -640,28 +749,118 @@ pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec>, ra } _ => 0, }; - (t.id, first, t.channels(), t.name.clone()) + let inputs = (0..t.channels()).map(|k| first.saturating_add(k) % input_channels.max(1)).collect(); + let file = |n: usize| dir.join("Audio Files").join(format!("{}_{n:02}.wav", sanitize_name(&t.name))); + let path = (take_no..take_no + 1_000).map(file).find(|p| !p.exists()).unwrap_or_else(|| file(take_no)); + (t.id, inputs, path) }) - .collect(); - if armed.is_empty() { - return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into())); - } + .collect() +} + +/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the +/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most +/// 1000), each pass after the first on a new playlist. +pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option) -> Vec { let before = e.session_arc(); let mut out = Vec::new(); - let take_no = e.session().sources.len() + 1; - for (tid, first, n, name) in armed { - let chans: Vec> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect(); - let buf = AudioBuffer { sample_rate: rate, channels: chans }; - let clip_name = format!("{name}_{take_no:02}"); + for (tid, path) in takes { + let read = std::fs::read(&path).map_err(|x| x.to_string()).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|x| x.to_string())); + let Ok((_, buf)) = read.inspect_err(|err| e.message(format!("{}: {err}", path.display()))) else { continue }; let s = e.session_mut(); - let src = add_source(s, &clip_name, buf, None, FileFormat::Wav); - let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0)); - let cid = s.new_clip_id(); - crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len)); - out.push(cid); + if s.track(tid).is_none() { + continue; + } + let resampled = buf.sample_rate != s.sample_rate.hz(); + let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take"); + let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav); + let Some(x) = s.sources.last_mut() else { continue }; + x.unsaved = resampled; + let len = i64::try_from(x.frames).unwrap_or(0); + let pass = pass.unwrap_or(len).max(1); + for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) { + let n = pass.min(len - offset); + if offset > 0 && n < pass / 8 { + break; + } + if offset > 0 + && let Some(t) = s.track_mut(tid) + { + t.playlists.push(soundcraft_model::Playlist::new(format!("{}.{:02}", t.name, t.playlists.len() + 1))); + t.active_playlist = t.playlists.len() - 1; + } + let cid = s.new_clip_id(); + crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n)); + out.push(cid); + } + } + if !out.is_empty() { + e.push_undo("Record", before); + } + out +} + +#[cfg(test)] +mod record_tests { + use super::*; + use serde_json::json; + + fn armed(e: &mut Engine, name: &str, format: &str, input: &str) -> TrackId { + let id = e.execute("track.new", &json!({"name": name, "format": format})).unwrap()["tracks"][0].as_u64().unwrap(); + e.execute("track.input", &json!({"tracks": [id], "input": input})).unwrap(); + e.execute("mix.record_arm", &json!({"tracks": [id], "value": true})).unwrap(); + TrackId(id) + } + + fn write_take(path: &Path, frames: usize) { + let mut bytes = soundcraft_audio_io::wav_float_header(1, 48_000, frames as u64).unwrap(); + bytes.extend((0..frames).flat_map(|i| (i as f32 / frames as f32 - 0.5).to_le_bytes())); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn armed_tracks_get_new_take_files_used_in_place() { + let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut e = Engine::default(); + let vox = armed(&mut e, "Vox", "mono", "In 2"); + let gtr = armed(&mut e, "Gtr", "stereo", "In 3-4"); + write_take(&dir.join("Audio Files/Vox_01.wav"), 10); + let plan = plan_takes(&e, &dir, 4); + assert_eq!(plan, vec![(vox, vec![1], dir.join("Audio Files/Vox_02.wav")), (gtr, vec![2, 3], dir.join("Audio Files/Gtr_01.wav"))]); + assert_eq!(plan_takes(&e, &dir, 2)[1].1, vec![0, 1]); + + let session = dir.join("Song.scraft").to_string_lossy().into_owned(); + e.execute("session.save_as", &json!({"path": session})).unwrap(); + write_take(&plan[0].2, 4_800); + let clips = add_recording(&mut e, 96_000, vec![(vox, plan[0].2.clone())], None); + let clip = e.session().find_clip(clips[0]).unwrap().1.clone(); + assert_eq!((clip.start, clip.length), (96_000, 4_800)); + assert_eq!(save_session(&mut e, &session).unwrap(), 0); + let src = e.session().sources.last().unwrap().clone(); + assert_eq!((src.name.as_str(), src.path.as_str(), src.unsaved, src.frames), ("Vox_02", "Audio Files/Vox_02.wav", false, 4_800)); + e.execute("edit.undo", &json!({})).unwrap(); + assert!(e.session().find_clip(clips[0]).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_loop_take_becomes_one_clip_per_pass_and_autosaves_in_place() { + let dir = std::env::temp_dir().join(format!("soundcraft-loop-take-{}", std::process::id())); + let path = dir.join("Audio Files/Vox_01.wav"); + write_take(&path, 1_050); + let mut e = Engine::default(); + let vox = armed(&mut e, "Vox", "mono", "In 1"); + assert_eq!(add_recording(&mut e, 1_000, vec![(vox, path.clone())], Some(400)).len(), 3); + assert_eq!(save_copy(&e, &dir.join("Untitled.scraft").to_string_lossy()).unwrap(), 0); + let t = e.session().track(vox).unwrap(); + let passes: Vec<_> = t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| (c.source_offset(), c.start, c.length))).collect(); + assert_eq!(passes, vec![(0, 1_000, 400), (400, 1_000, 400), (800, 1_000, 250)]); + assert_eq!((t.active_playlist, t.playlists[2].name.as_str()), (2, "Vox.03")); + e.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1); + let _ = std::fs::remove_dir_all(&dir); } - e.push_undo("Record", before); - Ok(out) } #[cfg(test)] @@ -686,6 +885,144 @@ mod save_tests { let _ = std::fs::remove_dir_all(&dir); } + fn with_take(level: f32) -> Engine { + let mut e = Engine::default(); + let s = e.session_mut(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let src = add_source(s, "Audio 1_02", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_02", src, 0, 0, 4_800)); + e + } + + fn reopen(path: &str) -> Engine { + let mut r = Engine::default(); + assert_eq!(open_session(&mut r, path).unwrap(), Vec::::new()); + r + } + + #[test] + fn saving_never_replaces_audio_of_another_session() { + let dir = std::env::temp_dir().join(format!("soundcraft-no-replace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |p: &str| dir.join(p).to_string_lossy().into_owned(); + save_session(&mut with_take(0.5), &path("A/A.scraft")).unwrap(); + save_session(&mut with_take(-0.25), &path("A/A v2.scraft")).unwrap(); + save_session(&mut with_take(0.75), &path("B/B.scraft")).unwrap(); + save_session(&mut reopen(&path("B/B.scraft")), &path("A/B.scraft")).unwrap(); + let levels = ["A/A.scraft", "A/A v2.scraft", "B/B.scraft", "A/B.scraft"].map(|p| { + let r = reopen(&path(p)); + r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0] + }); + assert_eq!(levels, [0.5, -0.25, 0.75, 0.75]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn consolidating_twice_keeps_both_renders() { + let dir = std::env::temp_dir().join(format!("soundcraft-consolidate-twice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().id; + let end = e.session().content_end(); + let render = |e: &Engine| soundcraft_mix::render_clips(e.session(), kick, Range::new(0, end)); + e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 500_000, "end": 560_000})).unwrap(); + save_session(&mut e, &path).unwrap(); + e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 1_400_000, "end": 1_500_000})).unwrap(); + save_session(&mut e, &path).unwrap(); + assert!(render(&reopen(&path)) == render(&e)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saving_after_undo_reuses_the_saved_audio() { + let dir = std::env::temp_dir().join(format!("soundcraft-undo-resave-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let mut e = with_take(0.5); + e.execute("track.new", &json!({})).unwrap(); + assert_eq!(save_session(&mut e, &path).unwrap(), 1); + e.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(save_session(&mut e, &path).unwrap(), 0); + assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saved_audio_is_trusted_only_in_the_folder_it_was_saved_in() { + let dir = std::env::temp_dir().join(format!("soundcraft-own-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |p: &str| dir.join(p).to_string_lossy().into_owned(); + let level = |r: &Engine| r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0]; + save_session(&mut with_take(-0.25), &path("B/Other.scraft")).unwrap(); + let mut e = with_take(0.5); + save_session(&mut e, &path("A/A.scraft")).unwrap(); + e.execute("track.new", &json!({})).unwrap(); + save_session(&mut e, &path("B/B.scraft")).unwrap(); + e.execute("edit.undo", &json!({})).unwrap(); + e.execute("session.save", &json!({})).unwrap(); + assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5); + e.execute("session.save_copy", &json!({"path": path("A/Copy.scraft")})).unwrap(); + e.execute("session.save", &json!({})).unwrap(); + assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5); + std::fs::remove_file(dir.join("A/Audio Files/Audio 1_02.wav")).unwrap(); + let mut missing = Engine::default(); + open_session(&mut missing, &path("A/A.scraft")).unwrap(); + save_session(&mut missing, &path("B/Moved.scraft")).unwrap(); + let mut r = Engine::default(); + assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saving_over_another_session_needs_replace() { + let dir = std::env::temp_dir().join(format!("soundcraft-replace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let mut first = with_take(0.5); + first.execute("session.save_as", &json!({"path": path})).unwrap(); + first.execute("session.save", &json!({})).unwrap(); + first.execute("session.save_as", &json!({"path": dir.join("Song")})).unwrap(); + assert!(first.execute("session.save_template", &json!({"path": path})).is_err()); + let mut second = with_take(-0.25); + let track = second.session().tracks[0].id.0; + for id in ["session.save", "session.save_as", "session.save_copy", "session.save_template", "file.export_selected_tracks_as_session"] { + let err = second.execute(id, &json!({"path": path, "tracks": [track]})).unwrap_err(); + assert!(err.to_string().contains("`replace: true`"), "{id}: {err}"); + } + assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1); + Engine::default().execute("session.save_as", &json!({"path": dir.join("New/New.scraft")})).unwrap(); + std::fs::copy(&path, dir.join("Song")).unwrap(); + let mut bare = Engine::default(); + open_session(&mut bare, &dir.join("Song").to_string_lossy()).unwrap(); + bare.execute("session.save", &json!({})).unwrap(); + second.execute("session.save_as", &json!({"path": path, "replace": true})).unwrap(); + let r = reopen(&path); + assert_eq!(r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0], -0.25); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saving_keeps_the_last_ten_versions_as_backups() { + let dir = std::env::temp_dir().join(format!("soundcraft-backups-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Song.scraft"); + let mut e = Engine::default(); + let mut versions = Vec::new(); + for i in 0..12 { + e.execute("track.new", &json!({"name": format!("T{i}")})).unwrap(); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + versions.push(std::fs::read(&path).unwrap()); + } + let backup = |n: usize| std::fs::read(dir.join(format!("Backups/Song backup {n}.scraft"))).ok(); + assert_eq!(std::fs::read_dir(dir.join("Backups")).unwrap().count(), 10); + assert_eq!((backup(1), backup(2), backup(11)), (None, Some(versions[1].clone()), Some(versions[10].clone()))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn save_gives_sources_that_share_a_path_their_own_files() { let mut s = Session::default(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9c0fd4..d159b9b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -237,7 +237,7 @@ impl Engine { 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", "start", "end", "at"].iter().any(|k| params.get(k).is_some()); + 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)); } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 79c2fc8..dfc72ea 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -873,6 +873,14 @@ fn process_strip( } TrackKind::Instrument | TrackKind::Midi => { if let Some(inst) = &mut strip.instrument { + // #152: push the instrument's stored parameter values into the live plugin + // before processing, the same way inserts do below. Without this, the values + // loaded from a session (or set via `mix.insert_state`) never reach the plugin + // instance and the rendered audio matches the plugin's defaults — e.g. a saved + // `level = -12 dB` sounds the same as `level = -6 dB`. + if let Some(ins) = &t.instrument { + apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); + } schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); @@ -1345,20 +1353,31 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { } fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { - let slot_u8 = u8::try_from(i).unwrap_or(0); + // Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it + // must not pick up slot A's lanes. + let Ok(slot_u8) = u8::try_from(i) else { + return apply_values(slot, ins, |_, v| v); + }; let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty()); - if slot.applied.len() != ins.params.len() { - slot.applied = vec![f32::NAN; ins.params.len()]; - } - for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) { - let mut val = *v; + apply_values(slot, ins, |k, val| { if automated && let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k)) && !l.points.is_empty() { - val = l.value_at(pos, val); + return l.value_at(pos, val); } + val + }); +} + +/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time. +fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) { + if slot.applied.len() != ins.params.len() { + slot.applied = vec![f32::NAN; ins.params.len()]; + } + for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) { + let val = value(k, *v); if val.to_bits() != last.to_bits() { slot.plugin.set_param(k, val); *last = val; @@ -1906,6 +1925,46 @@ mod tests { use soundcraft_model::{Insert, SourceAudio, SourceId}; use std::sync::Arc; + fn synth_session(level_db: f32) -> Session { + use soundcraft_midi::{Note, Sequence}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None); + let mut seq = Sequence::default(); + seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }); + let cid = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::midi(cid, "n", 0, 24_000, seq)); + let mut ins = Insert::new("subtractive_synth"); + ins.params.insert("level".into(), level_db); + tr.instrument = Some(ins); + s + } + + #[test] + fn stored_instrument_params_reach_the_plugin() { + // #152: a saved instrument level of -12 dB must render quieter than -6 dB. + // RMS over the held part of the note, past any parameter smoothing. + let rms = |db: f32| { + let out = render_range(&synth_session(db), Range::new(0, 12_000), 512); + let tail = out[0].get(6_000..).unwrap_or_default(); + (tail.iter().map(|x| x * x).sum::() / tail.len().max(1) as f32).sqrt() + }; + let (loud, quiet) = (rms(-6.0), rms(-12.0)); + assert!(loud > 0.0, "the synth should sound"); + assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}"); + } + + #[test] + fn insert_slot_a_automation_does_not_drive_the_instrument() { + let mut s = synth_session(-6.0); + let reference = render_range(&s, Range::new(0, 12_000), 512); + let tr = &mut s.tracks[0]; + let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: 0, param: "level".into() }); + lane.set_point(0, -48.0); + tr.automation.push(lane); + assert_eq!(render_range(&s, Range::new(0, 12_000), 512), reference); + } + fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs index e9a7867..001f2a1 100644 --- a/crates/model/src/mixer.rs +++ b/crates/model/src/mixer.rs @@ -425,7 +425,7 @@ pub enum Route { pub struct Insert { /// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`). pub plugin: String, - #[serde(default)] + #[serde(default, deserialize_with = "finite_params")] pub params: BTreeMap, #[serde(default)] pub bypass: bool, @@ -577,6 +577,13 @@ pub(crate) fn finite_or(v: f32, d: f32) -> f32 { if v.is_finite() { v } else { d } } +/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so +/// they keep their defaults instead of failing the whole session. +fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let params: BTreeMap> = serde::Deserialize::deserialize(d)?; + Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect()) +} + /// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12). pub fn fader_pos_to_db(pos: f32) -> f32 { let p = finite_or(pos, 0.0).clamp(0.0, 1.0); @@ -624,6 +631,16 @@ mod tests { assert_eq!(bad.state_bytes(), None); } + #[test] + fn params_saved_as_null_or_out_of_range_are_dropped() { + let mut i = Insert::new("eq_7band"); + i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]); + let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap(); + assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)])); + let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap(); + assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)])); + } + #[test] fn fader_taper_round_trips() { for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] { diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index f3537d7..5956796 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -160,6 +160,9 @@ pub struct EditState { /// Clips explicitly selected (Grabber selection). #[serde(default)] pub selected_clips: Vec, + /// Audio files selected in the Clip List. + #[serde(default)] + pub selected_sources: Vec, /// Playhead position when stopped. pub playhead: Samples, pub grid: GridValue, @@ -234,6 +237,7 @@ impl Default for EditState { selection: Range::default(), selected_tracks: Vec::new(), selected_clips: Vec::new(), + selected_sources: Vec::new(), playhead: 0, grid: GridValue::default(), nudge: GridValue::Seconds(1.0), @@ -308,6 +312,9 @@ pub struct Session { pub midi_originals: std::collections::BTreeMap, #[serde(skip)] pub pool: SourcePool, + /// The folder relative source paths point into: where the session was opened or last saved. + #[serde(skip)] + pub folder: Option, } impl Default for Session { @@ -341,6 +348,7 @@ impl Session { comments: String::new(), midi_originals: std::collections::BTreeMap::new(), pool: SourcePool::default(), + folder: None, }; s.edit.pre_roll = sample_rate.samples(2.0); s.edit.post_roll = sample_rate.samples(2.0); @@ -399,6 +407,15 @@ impl Session { self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id))) } + /// Clips on every playlist, alternates included. + pub fn all_clips(&self) -> impl Iterator { + self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips)) + } + + pub fn clips_using(&self, src: SourceId) -> impl Iterator { + self.all_clips().filter(move |c| c.source() == Some(src)) + } + /// End of the last clip on any track (session length). pub fn content_end(&self) -> Samples { self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0) diff --git a/crates/playback/Cargo.toml b/crates/playback/Cargo.toml index 6f728a5..5ff9904 100644 --- a/crates/playback/Cargo.toml +++ b/crates/playback/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true [dependencies] soundcraft-time.workspace = true +soundcraft-audio-io.workspace = true soundcraft-model.workspace = true soundcraft-mix.workspace = true soundcraft-dsp.workspace = true @@ -20,8 +21,5 @@ cpal.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] cpal = { workspace = true, features = ["wasm-bindgen"] } -[dev-dependencies] -soundcraft-audio-io.workspace = true - [lints] workspace = true diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index 09eb684..ec784ac 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -1,22 +1,78 @@ //! Audio input capture for recording. //! -//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared -//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is -//! kept in a small local backlog and appended next time, so nothing blocks the audio thread. +//! A [`Recorder`] opens the default input device. While armed, the input callback copies each +//! block into a preallocated lock-free ring (it never locks or allocates), and a writer thread +//! streams the ring into one WAV file per take, rewriting the header every second so a crash leaves +//! a playable file. -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::fs::File; +use std::io::{Seek, SeekFrom, Write}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; -/// Captured audio: planar f32 at `sample_rate`. -#[derive(Debug, Clone, Default)] -pub struct Take { - pub sample_rate: u32, - pub channels: Vec>, +use soundcraft_model::TrackId; + +/// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met. +pub type Takes = (Vec<(TrackId, PathBuf)>, Option); + +const RING_SECONDS: usize = 4; + +/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots +/// are atomics, so it needs no lock and no `unsafe`. +struct CaptureRing { + slots: Box<[AtomicU32]>, + mask: usize, + head: AtomicUsize, + tail: AtomicUsize, +} + +impl CaptureRing { + fn new(capacity: usize) -> CaptureRing { + let n = capacity.max(2).next_power_of_two(); + CaptureRing { slots: (0..n).map(|_| AtomicU32::new(0)).collect(), mask: n - 1, head: AtomicUsize::new(0), tail: AtomicUsize::new(0) } + } + + /// Queue up to `n` samples (`sample(i)` is the i-th); returns how many fit. + fn push(&self, n: usize, sample: impl Fn(usize) -> f32) -> usize { + let head = self.head.load(Ordering::Relaxed); + let n = n.min(self.slots.len().saturating_sub(head.wrapping_sub(self.tail.load(Ordering::Acquire)))); + for i in 0..n { + if let Some(slot) = self.slots.get(head.wrapping_add(i) & self.mask) { + slot.store(sample(i).to_bits(), Ordering::Relaxed); + } + } + self.head.store(head.wrapping_add(n), Ordering::Release); + n + } + + fn pop_all(&self, out: &mut Vec) { + let (tail, head) = (self.tail.load(Ordering::Relaxed), self.head.load(Ordering::Acquire)); + out.extend( + (0..head.wrapping_sub(tail)) + .map(|i| self.slots.get(tail.wrapping_add(i) & self.mask).map_or(0.0, |s| f32::from_bits(s.load(Ordering::Relaxed)))), + ); + self.tail.store(head, Ordering::Release); + } } struct Shared { armed: AtomicBool, - data: Mutex>, + ring: CaptureRing, + /// Samples lost while the ring was full. + dropped: AtomicUsize, +} + +impl Shared { + /// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued + /// as silence before the next block, so the take keeps its timing. + fn capture(&self, data: &[f32], owed: &mut usize) { + *owed -= self.ring.push(*owed, |_| 0.0); + let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 }; + *owed = owed.saturating_add(data.len() - kept); + self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed); + } } pub struct Recorder { @@ -28,12 +84,9 @@ pub struct Recorder { pub device_name: String, pub sample_rate: u32, pub channels: usize, + writer: Option>, } -/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets -/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`. -const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 }; - impl Recorder { /// Open the default input device. Errors when there is none (the caller shows a message). #[cfg(not(target_os = "freebsd"))] @@ -48,29 +101,23 @@ impl Recorder { return Err(format!("unsupported input sample format {:?}", cfg.sample_format())); } let config = cfg.config(); - let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) }); + let channels = usize::from(config.channels).max(1); + let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); + let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) }); let sh = Arc::clone(&shared); let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); let mon = Arc::clone(&monitor); - let mut backlog: Vec = Vec::new(); + let mut owed = 0; let stream = device .build_input_stream( &config, move |data: &[f32], _: &cpal::InputCallbackInfo| { crate::mark_audio_thread(); mon.push(data); - if !sh.armed.load(Ordering::Relaxed) { - backlog.clear(); - return; - } - if let Ok(mut d) = sh.data.try_lock() { - if d.len() < MAX_SAMPLES { - d.extend_from_slice(&backlog); - d.extend_from_slice(data); - } - backlog.clear(); - } else if backlog.len() < 1 << 20 { - backlog.extend_from_slice(data); + if sh.armed.load(Ordering::Acquire) { + sh.capture(data, &mut owed); + } else { + owed = 0; } }, |e| { @@ -82,14 +129,7 @@ impl Recorder { ) .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; - Ok(Recorder { - monitor, - shared, - _stream: Some(stream), - device_name: name, - sample_rate: config.sample_rate.0, - channels: usize::from(config.channels).max(1), - }) + Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None }) } #[cfg(target_os = "freebsd")] @@ -97,51 +137,174 @@ impl Recorder { Err("recording is not supported on this platform yet".into()) } - /// Start capturing (clears any previous take). - pub fn arm(&self) { - self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear(); - self.shared.armed.store(true, Ordering::Relaxed); + /// Create each take's file and start recording into them: a track's take records the given + /// input channels into a new file. + pub fn arm(&mut self, takes: Vec<(TrackId, Vec, PathBuf)>) -> Result<(), String> { + if self.writer.is_some() { + return Err("already recording".into()); + } + let takes = + takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::, _>>()?; + let ring = &self.shared.ring; + ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release); + self.shared.armed.store(true, Ordering::Release); + let (shared, channels) = (Arc::clone(&self.shared), self.channels); + match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) { + Ok(w) => self.writer = Some(w), + Err(e) => { + self.shared.armed.store(false, Ordering::Release); + return Err(e.to_string()); + } + } + Ok(()) } - pub fn is_armed(&self) -> bool { - self.shared.armed.load(Ordering::Relaxed) - } - - /// Stop capturing and return the take. - pub fn take(&self) -> Take { - self.shared.armed.store(false, Ordering::Relaxed); - let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner)); - deinterleave(&data, self.channels, self.sample_rate) - } - - /// Samples captured so far (per channel). - pub fn captured_frames(&self) -> usize { - self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.max(1)) + pub fn finish(&mut self) -> Takes { + self.shared.armed.store(false, Ordering::Release); + let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default(); + let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1); + let slow = + (lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize)); + (takes, problem.or(slow)) } } -pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take { - let ch = channels.max(1); - let frames = data.len() / ch; - let mut out = vec![Vec::with_capacity(frames); ch]; - for frame in data.chunks_exact(ch) { - for (c, v) in frame.iter().enumerate() { - if let Some(o) = out.get_mut(c) { - o.push(*v); - } +impl Drop for Recorder { + fn drop(&mut self) { + self.finish(); + } +} + +fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> Takes { + let (mut block, mut header_written) = (Vec::new(), Instant::now()); + loop { + let stopping = !shared.armed.load(Ordering::Acquire); + // The ring holds interleaved samples and a pop can end mid-frame: write whole frames and + // keep the remainder for the next pop, so channels never shift. + shared.ring.pop_all(&mut block); + let whole = block.len() - block.len() % channels.max(1); + takes.iter_mut().for_each(|t| t.write(&block[..whole], channels)); + block.drain(..whole); + if stopping { + break; + } + if header_written.elapsed() >= Duration::from_secs(1) { + header_written = Instant::now(); + takes.iter_mut().for_each(TakeWriter::write_header); + } + std::thread::sleep(Duration::from_millis(10)); + } + let (mut done, mut problem) = (Vec::new(), None); + for mut t in takes { + t.write_header(); + if t.frames == 0 { + let _ = std::fs::remove_file(&t.path); + } else { + done.push((t.id, t.path)); + } + problem = problem.or(t.error); + } + (done, problem) +} + +/// One take's file. After an error it stops writing; its header keeps covering what was written. +struct TakeWriter { + id: TrackId, + inputs: Vec, + path: PathBuf, + file: File, + rate: u32, + frames: u64, + error: Option, +} + +impl TakeWriter { + fn create(id: TrackId, inputs: Vec, path: PathBuf, rate: u32) -> Result { + let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path)); + let file = file.map_err(|e| format!("{}: {e}", path.display()))?; + let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None }; + take.write_header(); + take.error.take().map_or(Ok(take), Err) + } + + /// Append this take's channels of `block` (interleaved, `channels` wide). + fn write(&mut self, block: &[f32], channels: usize) { + let channels = channels.max(1); + if self.error.is_some() { + return; + } + let bytes: Vec = block + .chunks_exact(channels) + .flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes())) + .collect(); + match self.file.write_all(&bytes) { + Ok(()) => self.frames += (block.len() / channels) as u64, + Err(e) => self.fail(e), } } - Take { sample_rate, channels: out } + + fn write_header(&mut self) { + let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) { + Ok(h) => h, + Err(e) => return self.fail(e), + }; + let f = &mut self.file; + if let Err(e) = f.seek(SeekFrom::Start(0)).and_then(|_| f.write_all(&header)).and_then(|()| f.seek(SeekFrom::End(0))) { + self.fail(e); + } + } + + fn fail(&mut self, e: impl std::fmt::Display) { + self.error.get_or_insert_with(|| format!("{}: {e}", self.path.display())); + } } #[cfg(test)] mod tests { + use super::*; + + fn decode(path: &PathBuf) -> Vec> { + soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap().1.channels + } + #[test] - fn deinterleaves() { - let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000); - assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]); - let t = super::deinterleave(&[], 0, 48_000); - assert_eq!(t.channels.len(), 1); + fn a_full_ring_owes_the_lost_samples_as_silence() { + let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) }; + let (mut owed, mut out) = (0, Vec::new()); + shared.capture(&[1.0; 6], &mut owed); + shared.capture(&[2.0; 4], &mut owed); + shared.ring.pop_all(&mut out); + shared.capture(&[3.0; 2], &mut owed); + shared.ring.pop_all(&mut out); + assert_eq!(out, [vec![1.0; 6], vec![2.0; 2], vec![0.0; 2], vec![3.0; 2]].concat()); + assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2)); + } + + #[test] + fn takes_stream_into_their_files() { + let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let gtr = TakeWriter::create(TrackId(1), vec![1, 2], dir.join("Gtr_01.wav"), 48_000).unwrap(); + let mut vox = TakeWriter::create(TrackId(2), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap(); + assert!(TakeWriter::create(TrackId(3), vec![0], dir.join("Vox_01.wav"), 48_000).is_err(), "never overwrites a file"); + vox.write(&[0.5; 300], 3); + vox.write_header(); + vox.write(&[0.25; 150], 3); + assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen"); + + let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) }); + let sh = Arc::clone(&shared); + let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3)); + let input: Vec = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect(); + let mut owed = 0; + input.chunks(3 * 480).for_each(|block| shared.capture(block, &mut owed)); + shared.armed.store(false, Ordering::Release); + let (done, problem) = writer.join().unwrap(); + let channel = |c: usize| input.iter().skip(c).step_by(3).copied().collect::>(); + assert_eq!((done.len(), problem), (2, None)); + assert_eq!(decode(&done[0].1), vec![channel(1), channel(2)]); + assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..15d2b25 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold}; use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; -use soundcraft_model::{ClipId, TrackId}; +use soundcraft_model::{ClipId, Session, SourceId, TrackId}; use soundcraft_time::Samples; #[derive(Debug, Clone)] @@ -15,6 +15,7 @@ pub enum Dialog { Bounce { path: String, format: String, bit_depth: String, normalize: bool }, RenameTrack { id: TrackId, name: String }, RenameClip { id: ClipId, name: String }, + ClearClips { clips: Vec, sources: Vec, message: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler @@ -38,6 +39,7 @@ impl Dialogs { Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", Dialog::RenameClip { .. } => "rename_clip", + Dialog::ClearClips { .. } => "clear_clips", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", Dialog::TempoChange { .. } => "tempo_change", @@ -57,11 +59,22 @@ impl Dialogs { self.open = Some(Dialog::RenameClip { id, name: name.to_string() }); } + /// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear. + pub fn open_clear(&mut self, e: &Engine, clips: Vec, sources: Vec) -> bool { + let Some(message) = clear_message(e.session(), &clips, &sources) else { return false }; + self.open = Some(Dialog::ClearClips { clips, sources, message }); + true + } + /// 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 { + "clip.clear" => { + let ed = &e.session().edit; + return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); + } "track.new" => { Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } } @@ -146,6 +159,41 @@ fn path_dialog(id: &str, home: &str, name: &str) -> Option { Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() }) } +fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option { + let listed: std::collections::BTreeSet = clips.iter().copied().collect(); + let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect(); + let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect(); + let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") }; + let what = match (names.as_slice(), files.as_slice()) { + ([], []) => return None, + ([name], []) => format!("the clip \"{name}\""), + ([], [f]) => format!("the file \"{}\"", f.name), + (c, []) => count(c.len(), "clip"), + ([], f) => count(f.len(), "file"), + (c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")), + }; + let mut text = format!("Remove {what} from the session?"); + let used: Vec<(&str, usize)> = + files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect(); + let n: usize = used.iter().map(|u| u.1).sum(); + let users = if n == 1 { + "a clip on the timeline, which is removed too.".to_string() + } else { + format!("{n} clips on the timeline, which are removed too.") + }; + match used.as_slice() { + [] => {} + [_] if files.len() == 1 => text += &format!(" It is used by {users}"), + u if u.len() == files.len() => text += &format!(" They are used by {users}"), + [(name, _)] => text += &format!(" \"{name}\" is used by {users}"), + many => text += &format!(" {} of the files are used by {users}", many.len()), + } + if !files.is_empty() && files.iter().all(|f| !f.unsaved) { + text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." }; + } + Some(text) +} + fn default_dir() -> String { #[cfg(not(target_arch = "wasm32"))] { @@ -169,6 +217,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", + Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", Dialog::Fades { .. } => "Fades", @@ -286,16 +335,31 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } } + Dialog::ClearClips { clips, sources, message } => { + ui.set_max_width(340.0); + ui.label(message.as_str()); + if buttons(ui, "Remove", enter) { + action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true}))); + } + } 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" + && key != "dir" && ui.button("Browse…").clicked() && let Some(p) = pick(cmd, &[]) { *path = p; } + if let Some(pick) = &app.services.pick_folder + && key == "dir" + && ui.button("Browse…").clicked() + && let Some(p) = pick(path) + { + *path = p; + } if let Some(pick) = &app.services.pick_save && (cmd == "session.save_as" || cmd == "session.save_copy") && ui.button("Browse…").clicked() @@ -303,9 +367,17 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { { *path = p; } - if buttons(ui, "OK", enter) { + let file = soundcraft_engine::io::session_file(path); + let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file); + if replaces { + ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy())); + } + if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) { let mut v = json!({}); v[key.as_str()] = json!(path); + if replaces { + v["replace"] = json!(true); + } action = Some((cmd.clone(), v)); } } @@ -412,8 +484,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } if let Some((cmd, params)) = action { keep = false; - if let Err(e) = app.run(&cmd, params) { - app.ui.status = e; + match app.run(&cmd, params) { + Ok(v) if cmd == "file.export_clips" => { + app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default()); + } + Ok(_) => {} + Err(e) => app.ui.status = e, } } if keep { @@ -522,4 +598,36 @@ mod tests { assert_eq!(params["at"], 96_000); assert_eq!(params["bpm"], 133.5); } + + #[test] + fn clear_always_asks_and_names_the_timeline_clips_that_go_too() { + let mut e = soundcraft_engine::demo::demo_engine(); + let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap(); + let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats")); + let kick_clips: Vec = e.session().clips_using(kick).map(|c| c.id).collect(); + let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id; + let message = |d: &Dialogs| match &d.open { + Some(Dialog::ClearClips { message, .. }) => message.clone(), + other => panic!("{other:?}"), + }; + let mut d = Dialogs::default(); + assert!(!d.open_for_command(&e, "clip.clear")); + e.execute("edit.select", &json!({"clips": [snare]})).unwrap(); + assert!(d.open_for_command(&e, "clip.clear")); + assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?"); + e.execute("edit.select", &json!({"sources": [kick]})).unwrap(); + assert!(d.open_for_command(&e, "clip.clear")); + assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick])); + assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too."); + assert!(d.open_clear(&e, kick_clips.clone(), vec![kick])); + assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?"); + assert!(d.open_clear(&e, kick_clips, vec![kick, hats])); + assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too."); + e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false); + assert!(d.open_clear(&e, vec![], vec![kick, hats])); + assert_eq!( + message(&d), + "Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk." + ); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 238e54b..b5cea06 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,11 @@ pub struct UiState { pub configurations: Vec<(String, Value)>, /// Appearance (`ui.theme`): dark by default, light or follow the system on request. pub theme: theme::ThemeMode, + /// The main window's size (logical points) and maximized state, and the interface scale, as + /// the desktop app last saw them; it restores them at launch. + pub window_size: [f32; 2], + pub window_maximized: bool, + pub ui_scale: f32, } impl Default for UiState { @@ -153,6 +158,9 @@ impl Default for UiState { workspace_dir: String::new(), configurations: Vec::new(), theme: theme::ThemeMode::Dark, + window_size: [1600.0, 1000.0], + window_maximized: false, + ui_scale: 1.0, } } } @@ -162,6 +170,7 @@ impl Default for UiState { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + pub pick_folder: Option Option>>, } /// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`). @@ -169,6 +178,15 @@ pub fn preset_folder_name(plugin: &str) -> String { plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect() } +/// On the first frame, maximizes a window too big for the screen it opened on (sized on a larger +/// monitor) instead of letting it run off the edge; its saved size is kept for that monitor. +pub fn fit_window_to_screen(ctx: &egui::Context) { + let (inner, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size)); + if ctx.cumulative_pass_nr() == 0 && inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true)); + } +} + /// Drag-and-drop payload: an audio file (source id) dragged from the Clip List. #[derive(Debug, Clone, Copy)] pub struct DragSource(pub u64); @@ -421,17 +439,14 @@ impl SoundApp { return; } self.ensure_input(); - if self.recorder.is_none() { - self.ui.status = "Cannot record: no audio input device".into(); + if let Err(err) = self.arm_recorder() { + self.ui.status = format!("Cannot record: {err}"); return; } if let Some(p) = &self.player { p.set_recording(true); } let already_playing = self.is_playing(); - if let Some(r) = &self.recorder { - r.arm(); - } self.engine.transport.recording = true; if already_playing { // Punch in on the fly at the current playhead. @@ -457,6 +472,19 @@ impl SoundApp { } } + /// Arm the input for the record-enabled tracks; a session never saved records into its + /// autosave folder. + fn arm_recorder(&mut self) -> Result<(), String> { + let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); + let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name)); + let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) }; + let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels); + if plan.is_empty() { + return Err("no audio track is record-enabled".into()); + } + r.arm(plan) + } + /// Reads every live third-party plugin's state from the audio engine and stores it on its /// insert (`mix.insert_state`), so saves and bounces include it. pub fn capture_plugin_states(&mut self) { @@ -552,49 +580,26 @@ impl SoundApp { if let Some(p) = &self.player { p.set_recording(false); } - let Some(r) = &self.recorder else { return }; - let take = r.take(); - let rate = take.sample_rate; + let Some(r) = &mut self.recorder else { return }; + let (takes, problem) = r.finish(); + let tracks = takes.len().max(1); let sel = self.engine.session().edit.selection; - if self.engine.session().edit.loop_record && !sel.is_empty() { - // Split the capture into one take per loop pass; each pass gets its own playlist. - let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64())) - .unwrap_or(0) - .max(1); - let total = take.channels.first().map_or(0, Vec::len); - let passes = total.div_ceil(pass).max(1); - let mut made = 0; - for k in 0..passes { - let chunk: Vec> = - take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect(); - if chunk.first().is_none_or(|c| c.len() < pass / 8) { - continue; - } - if made > 0 { - let armed: Vec = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect(); - let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed})); - } - if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() { - made += 1; - } - } - self.ui.status = format!("Loop-recorded {made} take(s)"); - return; + let looped = self.engine.session().edit.loop_record && !sel.is_empty(); + let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) }; + let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass); + if let Some(p) = self.punch.take() { + // Keep only the punch range of each new take (merged into the Record undo step). + let clip_ids: Vec = ids.iter().map(|c| c.0).collect(); + let _ = self.engine.execute_merged( + "edit.trim_to_fill_selection", + &serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}), + "record", + ); } - match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) { - Ok(ids) => { - if let Some(p) = self.punch.take() { - // Keep only the punch range of each new take (merged into the Record undo step). - let clip_ids: Vec = ids.iter().map(|c| c.0).collect(); - let _ = self.engine.execute_merged( - "edit.trim_to_fill_selection", - &serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}), - "record", - ); - } - self.ui.status = format!("Recorded {} clip(s)", ids.len()); - } - Err(e) => self.ui.status = e.to_string(), + self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) }; + if let Some(p) = problem { + log::warn!("recording: {p}"); + self.ui.status = format!("{} ({p})", self.ui.status); } } @@ -1037,6 +1042,35 @@ mod tests { assert!(automation_step_due(0, 1_000_000, 960)); } + #[test] + fn preferences_saved_before_the_window_was_remembered_keep_full_scale() { + let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap(); + assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0)); + } + + #[test] + fn a_window_bigger_than_its_screen_opens_maximized() { + let maximizes = |w: f32, frames: usize| { + let ctx = egui::Context::default(); + let info = egui::ViewportInfo { + inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 1000.0))), + monitor_size: Some(egui::vec2(1728.0, 1117.0)), + ..Default::default() + }; + let mut maximized = false; + for _ in 0..frames { + let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, info.clone())].into_iter().collect(), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| super::fit_window_to_screen(ui.ctx())); + out.textures_delta.clear(); + maximized = out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Maximized(true)); + } + maximized + }; + assert!(maximizes(2400.0, 1)); + assert!(!maximizes(1600.0, 1)); + assert!(!maximizes(2400.0, 2)); + } + #[test] fn ordinary_play_after_stop_is_full_speed() { use serde_json::json; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8ae3899..258ee9b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -145,6 +145,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ /// Commands that open a dialog when invoked from a menu (programmatic calls never do). const DIALOG_COMMANDS: &[&str] = &[ + "clip.clear", "track.new", "file.bounce_mix", "session.new", diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index d977825..3ddf696 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -221,12 +221,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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 mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).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)); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true)); + let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).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)); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).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); @@ -303,7 +303,10 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let _ = resp; 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); + let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan); + if let Some(v) = entered { + let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); + } } y += ks + 20.0; } @@ -368,7 +371,17 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh // 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 (_, entered) = editable_readout( + ui, + vr, + ui.id().with(("volume-value", id.0)), + &db_text(track.mixer.volume_db), + &format!("{:.1}", track.mixer.volume_db), + parse_db, + ); + if let Some(db) = entered { + let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); + } let kind = match track.kind { TrackKind::Audio => "audio", TrackKind::Aux => "aux", @@ -607,9 +620,11 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec let t = Tokens::current(); 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() { t.send_on } else { t.slot_bg }; ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside); + let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max); + let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); + let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); match &snd { Some(s) => { let name = route_name(app, &s.target); @@ -620,44 +635,257 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec 0.0, t.counter_text, ); - ui.painter().with_clip_rect(r).text( - r.center() - vec2(0.0, 1.0), - Align2::CENTER_CENTER, + ui.painter().with_clip_rect(route_r).text( + pos2(r.min.x + 4.0, r.center().y - 1.0), + Align2::LEFT_CENTER, name, - regular(10.0), + regular(9.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 (level_resp, entered) = editable_readout( + ui, + level_r, + ui.id().with(("send-value", id.0, slot)), + &format!("{} dB", db_text(s.level_db)), + &format!("{:.1}", s.level_db), + parse_db, + ); + if let Some(db) = entered { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + } + if level_resp.as_ref().is_some_and(egui::Response::dragged) { + // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so + // applying it to the already-updated value made trackpad adjustment accelerate. + let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); + let db_per_point = if fine { 0.02 } else { 0.15 }; + let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } - if resp.double_clicked() { + if level_resp.as_ref().is_some_and(egui::Response::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))); + if let Some(resp) = level_resp { + let _ = resp.clone().on_hover_text(format!( + "Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options", + (b'A' + slot as u8) as char, + db_text(s.level_db) + )); + resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); + } } 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 = 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})); + if let Some(send) = &snd { + egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(250.0); + ui.horizontal(|ui| { + ui.strong(format!("Send {}", (b'A' + slot as u8) as char)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(route_name(app, &send.target)).color(Tokens::current().counter_text)); + }); + }); + ui.label(egui::RichText::new("DESTINATION").size(8.0).color(Tokens::current().text_dim)); + send_route_menu(app, ui, id, slot, Some(&send.target)); + ui.separator(); + if ui.button("Remove send").clicked() { + let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot})); + ui.close(); + } + }); + } else { + egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(250.0); + ui.strong(format!("Send {} · Choose destination", (b'A' + slot as u8) as char)); + send_route_menu(app, ui, id, slot, None); + }); + } +} + +/// Secondary controls live behind the amount readout, separate from the bus selector like Logic. +fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) { + ui.set_min_width(230.0); + ui.strong(format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target))); + let mut db = send.level_db.max(-60.0); + if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB")).changed() { + let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); + } + send_level_scale(ui, send.level_db); + ui.horizontal(|ui| { + for (label, delta) in [("−3", -3.0), ("−1", -1.0), ("+1", 1.0), ("+3", 3.0)] { + if ui.small_button(label).clicked() { + let db = (send.level_db.max(-60.0) + delta).clamp(-60.0, 12.0); + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); } } - 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}")})); + if ui.small_button("0 dB").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); + } + if ui.small_button("−∞").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": -144.0})); } }); + let mut pan = send.pan; + if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text("Pan")).changed() { + let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "pan": pan}), &format!("send-pan:{}:{slot}", id.0)); + } + ui.horizontal(|ui| { + let mut mute = send.mute; + if ui.checkbox(&mut mute, "Mute").changed() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": mute})); + } + let mut pre = send.pre_fader; + if ui.checkbox(&mut pre, "Pre-fader").changed() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": pre})); + } + }); + if let Route::Bus(bus) = send.target + && let Some(aux) = + app.engine.session().tracks.iter().find(|t| t.kind == TrackKind::Aux && t.mixer.input == Route::Bus(bus)).map(|t| (t.id, t.name.clone())) + && ui.button("Rename bus…").clicked() + { + app.dialogs.open_rename_track(aux.0, &aux.1); + ui.close(); + } +} + +/// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away +/// commits a valid value; Escape cancels. The interaction response remains available for dragging. +fn editable_readout( + ui: &mut Ui, + r: Rect, + id: egui::Id, + display: &str, + edit_initial: &str, + parse: fn(&str) -> Option, +) -> (Option, Option) { + let t = Tokens::current(); + let active_id = id.with("active"); + let text_id = id.with("text"); + let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false); + let mut started = false; + let mut response = None; + if !active { + let resp = ui.interact(r, id, Sense::click_and_drag()); + if resp.clicked() && !resp.double_clicked() { + active = true; + started = true; + ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string())); + } else { + ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, display, regular(9.0), t.counter_text); + } + response = Some(resp); + } + let mut value = None; + if active { + let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string()); + let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r)); + child.visuals_mut().override_text_color = Some(t.counter_text); + let edit = egui::TextEdit::singleline(&mut text) + .id(id.with("editor")) + .frame(egui::Frame::NONE) + .horizontal_align(egui::Align::Center) + .font(regular(9.0)) + .desired_width(r.width()); + let edit_resp = child.add_sized(r.size(), edit); + if started { + edit_resp.request_focus(); + } + let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if escape { + active = false; + } else if enter || edit_resp.lost_focus() { + if let Some(parsed) = parse(&text) { + value = Some(parsed); + active = false; + } else if edit_resp.lost_focus() { + active = false; + } + } + ui.data_mut(|d| d.insert_temp(text_id, text)); + } + ui.data_mut(|d| d.insert_temp(active_id, active)); + (response, value) +} + +fn parse_db(text: &str) -> Option { + let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', "."); + if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") { + return Some(-144.0); + } + cleaned.trim().parse::().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0)) +} + +fn parse_pan(text: &str) -> Option { + let cleaned = text.trim().to_ascii_lowercase().replace(',', "."); + let (sign, number) = if let Some(v) = cleaned.strip_prefix('<').or_else(|| cleaned.strip_prefix('l')) { + (-1.0, v) + } else if let Some(v) = cleaned.strip_suffix('>').or_else(|| cleaned.strip_prefix('r')) { + (1.0, v) + } else { + (1.0, cleaned.as_str()) + }; + let raw = number.trim().parse::().ok()? * sign; + let value = if number.contains('.') && raw.abs() <= 1.0 { raw } else { raw / 100.0 }; + value.is_finite().then_some(value.clamp(-1.0, 1.0)) +} + +/// A compact calibrated scale for a send. The marks use the same taper as the strip's level bar. +fn send_level_scale(ui: &mut Ui, db: f32) { + let t = Tokens::current(); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover()); + let line_y = r.min.y + 4.0; + ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border)); + for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() { + let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark); + ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim)); + let align = if i == 0 { + Align2::LEFT_TOP + } else if i == 5 { + Align2::RIGHT_TOP + } else { + Align2::CENTER_TOP + }; + let label = if mark > 0.0 { format!("+{mark:.0}") } else { format!("{mark:.0}") }; + ui.painter().text(pos2(x, line_y + 5.0), align, label, regular(8.0), t.text_dim); + } + let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(db); + ui.painter().line_segment([pos2(x, r.min.y), pos2(x, line_y + 3.0)], Stroke::new(2.0, t.counter_text)); +} + +fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) { + let busses = app.engine.session().busses.clone(); + let search_id = egui::Id::new(("send-bus-search", id.0, slot)); + let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); + ui.data_mut(|d| d.insert_temp(search_id, search.clone())); + let needle = search.trim().to_ascii_lowercase(); + let visible: Vec<_> = busses.iter().filter(|bus| needle.is_empty() || bus.name.to_ascii_lowercase().contains(&needle)).collect(); + egui::ScrollArea::vertical().id_salt(("send-route-busses", id.0, slot)).max_height(260.0).auto_shrink([false, true]).show_rows( + ui, + 22.0, + visible.len(), + |ui, rows| { + for bus in &visible[rows] { + let selected = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == bus.id); + if ui.selectable_label(selected, &bus.name).clicked() { + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus.name})); + ui.close(); + } + } + }, + ); + ui.separator(); + if ui.button("new bus...").clicked() { + let mut n = busses.len() + 1; + while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() { + n += 1; + } + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + ui.close(); + } } fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { @@ -665,29 +893,67 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { 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"})); + ui.close(); } let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone()); if !input && ui.button(main_name).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: "main"})); + ui.close(); } 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.close(); } } } ui.separator(); let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); - ui.menu_button("bus", |ui| { - for b in &busses { + ui.strong("Bus"); + let search_id = egui::Id::new(("route-bus-search", id.0, input)); + let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); + ui.data_mut(|d| d.insert_temp(search_id, search.clone())); + let needle = search.trim().to_ascii_lowercase(); + egui::ScrollArea::vertical().id_salt(("track-route-busses", id.0, input)).max_height(420.0).show(ui, |ui| { + for b in busses.iter().filter(|b| needle.is_empty() || b.to_ascii_lowercase().contains(&needle)) { if ui.button(b).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: b})); + ui.close(); } } - if ui.button("new bus...").clicked() { - let n = busses.len() + 1; - let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")})); - } }); + if ui.button("new bus...").clicked() { + let mut n = busses.len() + 1; + while busses.contains(&format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() { + n += 1; + } + let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")})); + ui.close(); + } +} + +#[cfg(test)] +mod tests { + use super::{parse_db, parse_pan}; + + #[test] + fn typed_db_accepts_console_notation_and_clamps() { + assert_eq!(parse_db("-6 dB"), Some(-6.0)); + assert_eq!(parse_db("+3,5"), Some(3.5)); + assert_eq!(parse_db("off"), Some(-144.0)); + assert_eq!(parse_db("99"), Some(12.0)); + assert_eq!(parse_db("loud"), None); + } + + #[test] + fn typed_pan_accepts_percent_console_and_fractional_values() { + assert_eq!(parse_pan("-50"), Some(-0.5)); + assert_eq!(parse_pan("<35"), Some(-0.35)); + assert_eq!(parse_pan("35>"), Some(0.35)); + assert_eq!(parse_pan("R25"), Some(0.25)); + assert_eq!(parse_pan("0.5"), Some(0.5)); + assert_eq!(parse_pan("center"), None); + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9ee4ba2..5522169 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -4,6 +4,7 @@ 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_model::{ClipId, SourceId}; use soundcraft_time::format_position; fn panel_header(ui: &mut Ui, title: &str) { @@ -100,51 +101,58 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); panel_header(ui, "CLIPS"); - let items: Vec<(Option, String, bool, [u8; 3])> = { + let items: Vec<(u64, String, bool, [u8; 3], bool)> = { let s = app.engine.session(); - let mut v: Vec<(Option, String, bool, [u8; 3])> = Vec::new(); + let mut v = Vec::new(); // Whole files first (bold), then clips on tracks. for src in &s.sources { - v.push((Some(src.id.0), format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150])); + v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id))); } 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.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id))); } } v }; - let selected: Vec = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect(); let fkey = egui::Id::new("clip_filter"); let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default(); ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY)); ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone())); let needle = filter.to_lowercase(); - let items: Vec<_> = items.into_iter().filter(|(_, n, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect(); + let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect(); egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| { - for (id, name, whole, color) in items { + for (id, name, whole, color, selected) in items { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() }); - if whole && let Some(src) = id { - resp.context_menu(|ui| { + resp.context_menu(|ui| { + if whole { if ui.button("Place on New Track").clicked() { - let _ = app.run("clip.place_source", json!({"source": src})); + let _ = app.run("clip.place_source", json!({"source": id})); ui.close(); } let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0); if let Some(t) = sel_track && ui.button("Place on Selected Track").clicked() { - let _ = app.run("clip.place_source", json!({"source": src, "track": t})); + let _ = app.run("clip.place_source", json!({"source": id, "track": t})); ui.close(); } - }); + ui.separator(); + } + if ui.button("Clear…").clicked() { + let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) }; + app.dialogs.open_clear(&app.engine, clips, sources); + ui.close(); + } + }); + if whole { // Whole files drag onto tracks. - resp.dnd_set_drag_payload(crate::DragSource(src)); + resp.dnd_set_drag_payload(crate::DragSource(id)); if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } } - if !whole && id.is_some_and(|i| selected.contains(&i)) { + if selected { ui.painter().rect_filled(r, 0.0, t.row_selected); } 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)); @@ -155,11 +163,8 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { if whole { bold(11.0) } else { regular(11.0) }, t.text, ); - if resp.clicked() - && !whole - && let Some(i) = id - { - let _ = app.run("edit.select", json!({"clips": [i]})); + if resp.clicked() { + let _ = app.run("edit.select", if whole { json!({"sources": [id]}) } else { json!({"clips": [id]}) }); } } }); @@ -407,44 +412,46 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { 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_merged( - "mix.insert_param", - &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}), - &format!("param:{}:{slot}:{}", tid.0, p.id), - ); - if let Some(pl) = &app.player { - // Keep the plugin's own editor in step. - pl.editor_set_param(tid, slot, p.id, x); + egui::ScrollArea::vertical().show(ui, |ui| { + 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_merged( + "mix.insert_param", + &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}), + &format!("param:{}:{slot}:{}", tid.0, p.id), + ); + if let Some(pl) = &app.player { + // Keep the plugin's own editor in step. + pl.editor_set_param(tid, slot, p.id, x); + } } + let _ = resp; + ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); + ui.end_row(); } - let _ = resp; - ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); - ui.end_row(); - } + }); }); }); if open { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index fb978c3..24e671d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -75,10 +75,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { if fixed(app, key, mods, mac) { 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, mac))); - if let Some(c) = hit { - let id = c.id; + if let Some(id) = command_for(key, mods, mac) { if crate::menus::invoke_shortcut_dialog(app, id) { continue; } @@ -87,6 +84,13 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { } } +fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> { + soundcraft_engine::command_specs() + .iter() + .find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))) + .map(|c| c.id) +} + /// Keys with context-dependent meaning. Returns true when handled. fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let plain = !m.command && !m.alt && !m.ctrl && !m.shift; @@ -284,8 +288,8 @@ mod label_tests { #[cfg(test)] mod tests { - use super::{mods_match, parse, search_chord}; - use egui::Modifiers; + use super::{command_for, mods_match, parse, search_chord}; + use egui::{Key, Modifiers}; /// Ctrl held on Windows or Linux, as egui reports it. const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; @@ -315,4 +319,11 @@ mod tests { assert!(!search_chord(MAC_CMD, true)); assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true)); } + + #[test] + fn shift_cmd_k_prompts_for_a_folder_to_export_clips() { + assert_eq!(command_for(Key::K, Modifiers { shift: true, ..MAC_CMD }, true), Some("file.export_clips")); + assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips")); + assert!(crate::dialogs::takes_path("file.export_clips")); + } } diff --git a/crates/ui-egui/tests/plugin_window.rs b/crates/ui-egui/tests/plugin_window.rs new file mode 100644 index 0000000..2387033 --- /dev/null +++ b/crates/ui-egui/tests/plugin_window.rs @@ -0,0 +1,19 @@ +use egui_kittest::Harness; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn a_long_plugin_window_stays_on_a_short_screen() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.ui.plugin_windows = vec![(kick, 0)]; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 500.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap(); + assert!(window.bottom() <= 500.0, "{window:?}"); +} diff --git a/crates/ui-egui/tests/save_as_replace.rs b/crates/ui-egui/tests/save_as_replace.rs new file mode 100644 index 0000000..717e2a6 --- /dev/null +++ b/crates/ui-egui/tests/save_as_replace.rs @@ -0,0 +1,38 @@ +//! Save As over another session asks first, and Enter alone never replaces it. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog}; + +#[test] +fn saving_over_another_session_takes_the_replace_button() { + let dir = std::env::temp_dir().join(format!("soundcraft-ui-replace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Song.scraft"); + soundcraft_engine::Engine::default().execute("session.save_as", &json!({"path": path})).unwrap(); + let other = std::fs::read(&path).unwrap(); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.dialogs.open = Some(Dialog::PathPrompt { + cmd: "session.save_as".into(), + title: "Save Session As".into(), + path: path.to_string_lossy().into(), + key: "path".into(), + }); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(std::fs::read(&path).unwrap(), other); + h.get_by_label("Replace").click(); + h.run_steps(3); + assert_ne!(std::fs::read(&path).unwrap(), other); + assert_eq!(h.state().engine.path, Some(path.to_string_lossy().into_owned())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index f27ea39..341ad53 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1749,7 +1749,7 @@ impl EditorLink { mod window { use objc2::rc::Retained; use objc2::{MainThreadMarker, MainThreadOnly}; - use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowStyleMask}; + use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask}; use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; use std::ffi::{c_char, c_void}; @@ -1780,7 +1780,10 @@ mod window { let mtm = MainThreadMarker::new().ok_or_else(|| "not on the main thread".to_string())?; let _app = NSApplication::sharedApplication(mtm); let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h)); - let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Miniaturizable; + // Plugin editors are transient windows. In particular they must not be + // minimizable: minimized NSWindows become persistent thumbnails in the macOS Dock, + // which made every editor visited in a session look like another running app. + let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable; // SAFETY: a freshly allocated window on the main thread; valid rect and style. let window = unsafe { NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false) @@ -1791,6 +1794,8 @@ mod window { window.setTitle(&NSString::from_str(title)); window.setLevel(NSFloatingWindowLevel); window.setHidesOnDeactivate(true); + window.setExcludedFromWindowsMenu(true); + window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle); window.center(); Ok(HostWindow { window: Retained::into_raw(window) }) } diff --git a/docs/mcp.md b/docs/mcp.md index d3ed9a9..7102a23 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,7 +24,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo | `execute` | Run one command with JSON params. | | `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. | | `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). | -| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). | +| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). `save_session` won't save over another existing file unless `replace` is true. | | `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. | | `bounce_mix` | Render to WAV/AIFF/FLAC; returns peak dBFS and integrated LUFS. | | `parity` | Feature-parity report. |