mirror of
https://github.com/aphrody-labs/soundcraft.git
synced 2026-10-13 19:26:53 +00:00
Merge branch 'edit-drag' into trackpad
This commit is contained in:
commit
18f81b037b
29 files changed
+1747
-368
No files matched your search
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user