mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-13 23:08:25 +00:00
Merge #334
This commit is contained in:
commit
b2e4dee0dd
12 files changed
+584
-18
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<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",
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
Reference in new issue
Block a user