This commit is contained in:
Brandon Thomas committed 2026-10-13 06:12:51 -04:00
commit b2e4dee0dd
12 files changed
+584 -18

No files matched your search

+22 -2
View File
@@ -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<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
/// 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<RefCell<Vec<(SampleRead, Result<Vec<u8>, 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);
+3 -2
View File
@@ -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<String> =
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);
}
+1 -1
View File
@@ -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;
+42 -3
View File
@@ -57,8 +57,20 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
}
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<Value> {
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<Value> {
.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");
+10
View File
@@ -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<Value> {
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.
+4
View File
@@ -1965,6 +1965,10 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
if resp.dnd_hover_payload::<crate::DragSource>().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);
+7
View File
@@ -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<egui::Event>,
/// 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<sampler_window::SampleRead>,
synthetic_grace: u32,
control_rx: Option<Receiver<ControlRequest>>,
pending_shots: Vec<control::PendingShot>,
@@ -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);
}
+9 -1
View File
@@ -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)
{
+25 -6
View File
@@ -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));
}
+307
View File
@@ -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(&param) || 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<egui::DroppedFileHandle> {
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<egui::Pos2>) -> Option<TrackId> {
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<SampleRead> {
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<Vec<u8>, 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<serde_json::Value, String>) {
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::<Option<String>>(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::<Option<String>>(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}");
}
}
}
@@ -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",
+154
View File
@@ -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<u8>,
}
impl DroppedFile for Dropped {
fn path(&self) -> &Path {
&self.path
}
fn bytes(&self) -> Result<Vec<u8>, String> {
Ok(self.bytes.clone())
}
}
fn wav(secs: f32) -> Vec<u8> {
let n = (48_000.0 * secs) as usize;
let one: Vec<f32> = (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)]);
}