Merge branch 'edit-drag' into trackpad

This commit is contained in:
Brandon Thomas committed 2026-10-11 20:41:55 -04:00
commit 18f81b037b
29 files changed
+1747 -368

No files matched your search

+1 -1
View File
@@ -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),
}
+20 -4
View File
@@ -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] }
}
+11
View File
@@ -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<u32> {
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<Vec<u8>> {
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)
}
+69 -54
View File
@@ -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<u32>,
frames: u64,
force_rf64: bool,
) -> Result<Vec<u8>> {
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)
}
+17 -1
View File
@@ -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());
}
+6 -1
View File
@@ -54,7 +54,12 @@ pub fn tool_definitions() -> Vec<Value> {
&[],
),
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.",
+27 -8
View File
@@ -185,10 +185,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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();
+160
View File
@@ -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<CommandSpec> {
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<Option<T>> {
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<Value> {
let s = e.session();
let (clips, sources): (BTreeSet<ClipId>, BTreeSet<SourceId>) = 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<ClipId> = 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::<Result<Vec<_>>>()?;
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<ClipId> = 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<ClipId>) {
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());
}
}
+11 -3
View File
@@ -49,11 +49,12 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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::<Vec<_>>());
let sources =
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
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<Value> {
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;
}
+41 -9
View File
@@ -15,30 +15,35 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
}
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<CommandSpec> {
]
}
/// 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<Value> {
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};
+21 -6
View File
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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));
}
}
+2
View File
@@ -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());
+388 -51
View File
@@ -520,7 +520,7 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
}
/// 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<usize> {
write_session(e.session(), path, true).map(|(_, n, _)| n)
}
@@ -534,61 +534,172 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
/// 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<bool> =
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/<name> backup <n>.<ext>`, 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<u32> = 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 `<stem>.wav`, `<stem>_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<Vec<String>> {
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<Vec<String>> {
}
}
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<Vec<String>> {
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<Vec<f32>>, rate: u32) -> Result<Vec<soundcraft_model::ClipId>> {
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
/// `<track>_<NN>.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<usize>, 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<Vec<f32>>, 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<Samples>) -> Vec<soundcraft_model::ClipId> {
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<Vec<f32>> = (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::<String>::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();
+1 -1
View File
@@ -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));
}
+66 -7
View File
@@ -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::<f32>() / 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);
+18 -1
View File
@@ -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<String, f32>,
#[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<BTreeMap<String, f32>, D::Error> {
let params: BTreeMap<String, Option<f64>> = 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] {
+17
View File
@@ -160,6 +160,9 @@ pub struct EditState {
/// Clips explicitly selected (Grabber selection).
#[serde(default)]
pub selected_clips: Vec<ClipId>,
/// Audio files selected in the Clip List.
#[serde(default)]
pub selected_sources: Vec<SourceId>,
/// 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<u64, soundcraft_midi::Sequence>,
#[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<std::path::PathBuf>,
}
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<Item = &Clip> {
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips))
}
pub fn clips_using(&self, src: SourceId) -> impl Iterator<Item = &Clip> {
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)
+1 -3
View File
@@ -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
+233 -70
View File
@@ -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<Vec<f32>>,
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<String>);
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<f32>) {
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<Vec<f32>>,
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<std::thread::JoinHandle<Takes>>,
}
/// 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<f32> = 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<usize>, 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::<Result<Vec<_>, _>>()?;
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<TakeWriter>, 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<usize>,
path: PathBuf,
file: File,
rate: u32,
frames: u64,
error: Option<String>,
}
impl TakeWriter {
fn create(id: TrackId, inputs: Vec<usize>, path: PathBuf, rate: u32) -> Result<TakeWriter, String> {
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<u8> = 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<Vec<f32>> {
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<f32> = (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::<Vec<f32>>();
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);
}
}
+112 -4
View File
@@ -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<ClipId>, sources: Vec<SourceId>, 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<ClipId>, sources: Vec<SourceId>) -> 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<Dialog> {
Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() })
}
fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<String> {
let listed: std::collections::BTreeSet<ClipId> = 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<ClipId> = 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."
);
}
}
+80 -46
View File
@@ -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<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
pub pick_folder: Option<Box<dyn Fn(&str) -> Option<String>>>,
}
/// 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<Vec<f32>> =
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<u64> = 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<u64> = 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<u64> = 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;
+1
View File
@@ -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",
+300 -34
View File
@@ -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<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
for b in busses {
if ui.button(&b).clicked() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": b}));
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<f32>,
) -> (Option<egui::Response>, Option<f32>) {
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<f32> {
let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', ".");
if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") {
return Some(-144.0);
}
cleaned.trim().parse::<f32>().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0))
}
fn parse_pan(text: &str) -> Option<f32> {
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::<f32>().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<String> = 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);
}
}
+62 -55
View File
@@ -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<u64>, String, bool, [u8; 3])> = {
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
let mut v: Vec<(Option<u64>, 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<u64> = 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 {
+17 -6
View File
@@ -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"));
}
}
+19
View File
@@ -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:?}");
}
+38
View File
@@ -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);
}
+7 -2
View File
@@ -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) })
}
+1 -1
View File
@@ -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. |