diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 80de7e1..3682fab 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -3,6 +3,7 @@ use serde_json::json; use soundcraft_engine::{Engine, io}; use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::sampler_window::SampleRead; use soundcraft_ui_egui::{Services, SoundApp}; use std::cell::RefCell; use std::rc::Rc; @@ -41,7 +42,7 @@ pub fn start() { let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() }; let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone())); let app = SoundApp::new(engine, Some(player), Services::default()); - Ok(Box::new(WebShell { app, inbox: Rc::default() })) + Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default() })) }), ) .await; @@ -107,9 +108,14 @@ impl Purpose { /// handled on the next frame. The browser has no paths: files travel as bytes. type Inbox = Rc)>>>; +/// Audio dropped on an instrument track or a Sampler window, its bytes read, waiting for the +/// next frame to load into that track's sampler. +type SampleInbox = Rc, String>)>>>; + struct WebShell { app: SoundApp, inbox: Inbox, + samples: SampleInbox, } impl WebShell { @@ -134,7 +140,17 @@ impl WebShell { fn take_drops(&mut self, ctx: &egui::Context) { // In the browser a dropped file's `path()` is only its name: read its bytes instead. - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + // Audio dropped on an instrument track or a Sampler window goes to its sampler instead. + let dropped = self.app.take_dropped_files(ctx); + ctx.input_mut(|i| i.raw.dropped_files.clear()); + for read in self.app.take_sample_reads() { + let (samples, ctx) = (self.samples.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let bytes = read.file.bytes_async().await; + samples.borrow_mut().push((read, bytes)); + ctx.request_repaint(); + }); + } for f in dropped { let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into()); let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); @@ -191,6 +207,10 @@ impl eframe::App for WebShell { for (purpose, name, bytes) in pending { self.handle(purpose, &name, &bytes); } + let samples = std::mem::take(&mut *self.samples.borrow_mut()); + for (read, bytes) in samples { + self.app.load_sample_bytes(&read, bytes); + } self.app.logic(ctx); // A prompt opened during this frame becomes a picker on the next one. self.take_path_prompt(ctx); diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 1d23325..2a9d776 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -49,9 +49,10 @@ impl eframe::App for App { if let Some(menu) = &mut self.2 { menu.sync(&self.0); } - // Files dropped on the window: sessions open, audio/MIDI import. + // Files dropped on the window: audio on an instrument track or a Sampler window loads + // the sampler; otherwise sessions open and audio/MIDI import. let dropped: Vec = - ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect()); + self.0.take_dropped_files(ctx).iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect(); for p in dropped { open_path(&mut self.0, &p); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index cec7628..05d4a58 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -25,7 +25,7 @@ mod more_util; mod options; mod plugin_state; mod query; -mod sampler; +pub(crate) mod sampler; mod setup_more; mod surround; mod track; diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs index e467234..fc3bf18 100644 --- a/crates/engine/src/cmd/sampler.rs +++ b/crates/engine/src/cmd/sampler.rs @@ -57,8 +57,20 @@ fn load(e: &mut Engine, p: &Value) -> Result { } let bytes = crate::io::read_file(&path)?; let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); - let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); - let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; + attach(e, t, &name, &bytes, Some(&path), values) +} + +/// Decodes `bytes` (a file called `name`, for the format hint) into a new session source and +/// makes it the sample of track `t`'s Sampler, which becomes its instrument if it is not +/// already; `values` are sampler parameters to set. Fails before touching the session. +pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path: Option<&str>, values: Vec<(String, f32)>) -> Result { + let id = "mix.sampler_load"; + if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) { + return Err(bad(id, "only instrument tracks have a sampler")); + } + let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?; + let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); + let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; if buf.frames() == 0 || buf.num_channels() == 0 { return Err(EngineError::Io(format!("{name}: no audio"))); } @@ -66,7 +78,7 @@ fn load(e: &mut Engine, p: &Value) -> Result { .ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?; let channels = buf.num_channels(); let s = e.session_mut(); - let src = crate::io::add_source(s, &name, buf, Some(&path), format.format); + let src = crate::io::add_source(s, name, buf, path, format.format); let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone())); let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?; let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER); @@ -185,6 +197,33 @@ mod tests { let _ = std::fs::remove_dir_all(d); } + #[test] + fn loads_dropped_bytes_as_one_undo_step() { + let d = dir("bytes"); + let wav = d.join("tone.wav"); + tone_wav(&wav, 440.0, 48_000, 0.5, 1); + let bytes = std::fs::read(&wav).unwrap(); + let mut e = Engine::default(); + let t = instrument_track(&mut e); + let steps = e.undo_history().len(); + let r = crate::io::load_sampler_bytes(&mut e, t, "dropped.wav", &bytes).unwrap(); + assert_eq!(r["name"], "dropped"); + assert_eq!(r["frames"], 24_000); + assert_eq!(e.undo_label(), Some("Load Sampler Sample")); + assert_eq!(e.undo_history().len(), steps + 1); + let src = e.session().sources.first().cloned().unwrap(); + assert!(src.unsaved, "written into Audio Files on the next save"); + assert!(e.undo()); + assert!(e.session().sources.is_empty()); + assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth")); + assert!(crate::io::load_sampler_bytes(&mut e, t, "junk.wav", b"not audio").is_err()); + e.execute("track.new", &json!({"kind": "audio"})).unwrap(); + let audio = e.session().tracks.last().unwrap().id; + assert!(crate::io::load_sampler_bytes(&mut e, audio, "tone.wav", &bytes).is_err(), "an audio track"); + assert!(e.session().sources.is_empty()); + let _ = std::fs::remove_dir_all(d); + } + #[test] fn loads_flac_and_aiff_too() { let d = dir("formats"); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index fa3d1f8..7b26e0b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -163,6 +163,16 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option Ok(json!({"track": track, "clip": cid, "source": src, "frames": frames, "channels": channels})) } +/// Loads an audio file's bytes into an instrument track's Sampler as one undo step ("Load +/// Sampler Sample"): for files that have no path, such as one dropped into the browser. The same +/// checks as `mix.sampler_load`; not journaled, since the bytes can be large. +pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u8]) -> Result { + let before = e.session_arc(); + let r = crate::cmd::sampler::attach(e, track, name, bytes, None, Vec::new())?; + e.push_undo("Load Sampler Sample", before); + Ok(r) +} + /// Import a movie: a Video track clip spanning the picture (the movie is referenced by `path`, /// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails /// only when the file has neither a readable picture nor audio. diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 9cb1a9e..12a1bf9 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1965,6 +1965,10 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if resp.dnd_hover_payload::().is_some() { ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside); } + // Audio files dropped from outside onto an instrument track load its sampler. + if track.kind == soundcraft_model::TrackKind::Instrument { + crate::sampler_window::drop_target(app, ui.ctx(), lane, track.id); + } let s = app.engine.session_arc(); let tool = s.edit.tool; let mods = ui.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fdb0d16..bf7a2d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod palette; pub mod panels; mod recent; pub mod recovery; +pub mod sampler_window; pub mod score_editor; pub mod shortcuts; pub mod spot; @@ -285,6 +286,9 @@ pub struct SoundApp { pub extra: extra_windows::ExtraState, pub palette: palette::PaletteState, pub synthetic: Vec, + /// Where a dropped audio file loads into a track's sampler (last frame's rects). + pub sample_drop_targets: Vec<(egui::Rect, TrackId)>, + sample_reads: Vec, synthetic_grace: u32, control_rx: Option>, pending_shots: Vec, @@ -365,6 +369,8 @@ impl SoundApp { extra: extra_windows::ExtraState::default(), palette: palette::PaletteState::default(), synthetic: Vec::new(), + sample_drop_targets: Vec::new(), + sample_reads: Vec::new(), synthetic_grace: 0, control_rx: None, pending_shots: Vec::new(), @@ -1171,6 +1177,7 @@ impl SoundApp { ctx.memory_mut(|m| m.reset_areas()); } shortcuts::handle(self, &ctx); + self.sample_drop_targets.clear(); if !self.native_menu_bar { menus::menu_bar(self, ui); } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index ced3ca2..462dd7d 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -456,7 +456,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } /// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window, -/// right-click (or click, when none is set) picks another instrument. +/// right-click (or click, when none is set) picks another instrument. A built-in's parameters +/// open from that menu. Dropping an audio file on it loads the sampler. pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { let t = Tokens::current(); let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT); @@ -476,6 +477,7 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) }; let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); popup.show(|ui| instrument_menu(app, ui, id)); + crate::sampler_window::drop_target(app, ui.ctx(), r, id); } /// What a plugin slot shows at its left: a dot for inserts, the send's letter, or an arrow for @@ -633,6 +635,12 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let current = app.engine.session().track(id).and_then(|t| t.instrument.clone()); if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) { ui.label(egui::RichText::new(info.name).strong()); + if current.as_ref().is_some_and(|i| !soundcraft_mix::is_third_party(&i.plugin)) && ui.button("Edit Parameters…").clicked() { + if !app.ui.plugin_windows.contains(&(id, slot)) { + app.ui.plugin_windows.push((id, slot)); + } + ui.close(); + } if let Some(p) = &app.player && p.has_editor(id, slot) { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index c4bb0d3..dda31ce 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -453,7 +453,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue }; let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() }; let mut open = true; - egui::Window::new(format!("{tname} · {place} · {}", info.name)) + let shown = egui::Window::new(format!("{tname} · {place} · {}", info.name)) .id(egui::Id::new(("plugin", tid.0, slot))) .open(&mut open) .default_width(340.0) @@ -487,12 +487,15 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } } }); - if instrument { - // The parameter commands address insert slots: an instrument is played and - // edited in its own editor. + if instrument && soundcraft_mix::is_third_party(&ins.plugin) { + // A hosted instrument keeps its sounds and settings in its own editor. ui.label(egui::RichText::new("Load and edit sounds in the plugin's own editor.").color(Tokens::current().text_dim)); return; } + if instrument && ins.plugin == crate::sampler_window::SAMPLER { + crate::sampler_window::sample_strip(app, ui, tid, &ins); + ui.add_space(4.0); + } if info.id == "eq_7band" || info.id == "eq_1band" { eq_curve(ui, info.id, &ins); } @@ -515,12 +518,21 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { r.response } else { let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); + if instrument && crate::sampler_window::whole_number(&ins.plugin, p.id) { + sl = sl.integer(); + } if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { sl = sl.logarithmic(true); } ui.add(sl) }; - if (x - v).abs() > f32::EPSILON { + if (x - v).abs() > f32::EPSILON && instrument { + let _ = app.engine.execute_merged( + "mix.instrument_param", + &json!({"track": tid.0, "param": p.id, "value": x}), + &format!("instrument_param:{}:{}", tid.0, p.id), + ); + } else 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}), @@ -532,12 +544,19 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } } let _ = resp; - ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); + let shown = if instrument { crate::sampler_window::format_param(&ins.plugin, p, x) } else { p.format(x) }; + ui.label(egui::RichText::new(shown).font(mono(11.0))); ui.end_row(); } }); }); }); + if let Some(w) = shown + && instrument + && ins.plugin == crate::sampler_window::SAMPLER + { + crate::sampler_window::drop_target(app, ctx, w.response.rect, tid); + } if open { keep.push((tid, slot)); } diff --git a/crates/ui-egui/src/sampler_window.rs b/crates/ui-egui/src/sampler_window.rs new file mode 100644 index 0000000..b259343 --- /dev/null +++ b/crates/ui-egui/src/sampler_window.rs @@ -0,0 +1,307 @@ +//! The Sampler in its instrument window: a sample strip (the waveform with its start and loop +//! markers, which drag), Load Sample and Clear, and audio files dropped onto the window or onto +//! an instrument track. Markers and every other control go through `mix.instrument_param`, so +//! they undo and script like any edit. + +use crate::SoundApp; +use crate::theme::{Tokens, mono, regular}; +use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; +use soundcraft_model::{Insert, TrackId}; +use std::path::Path; + +/// The built-in Sampler's plugin id. +pub const SAMPLER: &str = "sampler"; + +/// File extensions a sample can come from (what the audio decoder reads). +const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf"]; + +/// Markers on the sample strip: the parameter each one sets, and whether it needs a loop. +const MARKERS: [(&str, bool); 3] = [("start", false), ("loop_start", true), ("loop_end", true)]; + +/// Whether a file name looks like audio the sampler can load. +pub fn is_audio_name(name: &str) -> bool { + Path::new(name).extension().and_then(|x| x.to_str()).is_some_and(|x| AUDIO_EXTS.contains(&x.to_ascii_lowercase().as_str())) +} + +/// Sampler parameters that are MIDI notes. +const NOTE_PARAMS: [&str; 3] = ["root_note", "key_low", "key_high"]; + +/// Whether a built-in instrument's parameter only takes whole numbers (notes and velocities). +pub fn whole_number(plugin: &str, param: &str) -> bool { + plugin == SAMPLER && (NOTE_PARAMS.contains(¶m) || param == "vel_low" || param == "vel_high") +} + +/// A built-in instrument's parameter as its window shows it: the sampler's notes by name. +pub fn format_param(plugin: &str, p: &soundcraft_dsp::ParamInfo, value: f32) -> String { + if plugin == SAMPLER && NOTE_PARAMS.contains(&p.id) { + let n = value.round().clamp(0.0, 127.0) as u8; + return format!("{} ({n})", soundcraft_midi::note_name(n)); + } + if whole_number(plugin, p.id) { + return format!("{:.0}", value.round()); + } + p.format(value) +} + +fn param(ins: &Insert, id: &str) -> f32 { + let default = soundcraft_dsp::plugin_info(SAMPLER).and_then(|i| i.param(id)).map_or(0.0, |p| p.default); + ins.params.get(id).copied().unwrap_or(default) +} + +/// Remembers `r` as a place where a dropped audio file loads into track `t`'s sampler, and +/// outlines it while files are dragged over it. +pub fn drop_target(app: &mut SoundApp, ctx: &egui::Context, r: Rect, t: TrackId) { + app.sample_drop_targets.push((r, t)); + let over = ctx.input(|i| !i.raw.hovered_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| r.contains(p))); + if over { + ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new(("sample_drop", t.0)))).rect_stroke( + r, + 2.0, + Stroke::new(2.0, Tokens::current().accent), + StrokeKind::Inside, + ); + } +} + +/// An audio file dropped on a sampler target whose bytes the shell has to read itself (in the +/// browser, files arrive by name and are read asynchronously). Hand the bytes to +/// [`SoundApp::load_sample_bytes`]. +#[derive(Debug, Clone)] +pub struct SampleRead { + pub track: TrackId, + pub file: egui::DroppedFileHandle, +} + +impl SampleRead { + /// The dropped file's name. + pub fn name(&self) -> String { + self.file.path().file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string() + } +} + +impl SoundApp { + /// Takes this frame's dropped files: audio dropped on an instrument track or a Sampler window + /// loads into that track's sampler (the sampler becomes its instrument). A file with a full + /// path loads straight away; one without (the browser) waits in [`SoundApp::take_sample_reads`] + /// for the shell to read. Everything else is returned for the shell to open or import. + pub fn take_dropped_files(&mut self, ctx: &egui::Context) -> Vec { + let (files, pos) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos())); + if files.is_empty() { + return files; + } + let Some(track) = self.drop_track(pos) else { return files }; + let mut rest = Vec::new(); + let mut claimed = false; + for f in files { + if claimed || !is_audio_name(&f.path().to_string_lossy()) { + rest.push(f); + continue; + } + claimed = true; + if f.path().is_absolute() { + let path = f.path().to_string_lossy().into_owned(); + let r = self.run("mix.sampler_load", json!({"track": track.0, "path": path})); + self.sample_loaded(track, &path, r); + } else { + self.sample_reads.push(SampleRead { track, file: f }); + } + } + rest + } + + /// The track a drop at `pos` loads into. Without a position (some platforms report none + /// during a drag), a single open Sampler window takes it. + fn drop_track(&self, pos: Option) -> Option { + if let Some(p) = pos { + return self.sample_drop_targets.iter().rev().find(|(r, _)| r.contains(p)).map(|(_, t)| *t); + } + let s = self.engine.session(); + let mut open = self.ui.plugin_windows.iter().filter(|(t, slot)| { + *slot == soundcraft_mix::INSTRUMENT_SLOT && s.track(*t).and_then(|x| x.instrument.as_ref()).is_some_and(|i| i.plugin == SAMPLER) + }); + match (open.next(), open.next()) { + (Some((t, _)), None) => Some(*t), + _ => None, + } + } + + /// Dropped files waiting for the shell to read their bytes. + pub fn take_sample_reads(&mut self) -> Vec { + std::mem::take(&mut self.sample_reads) + } + + /// Loads a dropped file's bytes (or reports why they could not be read) into its track's + /// sampler, as one undo step. + pub fn load_sample_bytes(&mut self, read: &SampleRead, bytes: Result, String>) { + let name = read.name(); + let r = bytes.and_then(|b| soundcraft_engine::io::load_sampler_bytes(&mut self.engine, read.track, &name, &b).map_err(|e| e.to_string())); + self.sample_loaded(read.track, &name, r); + } + + fn sample_loaded(&mut self, track: TrackId, what: &str, r: Result) { + match r { + Ok(v) => { + if !self.ui.plugin_windows.contains(&(track, soundcraft_mix::INSTRUMENT_SLOT)) { + self.ui.plugin_windows.push((track, soundcraft_mix::INSTRUMENT_SLOT)); + } + let name = v.get("name").and_then(|n| n.as_str()).unwrap_or(what); + self.ui.status = format!("{name} loaded into the sampler"); + } + Err(e) => self.ui.status = format!("{what}: {e}"), + } + } +} + +/// The sample strip and its buttons, shown above the Sampler's parameters. +pub fn sample_strip(app: &mut SoundApp, ui: &mut Ui, tid: TrackId, ins: &Insert) { + let t = Tokens::current(); + let s = app.engine.session(); + let source = ins.sample.and_then(|id| s.source(id)).cloned(); + let audio = ins.sample.and_then(|id| s.pool.get(id)).cloned(); + ui.horizontal(|ui| { + let can_pick = app.services.pick_open.is_some(); + let load = ui.add_enabled(can_pick, egui::Button::new("Load Sample…")); + let load = if can_pick { load } else { load.on_disabled_hover_text("Drop an audio file on this window") }; + if load.clicked() + && let Some(pick) = &app.services.pick_open + && let Some(path) = pick("mix.sampler_load", AUDIO_EXTS) + { + let _ = app.run("mix.sampler_load", json!({"track": tid.0, "path": path})); + } + if ins.sample.is_some() && ui.button("Clear").clicked() { + let _ = app.run("mix.sampler_clear", json!({"track": tid.0})); + } + }); + let info = match (&source, &audio) { + (Some(src), Some(a)) => { + let rate = a.buffer.sample_rate.max(1); + let secs = a.frames() as f64 / f64::from(rate); + let width = match a.buffer.num_channels() { + 1 => "mono".to_string(), + 2 => "stereo".to_string(), + n => format!("{n} ch"), + }; + format!("{} · {secs:.2} s · {width} · {:.1} kHz", src.name, f64::from(rate) / 1000.0) + } + (Some(src), None) => format!("{}: missing", src.name), + _ => "No sample".to_string(), + }; + ui.label(egui::RichText::new(info).font(mono(11.0)).color(t.text_dim)); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().max(300.0), 96.0), Sense::click_and_drag()); + let p = ui.painter_at(r); + p.rect_filled(r, 4.0, t.playlist_bg); + p.rect_stroke(r, 4.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + let Some(audio) = audio else { + p.text(r.center(), Align2::CENTER_CENTER, "Drop an audio file here", regular(12.0), t.text_dim); + return; + }; + let frames = audio.frames().max(1); + let x_of = |pct: f32| r.min.x + r.width() * (pct / 100.0).clamp(0.0, 1.0); + // Loop region under the waveform. + let looping = param(ins, "loop_mode") >= 0.5; + let (ls, le) = { + let (a, b) = (param(ins, "loop_start"), param(ins, "loop_end")); + (a.min(b), a.max(b)) + }; + if looping { + p.rect_filled(Rect::from_x_y_ranges(x_of(ls)..=x_of(le), r.y_range()), 0.0, t.accent.gamma_multiply(0.18)); + } + // One min/max column per point, a lane per channel (two at most). + let lanes = audio.peaks.len().clamp(1, 2); + let lane_h = r.height() / lanes as f32; + let cols = r.width().max(1.0) as usize; + let wave = t.text.gamma_multiply(0.8); + for (c, peaks) in audio.peaks.iter().take(lanes).enumerate() { + let mid = r.min.y + lane_h * (c as f32 + 0.5); + let half = lane_h * 0.45; + let samples = audio.channel(c).unwrap_or_default(); + for i in 0..cols { + let a = frames.saturating_mul(i) / cols; + let b = (frames.saturating_mul(i + 1) / cols).max(a + 1); + let (lo, hi) = peaks.minmax(samples, a, b); + let x = r.min.x + i as f32 + 0.5; + p.line_segment([pos2(x, mid - hi.clamp(-1.0, 1.0) * half), pos2(x, mid - lo.clamp(-1.0, 1.0) * half)], Stroke::new(1.0, wave)); + } + } + // Markers: S (sample start), L and E (loop start and end, when looping). + let shown: Vec<(&str, f32, Color32, &str)> = MARKERS + .iter() + .filter(|(_, needs_loop)| !needs_loop || looping) + .map(|(id, _)| { + let (col, tag) = match *id { + "start" => (t.playhead, "S"), + "loop_start" => (t.accent, "L"), + _ => (t.accent, "E"), + }; + (*id, param(ins, id), col, tag) + }) + .collect(); + for (_, pct, col, tag) in &shown { + let x = x_of(*pct); + p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.5, *col)); + let flag = Rect::from_min_size(pos2(x, r.min.y), vec2(12.0, 12.0)); + p.rect_filled(flag, 1.0, *col); + p.text(flag.center(), Align2::CENTER_CENTER, *tag, regular(9.0), t.text_dark); + } + // Dragging the nearest marker sets its parameter. + let grab_id = ui.id().with(("sample_marker", tid.0)); + let nearest = |x: f32| { + shown + .iter() + .map(|(id, pct, ..)| (*id, (x_of(*pct) - x).abs())) + .filter(|(_, d)| *d <= 10.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(id, _)| id) + }; + if let Some(h) = resp.hover_pos() + && nearest(h.x).is_some() + { + ui.ctx().set_cursor_icon(CursorIcon::ResizeHorizontal); + } + if resp.drag_started() + && let Some(pos) = resp.interact_pointer_pos() + { + let grabbed = nearest(pos.x).map(str::to_string); + ui.ctx().data_mut(|d| d.insert_temp(grab_id, grabbed)); + } + let grabbed = ui.ctx().data(|d| d.get_temp::>(grab_id)).flatten(); + if resp.dragged() + && let (Some(id), Some(pos)) = (grabbed.as_deref(), resp.interact_pointer_pos()) + { + let pct = ((pos.x - r.min.x) / r.width().max(1.0) * 100.0).clamp(0.0, 100.0); + let _ = app.engine.execute_merged( + "mix.instrument_param", + &json!({"track": tid.0, "param": id, "value": pct}), + &format!("instrument_param:{}:{id}", tid.0), + ); + } + if resp.drag_stopped() { + ui.ctx().data_mut(|d| d.remove::>(grab_id)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notes_show_by_name() { + let info = soundcraft_dsp::plugin_info(SAMPLER).unwrap(); + let root = info.param("root_note").unwrap(); + assert_eq!(format_param(SAMPLER, root, 60.0), format!("{} (60)", soundcraft_midi::note_name(60))); + assert_eq!(format_param(SAMPLER, info.param("vel_high").unwrap(), 126.6), "127"); + assert_eq!(format_param(SAMPLER, info.param("attack").unwrap(), 1.0), "1.0 ms"); + assert!(whole_number(SAMPLER, "key_low") && !whole_number(SAMPLER, "attack") && !whole_number("subtractive_synth", "root_note")); + } + + #[test] + fn audio_names() { + for n in ["a.wav", "B.FLAC", "x.mp3", "y.ogg", "z.aiff", "/tmp/k.Wav"] { + assert!(is_audio_name(n), "{n}"); + } + for n in ["song.scraft", "beat.mid", "noext", "", "pic.png"] { + assert!(!is_audio_name(n), "{n}"); + } + } +} diff --git a/crates/ui-egui/tests/command_reachability.rs b/crates/ui-egui/tests/command_reachability.rs index 384401e..5328947 100644 --- a/crates/ui-egui/tests/command_reachability.rs +++ b/crates/ui-egui/tests/command_reachability.rs @@ -21,10 +21,7 @@ const KNOWN_UNREACHED: &[&str] = &[ "mix.clear_mutes", "mix.clear_solos", "mix.insert_move", - "mix.instrument_param", "mix.phase_invert", - "mix.sampler_clear", - "mix.sampler_load", "mix.send_surround_pan", "mix.solo_safe", "options.post_roll", diff --git a/crates/ui-egui/tests/sampler_window.rs b/crates/ui-egui/tests/sampler_window.rs new file mode 100644 index 0000000..b322fdc --- /dev/null +++ b/crates/ui-egui/tests/sampler_window.rs @@ -0,0 +1,154 @@ +//! The Sampler's instrument window: audio dropped on it loads the sample (by path on the +//! desktop, by bytes in the browser), and built-in instruments show their parameters. + +use egui::{DroppedFile, Event, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable as _; +use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat}; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +#[derive(Debug)] +struct Dropped { + path: PathBuf, + bytes: Vec, +} + +impl DroppedFile for Dropped { + fn path(&self) -> &Path { + &self.path + } + fn bytes(&self) -> Result, String> { + Ok(self.bytes.clone()) + } +} + +fn wav(secs: f32) -> Vec { + let n = (48_000.0 * secs) as usize; + let one: Vec = (0..n).map(|i| 0.4 * (std::f32::consts::TAU * 220.0 * i as f32 / 48_000.0).sin()).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![one; 2] }; + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None }; + soundcraft_audio_io::encode(&buf, &opts).unwrap() +} + +fn step(h: &mut Harness<'_, SoundApp>) { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn harness() -> (Harness<'static, SoundApp>, TrackId) { + let engine = soundcraft_engine::demo::demo_engine(); + let keys = engine.session().tracks.iter().find(|t| t.name == "Keys").unwrap().id; + let mut app = SoundApp::new(engine, None, Services::default()); + app.ui.plugin_windows.push((keys, soundcraft_mix::INSTRUMENT_SLOT)); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + let _ = app.take_dropped_files(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + step(&mut h); + } + (h, keys) +} + +fn instrument(h: &Harness<'_, SoundApp>, t: TrackId) -> soundcraft_model::Insert { + h.state().engine.session().track(t).unwrap().instrument.clone().unwrap() +} + +/// Drops `file` with the pointer at the centre of the Keys sampler window. +fn drop_on_window(h: &mut Harness<'_, SoundApp>, keys: TrackId, file: Dropped) { + let r = h.state().sample_drop_targets.iter().rev().find(|(_, t)| *t == keys).map(|(r, _)| *r).expect("the sampler window is a drop target"); + h.state_mut().synthetic.push(Event::PointerMoved(r.center())); + for _ in 0..3 { + step(h); + } + h.input_mut().dropped_files.push(Arc::new(file)); + step(h); + for _ in 0..3 { + step(h); + } +} + +#[test] +fn built_in_instruments_show_their_parameters() { + let (h, keys) = harness(); + assert_eq!(instrument(&h, keys).plugin, "subtractive_synth"); + let _ = h.get_by_label("Cutoff"); +} + +#[test] +fn dropping_audio_with_a_path_loads_the_sampler() { + let dir = std::env::temp_dir().join(format!("sc-sampler-window-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("pluck.wav"); + std::fs::write(&path, wav(0.5)).unwrap(); + let (mut h, keys) = harness(); + drop_on_window(&mut h, keys, Dropped { path: path.clone(), bytes: Vec::new() }); + let ins = instrument(&h, keys); + assert_eq!(ins.plugin, "sampler"); + let src = h.state().engine.session().source(ins.sample.unwrap()).cloned().unwrap(); + assert_eq!(src.name, "pluck"); + assert_eq!(src.frames, 24_000); + assert!(h.state().engine.session().pool.get(src.id).is_some()); + assert!(h.state().ui.status.contains("loaded into the sampler"), "{}", h.state().ui.status); + // The window now shows the sample strip and the sampler's own parameters. + let _ = h.get_by_label("Load Sample…"); + let _ = h.get_by_label("Root Note"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn dropping_audio_without_a_path_reads_its_bytes() { + let (mut h, keys) = harness(); + drop_on_window(&mut h, keys, Dropped { path: PathBuf::from("web drop.wav"), bytes: wav(0.25) }); + // Nothing loads until the shell has read the bytes (asynchronously, in the browser). + assert_eq!(instrument(&h, keys).plugin, "subtractive_synth"); + let reads = h.state_mut().take_sample_reads(); + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].track, keys); + for r in &reads { + let bytes = r.file.bytes(); + h.state_mut().load_sample_bytes(r, bytes); + } + let ins = instrument(&h, keys); + assert_eq!(ins.plugin, "sampler"); + assert_eq!(h.state().engine.session().source(ins.sample.unwrap()).map(|s| s.name.as_str()), Some("web drop")); + assert_eq!(h.state().engine.undo_label(), Some("Load Sampler Sample")); + // Unreadable bytes leave the track alone and say why. + let before = h.state().engine.session().sources.len(); + h.state_mut().load_sample_bytes(&reads[0], Err("permission denied".into())); + assert!(h.state().ui.status.contains("permission denied")); + assert_eq!(h.state().engine.session().sources.len(), before); +} + +#[test] +fn non_audio_drops_are_left_to_the_shell() { + let (mut h, keys) = harness(); + drop_on_window(&mut h, keys, Dropped { path: PathBuf::from("/tmp/song.scraft"), bytes: Vec::new() }); + assert_eq!(instrument(&h, keys).plugin, "subtractive_synth"); + assert!(h.state_mut().take_sample_reads().is_empty()); +} + +#[test] +fn a_built_in_instruments_menu_opens_its_parameters() { + let engine = soundcraft_engine::demo::demo_engine(); + let keys = engine.session().tracks.iter().find(|t| t.name == "Keys").unwrap().id; + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder() + .with_size(vec2(400.0, 400.0)) + .build_ui_state(move |ui, app: &mut SoundApp| soundcraft_ui_egui::mix_window::instrument_menu(app, ui, keys), app); + h.step(); + assert!(h.state().ui.plugin_windows.is_empty()); + h.get_by_label("Edit Parameters…").click(); + h.step(); + assert_eq!(h.state().ui.plugin_windows, vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)]); +}