diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs new file mode 100644 index 00000000..61c92b1a --- /dev/null +++ b/crates/engine/src/cmd/midi.rs @@ -0,0 +1,162 @@ +//! MIDI note editing (MIDI Editor, pencil on MIDI tracks). + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_midi::{Note, Sequence}; +use soundcraft_model::{Clip, ClipContent, ClipId}; +use soundcraft_time::TICKS_PER_QUARTER; + +pub fn specs() -> Vec { + vec![ + cmd!(query "midi.notes", "List MIDI Notes", [], None, "{clip}", always, notes), + cmd!("midi.new_clip", "New MIDI Clip", [], None, "{track, start?, end?|length?} (default: the edit selection, or 1 bar)", has_tracks, new_clip), + cmd!("midi.note_add", "Add Note", [], None, "{clip, pitch, start_ticks, length_ticks?: 480, velocity?: 100, channel?: 0}", always, note_add), + cmd!("midi.note_edit", "Edit Note", [], None, "{clip, index, pitch?, start_ticks?, length_ticks?, velocity?}", always, note_edit), + cmd!("midi.note_delete", "Delete Notes", [], None, "{clip, indices: [n]}", always, note_delete), + cmd!("midi.notes_move", "Move Notes", [], None, "{clip, indices: [n], ticks?: 0, semitones?: 0}", always, notes_move), + ] +} + +fn clip_id(p: &Value, cmd: &str) -> Result { + p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required")) +} + +fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> { + match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) { + Some(ClipContent::Midi { sequence }) => Ok(sequence), + Some(_) => Err(bad(cmd, "not a MIDI clip")), + None => Err(bad(cmd, "no such clip")), + } +} + +fn notes(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.notes")?; + match e.session().find_clip(id).map(|(_, c)| &c.content) { + Some(ClipContent::Midi { sequence }) => Ok(json!({"notes": sequence.notes})), + _ => Err(bad("midi.notes", "not a MIDI clip")), + } +} + +fn new_clip(e: &mut Engine, p: &Value) -> Result { + let t = tracks_required(e, "midi.new_clip", p)?.first().copied().ok_or_else(|| bad("midi.new_clip", "no track"))?; + if !e.session().track(t).is_some_and(|x| x.kind.is_midi()) { + return Err(bad("midi.new_clip", "not a MIDI or instrument track")); + } + let mut r = range_param(e, "midi.new_clip", p)?; + if r.is_empty() { + let s = e.session(); + let bar = soundcraft_time::GridValue::Note { value: soundcraft_time::NoteValue::Bar, dotted: false, triplet: false }; + let start = bar.line_at_or_before(r.start, s.sample_rate, &s.tempo, s.frame_rate); + r = soundcraft_time::Range::new(start, bar.next_line(start, s.sample_rate, &s.tempo, s.frame_rate)); + } + let s = e.session_mut(); + let id = s.new_clip_id(); + let name = s.track(t).map(|x| x.name.clone()).unwrap_or_default(); + crate::edit::place_clip(s, t, Clip::midi(id, name, r.start, r.len(), Sequence::default())); + Ok(json!({"clip": id, "start": r.start, "end": r.end})) +} + +fn note_add(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.note_add")?; + let n = Note { + pitch: i64_or(p, "pitch", 60).clamp(0, 127) as u8, + velocity: i64_or(p, "velocity", 100).clamp(1, 127) as u8, + release_velocity: 64, + channel: i64_or(p, "channel", 0).clamp(0, 15) as u8, + start: i64_or(p, "start_ticks", 0).max(0), + length: i64_or(p, "length_ticks", TICKS_PER_QUARTER / 2).max(1), + }; + let seq = seq_mut(e, id, "midi.note_add")?; + seq.notes.push(n); + seq.sort(); + let index = seq.notes.iter().position(|x| *x == n).unwrap_or(0); + Ok(json!({"index": index, "notes": seq.notes.len()})) +} + +fn note_edit(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.note_edit")?; + let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("midi.note_edit", "`index` required"))? as usize; + let seq = seq_mut(e, id, "midi.note_edit")?; + let n = seq.notes.get_mut(idx).ok_or_else(|| bad("midi.note_edit", "index out of range"))?; + if let Some(v) = p.get("pitch").and_then(Value::as_i64) { + n.pitch = v.clamp(0, 127) as u8; + } + if let Some(v) = p.get("start_ticks").and_then(Value::as_i64) { + n.start = v.max(0); + } + if let Some(v) = p.get("length_ticks").and_then(Value::as_i64) { + n.length = v.max(1); + } + if let Some(v) = p.get("velocity").and_then(Value::as_i64) { + n.velocity = v.clamp(1, 127) as u8; + } + let edited = *n; + seq.sort(); + Ok(json!({"note": edited, "index": seq.notes.iter().position(|x| *x == edited)})) +} + +fn indices(p: &Value) -> Vec { + p.get("indices").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|x| x as usize).collect()).unwrap_or_default() +} + +fn note_delete(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.note_delete")?; + let idx = indices(p); + let seq = seq_mut(e, id, "midi.note_delete")?; + let before = seq.notes.len(); + let mut i = 0; + seq.notes.retain(|_| { + let keep = !idx.contains(&i); + i += 1; + keep + }); + Ok(json!({"deleted": before - seq.notes.len()})) +} + +fn notes_move(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.notes_move")?; + let idx = indices(p); + let dt = i64_or(p, "ticks", 0); + let ds = i64_or(p, "semitones", 0); + let seq = seq_mut(e, id, "midi.notes_move")?; + for (i, n) in seq.notes.iter_mut().enumerate() { + if idx.contains(&i) { + n.start = (n.start + dt).max(0); + n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8; + } + } + seq.sort(); + Ok(json!({"moved": idx.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, TrackKind}; + + #[test] + fn create_clip_add_edit_move_delete() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0})).unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 480})).unwrap(); + e.execute("midi.note_edit", &json!({"clip": clip, "index": 0, "velocity": 30})).unwrap(); + e.execute("midi.notes_move", &json!({"clip": clip, "indices": [1], "semitones": 3})).unwrap(); + let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + assert_eq!(n["notes"][0]["velocity"], 30); + assert_eq!(n["notes"][1]["pitch"], 67); + e.execute("midi.note_delete", &json!({"clip": clip, "indices": [0]})).unwrap(); + assert_eq!(e.execute("midi.notes", &json!({"clip": clip})).unwrap()["notes"].as_array().map(Vec::len), Some(1)); + assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err()); + } + + #[test] + fn rejects_audio_tracks() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None); + assert!(e.execute("midi.new_clip", &json!({"track": t.0})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8fddf3a4..9726d2bb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ use std::sync::OnceLock; mod audiosuite; mod clip; +mod midi; mod edit; mod event; mod file; @@ -93,6 +94,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(transport::specs()); v.extend(query::specs()); v.extend(audiosuite::specs()); + v.extend(midi::specs()); v }) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cd3e1144..adf645bf 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -376,3 +376,45 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { } Ok(missing) } + +/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record"). +/// `channels` is the planar input capture at `rate`; each track takes the input channels its +/// input route names ("In 3" → channel 2), defaulting to the first channels. +pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec>, rate: u32) -> Result> { + let frames = channels.first().map_or(0, Vec::len); + if frames == 0 { + return Ok(Vec::new()); + } + let armed: Vec<(TrackId, usize, usize, String)> = e + .session() + .tracks + .iter() + .filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive) + .map(|t| { + let first = match &t.mixer.input { + soundcraft_model::Route::Hardware(h) => h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::().ok()).map_or(0, |n| n.saturating_sub(1)), + _ => 0, + }; + (t.id, first, t.channels(), t.name.clone()) + }) + .collect(); + if armed.is_empty() { + return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into())); + } + let before = e.session_arc(); + let mut out = Vec::new(); + let take_no = e.session().sources.len() + 1; + for (tid, first, n, name) in armed { + let chans: Vec> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect(); + let buf = AudioBuffer { sample_rate: rate, channels: chans }; + let clip_name = format!("{name}_{take_no:02}"); + let s = e.session_mut(); + let src = add_source(s, &clip_name, buf, None, FileFormat::Wav); + let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0)); + let cid = s.new_clip_id(); + crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len)); + out.push(cid); + } + e.push_undo("Record", before); + Ok(out) +} diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 6da4a665..32e3b443 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -8,6 +8,8 @@ //! falls back to a silent clock thread so the transport, meters and automation still run. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod record; + use soundcraft_mix::{MixEngine, StripMeter}; use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs new file mode 100644 index 00000000..edeb4fb4 --- /dev/null +++ b/crates/playback/src/record.rs @@ -0,0 +1,128 @@ +//! Audio input capture for recording. +//! +//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared +//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is +//! kept in a small local backlog and appended next time, so nothing blocks the audio thread. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; + +/// Captured audio: planar f32 at `sample_rate`. +#[derive(Debug, Clone, Default)] +pub struct Take { + pub sample_rate: u32, + pub channels: Vec>, +} + +struct Shared { + armed: AtomicBool, + data: Mutex>, +} + +pub struct Recorder { + shared: Arc, + #[cfg(not(target_os = "freebsd"))] + _stream: Option, + pub device_name: String, + pub sample_rate: u32, + pub channels: usize, +} + +/// Cap: one hour of 8-channel 192 kHz audio. +const MAX_SAMPLES: usize = 3600 * 192_000 * 8; + +impl Recorder { + /// Open the default input device. Errors when there is none (the caller shows a message). + #[cfg(not(target_os = "freebsd"))] + pub fn open() -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + let host = cpal::default_host(); + let device = host.default_input_device().ok_or("no audio input device")?; + #[allow(deprecated)] + let name = device.name().unwrap_or_else(|_| "Input".into()); + let cfg = device.default_input_config().map_err(|e| e.to_string())?; + if cfg.sample_format() != cpal::SampleFormat::F32 { + return Err(format!("unsupported input sample format {:?}", cfg.sample_format())); + } + let config = cfg.config(); + let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) }); + let sh = Arc::clone(&shared); + let mut backlog: Vec = Vec::new(); + let stream = device + .build_input_stream( + &config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + if !sh.armed.load(Ordering::Relaxed) { + backlog.clear(); + return; + } + if let Ok(mut d) = sh.data.try_lock() { + if d.len() < MAX_SAMPLES { + d.extend_from_slice(&backlog); + d.extend_from_slice(data); + } + backlog.clear(); + } else if backlog.len() < 1 << 20 { + backlog.extend_from_slice(data); + } + }, + |e| log::warn!("input stream error: {e}"), + None, + ) + .map_err(|e| e.to_string())?; + stream.play().map_err(|e| e.to_string())?; + Ok(Recorder { shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels: usize::from(config.channels).max(1) }) + } + + #[cfg(target_os = "freebsd")] + pub fn open() -> Result { + Err("recording is not supported on this platform yet".into()) + } + + /// Start capturing (clears any previous take). + pub fn arm(&self) { + self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear(); + self.shared.armed.store(true, Ordering::Relaxed); + } + + pub fn is_armed(&self) -> bool { + self.shared.armed.load(Ordering::Relaxed) + } + + /// Stop capturing and return the take. + pub fn take(&self) -> Take { + self.shared.armed.store(false, Ordering::Relaxed); + let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner)); + deinterleave(&data, self.channels, self.sample_rate) + } + + /// Samples captured so far (per channel). + pub fn captured_frames(&self) -> usize { + self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.max(1)) + } +} + +pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take { + let ch = channels.max(1); + let frames = data.len() / ch; + let mut out = vec![Vec::with_capacity(frames); ch]; + for frame in data.chunks_exact(ch) { + for (c, v) in frame.iter().enumerate() { + if let Some(o) = out.get_mut(c) { + o.push(*v); + } + } + } + Take { sample_rate, channels: out } +} + +#[cfg(test)] +mod tests { + #[test] + fn deinterleaves() { + let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000); + assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]); + let t = super::deinterleave(&[], 0, 48_000); + assert_eq!(t.channels.len(), 1); + } +} diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index bb43db7f..b6d2daf1 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -9,6 +9,17 @@ use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Trac use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position}; pub const HEADER_W: f32 = 236.0; +pub const COLUMN_W: f32 = 112.0; + +/// Optional Edit-window columns (View › Edit Window Views) that are switched on. +pub fn edit_columns(s: &Session) -> Vec<&'static str> { + let all = s.edit.flag("edit_view.all"); + ["io", "inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "comments"].into_iter().filter(|c| all || s.edit.flag(&format!("edit_view.{c}"))).collect() +} + +pub fn header_width(s: &Session) -> f32 { + HEADER_W + COLUMN_W * edit_columns(s).len() as f32 +} pub const RULER_H: f32 = 17.0; /// Geometry of the last frame, for hit testing and agents (`ui.inspect`). @@ -36,6 +47,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::DARK; egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); + egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui)); + if app.ui.show_midi_editor { + egui::Panel::bottom("midi_editor").resizable(true).default_size(320.0).min_size(260.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| crate::midi_editor::show(app, ui)); + } if app.ui.show_tracks_list { egui::Panel::left("tracks_list") .exact_size(168.0) @@ -48,6 +63,24 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui)); } +fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let r = ui.max_rect(); + let mut x = r.min.x + 10.0; + for (label, on, id) in [("MIDI EDITOR", app.ui.show_midi_editor, "window.midi_editor"), ("MEMORY LOCATIONS", app.ui.show_memory_locations, "window.memory_locations"), ("UNDO HISTORY", app.ui.show_undo_history, "window.undo_history")] { + let g = ui.painter().layout_no_wrap(label.to_string(), bold(10.5), if on { Color32::WHITE } else { t.text_dim }); + let tr = Rect::from_min_size(pos2(x, r.min.y + 2.0), vec2(g.size().x + 16.0, r.height() - 4.0)); + if on { + ui.painter().rect_filled(tr, 3.0, t.accent_dark); + } + ui.painter().galley(pos2(tr.min.x + 8.0, tr.center().y - g.size().y * 0.5), g, Color32::WHITE); + if ui.interact(tr, ui.id().with(("dock", label)), Sense::click()).clicked() { + let _ = app.run(id, json!({})); + } + x = tr.max.x + 6.0; + } +} + fn status_bar(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::DARK; let r = ui.max_rect(); @@ -81,8 +114,9 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { let rulers: Vec = app.engine.session().edit.rulers.clone(); let rulers_h = RULER_H * rulers.len() as f32 + 6.0; let hscroll_h = 14.0; - let header = Rect::from_min_max(full.min, pos2(full.min.x + HEADER_W, full.max.y - hscroll_h)); - let tl = Rect::from_min_max(pos2(full.min.x + HEADER_W, full.min.y + rulers_h), pos2(full.max.x - 12.0, full.max.y - hscroll_h)); + let hw = header_width(app.engine.session()); + let header = Rect::from_min_max(full.min, pos2(full.min.x + hw, full.max.y - hscroll_h)); + let tl = Rect::from_min_max(pos2(full.min.x + hw, full.min.y + rulers_h), pos2(full.max.x - 12.0, full.max.y - hscroll_h)); let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h)); app.edit_layout.timeline = [tl.min.x, tl.min.y, tl.max.x, tl.max.y]; ui.painter().rect_filled(full, 0.0, t.window_bg); @@ -410,6 +444,11 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) let Some(track) = s.track(id).cloned() else { return }; let selected = s.edit.selected_tracks.contains(&id); let head = Rect::from_min_max(row.min, pos2(row.min.x + HEADER_W, row.max.y)); + let cols = edit_columns(app.engine.session()); + for (i, c) in cols.iter().enumerate() { + let cr = Rect::from_min_size(pos2(head.max.x + i as f32 * COLUMN_W, row.min.y), vec2(COLUMN_W, row.height())); + header_column(app, ui, &track, c, cr); + } let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.max.y)); // Header background. ui.painter().rect_filled(head, 0.0, if selected { Color32::from_rgb(52, 58, 66) } else { t.panel_bg2 }); @@ -476,7 +515,13 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside, ); - ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark); + let shown_name = if app.engine.session().edit.flag("view.track_number") { + let n = app.engine.session().track_index(id).map_or(0, |i| i + 1); + format!("{n} {}", track.name) + } else { + track.name.clone() + }; + ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &shown_name, bold(12.0), t.text_dark); let nresp = ui.interact(name_r, ui.id().with(("name", id.0)), Sense::click()); if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); @@ -598,6 +643,57 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } } +/// One optional Edit-window column for a track (I/O, inserts, sends, comments). +fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { + let t = Tokens::DARK; + ui.painter().rect_filled(r, 0.0, t.panel_bg); + ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, t.border)); + let line_h = 15.0; + let rows = ((r.height() - 4.0) / line_h).floor().max(1.0) as usize; + let item = |i: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 3.0 + i as f32 * line_h), vec2(r.width() - 8.0, line_h - 2.0)); + let text = |ui: &Ui, rr: Rect, s: &str, c: Color32| { + ui.painter().rect_filled(rr, 2.0, t.slot_bg); + ui.painter().with_clip_rect(rr).text(pos2(rr.min.x + 4.0, rr.center().y), Align2::LEFT_CENTER, s, regular(10.0), c); + }; + let route = |r: &soundcraft_model::Route| match r { + soundcraft_model::Route::None => "none".to_string(), + soundcraft_model::Route::Main => "Out 1-2".to_string(), + soundcraft_model::Route::Bus(b) => app.engine.session().bus(*b).map_or("bus".into(), |b| b.name.clone()), + soundcraft_model::Route::Hardware(h) => h.clone(), + }; + match col { + "io" => { + if rows >= 1 { + text(ui, item(0), &format!("in: {}", route(&track.mixer.input)), t.text); + } + if rows >= 2 { + text(ui, item(1), &format!("out: {}", route(&track.mixer.output)), t.text); + } + } + "inserts_ae" | "inserts_fj" => { + let off = if col == "inserts_ae" { 0 } else { 5 }; + for i in 0..5.min(rows) { + let name = track.mixer.inserts.get(off + i).cloned().flatten().and_then(|x| soundcraft_dsp::plugin_info(&x.plugin).map(|p| p.name)).unwrap_or(""); + text(ui, item(i), name, t.text); + } + } + "sends_ae" | "sends_fj" => { + let off = if col == "sends_ae" { 0 } else { 5 }; + for i in 0..5.min(rows) { + let s = track.mixer.sends.get(off + i).cloned().flatten().map(|x| format!("{} {:.0}", route(&x.target), x.level_db.max(-99.0))).unwrap_or_default(); + text(ui, item(i), &s, t.text); + } + } + _ => { + let mut c = track.comments.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0))); + if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() { + let _ = app.run("track.comments", json!({"track": track.id.0, "comments": c})); + } + } + } +} + fn view_label(v: &str) -> String { match AutoParam::parse(v) { Some(p) => p.label(), @@ -716,7 +812,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track wave = Color32::from_rgb(70, 70, 70); } painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); - let name_h = if lane.height() >= 40.0 { 13.0 } else { 0.0 }; + let name_h = if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 }; if name_h > 0.0 { let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h)); painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar }); @@ -737,6 +833,9 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let nch = track.channels().min(audio.peaks.len().max(1)).max(1); let ch_h = body_r.height() / nch as f32; let zoom = s.edit.zoom.waveform_zoom; + let rectified = s.edit.flag("waveform.rectified"); + let power = s.edit.flag("waveform.power"); + let outlines = s.edit.flag("waveform.outlines"); let cols = vis.width().max(0.0) as usize; if cols > 0 && vis.height() > 2.0 { let a = (offset + sample_at(s, tl, vis.min.x) - clip.start) as f64; @@ -751,8 +850,22 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let x = vis.min.x + i as f32; let rel = sample_at(s, tl, x) - clip.start; let g = clip.gain_at(rel.clamp(0, clip.length)); - let top = cy - (mx * g * zoom).clamp(-1.0, 1.0) * half; - let bot = cy - (mn * g * zoom).clamp(-1.0, 1.0) * half; + let (mut top, mut bot) = if rectified { + let a = (mx.abs().max(mn.abs()) * g * zoom).clamp(0.0, 1.0); + let b = cy + half; + (b - a * half * 2.0, b) + } else { + (cy - (mx * g * zoom).clamp(-1.0, 1.0) * half, cy - (mn * g * zoom).clamp(-1.0, 1.0) * half) + }; + if power { + // Power view: compress peaks logarithmically around the centre. + let k = |y: f32| cy + (y - cy).signum() * ((y - cy).abs() / half).sqrt() * half; + top = k(top); + bot = k(bot); + } + if outlines { + bot = top + 1.0; + } let bot = if (bot - top) < 1.0 { top + 1.0 } else { bot }; let idx = mesh.vertices.len() as u32; mesh.colored_vertex(pos2(x, top), wave); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 440bd1f6..15fbe197 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,12 +10,14 @@ pub mod edit_window; pub mod fonts; pub mod icons; pub mod menus; +pub mod midi_editor; pub mod mix_window; pub mod panels; pub mod shortcuts; pub mod theme; pub mod toolbar; pub mod widgets; +pub mod windows; use serde_json::{Value, json}; use soundcraft_engine::{Engine, TransportRequest}; @@ -36,6 +38,7 @@ pub enum MainWindow { /// UI state (serde, so agents can read and set it). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default)] pub struct UiState { pub window: MainWindow, pub show_tracks_list: bool, @@ -57,6 +60,18 @@ pub struct UiState { pub plugin_windows: Vec<(TrackId, usize)>, pub audiosuite: Option, pub status: String, + pub show_automation: bool, + pub show_color_palette: bool, + pub show_disk_usage: bool, + pub show_system_usage: bool, + pub show_task_manager: bool, + pub show_metadata: bool, + pub show_event_list: bool, + pub show_midi_keyboard: bool, + pub show_workspace: bool, + pub show_configurations: bool, + pub workspace_dir: String, + pub configurations: Vec<(String, Value)>, } impl Default for UiState { @@ -79,6 +94,18 @@ impl Default for UiState { plugin_windows: Vec::new(), audiosuite: None, status: String::new(), + show_automation: false, + show_color_palette: false, + show_disk_usage: false, + show_system_usage: false, + show_task_manager: false, + show_metadata: false, + show_event_list: false, + show_midi_keyboard: false, + show_workspace: false, + show_configurations: false, + workspace_dir: String::new(), + configurations: Vec::new(), } } } @@ -117,6 +144,9 @@ pub enum Gesture { pub struct SoundApp { pub engine: Engine, pub player: Option, + /// Input capture, opened on first record. + pub recorder: Option, + record_start: Samples, pub ui: UiState, pub services: Services, pub dialogs: dialogs::Dialogs, @@ -124,6 +154,7 @@ pub struct SoundApp { pub main_meter: MeterDisplay, pub gesture: Option, pub edit_layout: edit_window::EditLayout, + pub midi: midi_editor::MidiEditorState, pub synthetic: Vec, control_rx: Option>, pending_shots: Vec, @@ -133,6 +164,8 @@ pub struct SoundApp { /// Transport simulation when there is no player (tests, offscreen renders). sim: Option<(Samples, Option, Option)>, pub quit_requested: bool, + /// Reset floating-window positions next frame (Window › Arrange). + pub arrange_request: bool, pub frame_ms: f32, last_frame: Option, } @@ -142,6 +175,8 @@ impl SoundApp { SoundApp { engine, player, + recorder: None, + record_start: 0, ui: UiState::default(), services, dialogs: dialogs::Dialogs::default(), @@ -149,6 +184,7 @@ impl SoundApp { main_meter: MeterDisplay::default(), gesture: None, edit_layout: edit_window::EditLayout::default(), + midi: midi_editor::MidiEditorState::default(), synthetic: Vec::new(), control_rx: None, pending_shots: Vec::new(), @@ -157,6 +193,7 @@ impl SoundApp { fonts_installed: false, sim: None, quit_requested: false, + arrange_request: false, frame_ms: 0.0, last_frame: None, } @@ -211,6 +248,9 @@ impl SoundApp { if let Some(p) = &self.player { p.stop(); } + if self.engine.transport.recording { + self.finish_recording(); + } self.sim = None; self.engine.transport.playing = false; self.engine.transport.recording = false; @@ -221,6 +261,45 @@ impl SoundApp { } } + fn start_recording(&mut self) { + let armed = self.engine.session().tracks.iter().any(|t| t.mixer.record_arm); + if !armed { + self.ui.status = "Record-enable a track first (the red button in its header).".into(); + return; + } + if self.recorder.is_none() && self.player.is_some() { + match soundcraft_playback::record::Recorder::open() { + Ok(r) => self.recorder = Some(r), + Err(e) => { + self.ui.status = format!("Cannot record: {e}"); + return; + } + } + } + self.record_start = self.engine.session().edit.selection.start; + if let Some(r) = &self.recorder { + r.arm(); + } + self.engine.transport.recording = true; + if !self.is_playing() { + let sel = self.engine.session().edit.selection; + // Record into the selection when there is one (punch), else open-ended. + let end = (!sel.is_empty()).then_some(sel.end); + self.start_play(sel.start, end, None); + } + } + + fn finish_recording(&mut self) { + self.engine.transport.recording = false; + let Some(r) = &self.recorder else { return }; + let take = r.take(); + let rate = take.sample_rate; + match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) { + Ok(ids) => self.ui.status = format!("Recorded {} clip(s)", ids.len()), + Err(e) => self.ui.status = e.to_string(), + } + } + /// Play from the selection, honouring loop playback and selection end. pub fn play_from_selection(&mut self) { let s = self.engine.session(); @@ -254,9 +333,10 @@ impl SoundApp { } } TransportRequest::Record => { - self.engine.transport.recording = true; - if !self.is_playing() { - self.play_from_selection(); + if self.engine.transport.recording { + self.stop_play(); + } else { + self.start_recording(); } } TransportRequest::Locate(at) => { @@ -287,8 +367,8 @@ impl SoundApp { // Read back the position. if let Some(p) = &self.player { let playing = p.is_playing(); - if self.engine.transport.playing && !playing { - self.engine.transport.playing = false; + let ended = self.engine.transport.playing && !playing; + if ended { p.set_speed(1.0); } if self.engine.transport.playing { @@ -301,6 +381,9 @@ impl SoundApp { } let main = snap.main.peak; feed_meter(&mut self.main_meter, main, 0.0, dt); + if ended { + self.stop_play(); + } } else if let Some((pos, end, looped)) = &mut self.sim { let sr = self.engine.session().sample_rate.as_f64(); *pos += (f64::from(dt) * sr) as i64; @@ -379,6 +462,10 @@ impl SoundApp { ctx.request_repaint(); return; } + if self.arrange_request { + self.arrange_request = false; + ctx.memory_mut(|m| m.reset_areas()); + } shortcuts::handle(self, &ctx); menus::menu_bar(self, ui); match self.ui.window { @@ -386,6 +473,7 @@ impl SoundApp { MainWindow::Mix => mix_window::show(self, ui), } panels::floating(self, &ctx); + windows::show(self, &ctx); dialogs::show(self, &ctx); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 35a25110..8a52bb7f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -24,6 +24,28 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")), ("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None), ("window.universe", "Universe", "View > Other Displays > Universe", None), + ("window.automation", "Automation", "Window > Automation", Some("Cmd+4")), + ("window.color_palette", "Color Palette", "Window > Color Palette", None), + ("window.disk_usage", "Disk Usage", "Window > Disk Usage", None), + ("window.system_usage", "System Usage", "Window > System Usage", None), + ("window.task_manager", "Task Manager", "Window > Task Manager", None), + ("window.metadata", "Metadata Inspector", "Window > Metadata Inspector", None), + ("window.event_list", "MIDI Event List", "Window > MIDI Event List", None), + ("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", None), + ("window.workspace", "Workspace", "Window > New Workspace > Default", None), + ("window.workspace_presets", "Workspace", "Window > New Workspace > Track Presets", None), + ("window.workspace_sounds", "Workspace", "Window > New Workspace > Soundbase", None), + ("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", None), + ("window.workspace_close", "Close All Workspaces", "Window > Workspaces > Close All Workspaces", None), + ("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", None), + ("window.config_new", "New Configuration", "Window > Configurations > New Configuration...", None), + ("window.config_update", "Update Active Configuration", "Window > Configurations > Update Active Configuration", None), + ("window.arrange_tile", "Tile", "Window > Arrange > Tile", None), + ("window.arrange_tile_h", "Tile Horizontal", "Window > Arrange > Tile Horizontal", None), + ("window.arrange_tile_v", "Tile Vertical", "Window > Arrange > Tile Vertical", None), + ("window.arrange_cascade", "Cascade", "Window > Arrange > Cascade", None), + ("window.midi_front", "MIDI Editors to Front", "Window > MIDI Editors > Bring to Front", None), + ("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None), ("view.mix_section", "Mix Window View", "", None), ("ui.new_tracks_dialog", "New Tracks…", "", None), ("ui.bounce_dialog", "Bounce Mix…", "", None), @@ -324,7 +346,46 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.narrow_mix), "window.session_info" => toggle(&mut app.ui.show_session_info), "window.about" => toggle(&mut app.ui.show_about), - "window.midi_editor" => toggle(&mut app.ui.show_midi_editor), + "window.midi_editor" | "window.midi_front" => toggle(&mut app.ui.show_midi_editor), + "window.midi_back" => { + app.ui.show_midi_editor = false; + json!({}) + } + "window.automation" => toggle(&mut app.ui.show_automation), + "window.color_palette" => toggle(&mut app.ui.show_color_palette), + "window.disk_usage" => toggle(&mut app.ui.show_disk_usage), + "window.system_usage" => toggle(&mut app.ui.show_system_usage), + "window.task_manager" => toggle(&mut app.ui.show_task_manager), + "window.metadata" => toggle(&mut app.ui.show_metadata), + "window.event_list" => toggle(&mut app.ui.show_event_list), + "window.midi_keyboard" => toggle(&mut app.ui.show_midi_keyboard), + "window.workspace" | "window.workspace_presets" | "window.workspace_sounds" | "window.workspace_front" => { + app.ui.show_workspace = true; + json!({"value": true}) + } + "window.workspace_close" => { + app.ui.show_workspace = false; + json!({"value": false}) + } + "window.configurations" => toggle(&mut app.ui.show_configurations), + "window.config_new" | "window.config_update" => { + let mut snap = app.ui.clone(); + snap.configurations.clear(); + let v = serde_json::to_value(&snap).unwrap_or_default(); + if id == "window.config_update" && !app.ui.configurations.is_empty() { + if let Some(last) = app.ui.configurations.last_mut() { + last.1 = v; + } + } else { + let n = app.ui.configurations.len() + 1; + app.ui.configurations.push((format!("Configuration {n}"), v)); + } + json!({"configurations": app.ui.configurations.len()}) + } + "window.arrange_tile" | "window.arrange_tile_h" | "window.arrange_tile_v" | "window.arrange_cascade" => { + app.arrange_request = true; + json!({}) + } "window.universe" => toggle(&mut app.ui.show_universe), "window.close" | "window.hide_floating" => { app.ui.show_transport = false; diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs new file mode 100644 index 00000000..fc610963 --- /dev/null +++ b/crates/ui-egui/src/midi_editor.rs @@ -0,0 +1,238 @@ +//! The MIDI Editor: a piano roll with a velocity lane, docked under the Edit window. + +use crate::SoundApp; +use crate::theme::{Tokens, bold, regular}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; +use soundcraft_model::{ClipContent, ClipId}; +use soundcraft_time::TICKS_PER_QUARTER; + +const KEY_W: f32 = 48.0; +const ROW_H: f32 = 9.0; +const VEL_H: f32 = 64.0; + +#[derive(Debug, Clone, Default)] +pub struct MidiEditorState { + pub selected: Vec, + pub drag: Option, + pub top_pitch: i32, + pub clip: Option, +} + +#[derive(Debug, Clone)] +pub struct NoteDrag { + pub index: usize, + pub resize: bool, + pub start: egui::Pos2, + pub dx_ticks: i64, + pub dy_semi: i64, +} + +/// The MIDI clip being edited: the first selected MIDI clip, else a MIDI clip under the insertion +/// point on a selected track. +pub fn target_clip(app: &SoundApp) -> Option { + let s = app.engine.session(); + for id in &s.edit.selected_clips { + if let Some((_, c)) = s.find_clip(*id) + && !c.is_audio() + { + return Some(*id); + } + } + let at = s.edit.selection.start; + s.tracks + .iter() + .filter(|t| s.edit.selected_tracks.contains(&t.id) && t.kind.is_midi()) + .flat_map(|t| t.clips().iter()) + .find(|c| c.range().contains(at) || c.range().overlaps(&s.edit.selection)) + .or_else(|| s.tracks.iter().filter(|t| s.edit.selected_tracks.contains(&t.id) && t.kind.is_midi()).flat_map(|t| t.clips().iter()).next()) + .map(|c| c.id) +} + +fn is_black(p: i32) -> bool { + matches!(p.rem_euclid(12), 1 | 3 | 6 | 8 | 10) +} + +pub fn show(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let full = ui.max_rect(); + ui.painter().rect_filled(full, 0.0, t.panel_bg); + let header = Rect::from_min_size(full.min, vec2(full.width(), 22.0)); + ui.painter().rect_filled(header, 0.0, t.panel_bg2); + ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, "MIDI EDITOR", bold(11.5), t.header_text); + let Some(cid) = target_clip(app) else { + ui.painter().text(full.center(), Align2::CENTER_CENTER, "Select a MIDI clip (or a MIDI track) to edit its notes.", regular(13.0), t.text_dim); + return; + }; + let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return }; + let ClipContent::Midi { sequence } = &clip.content else { return }; + let track_color = app.engine.session().track(track).map_or([120, 120, 200], |t| t.color); + ui.painter().text(pos2(header.min.x + 110.0, header.center().y), Align2::LEFT_CENTER, format!("{} · {} notes · click: add · drag: move · edge: resize · Delete: remove", clip.name, sequence.notes.len()), regular(11.0), t.text_dim); + let s = app.engine.session().clone(); + let sr = s.sample_rate; + let base_tick = s.tempo.samples_to_ticks(clip.start, sr); + let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1); + let grid_ticks = match s.edit.grid { + soundcraft_time::GridValue::Note { value, dotted, triplet } => { + let mut g = value.ticks(); + if dotted { + g = g * 3 / 2; + } + if triplet { + g = g * 2 / 3; + } + g.max(1) + } + _ => TICKS_PER_QUARTER / 4, + }; + let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0)); + let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y)); + let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); + // Vertical range: centre on the notes the first time. + let rows = (roll.height() / ROW_H).floor() as i32; + if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) { + let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72); + let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48); + let mid = (hi + lo) / 2; + app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127); + app.midi.clip = Some(cid); + } + if ui.rect_contains_pointer(roll) { + let dy = ui.input(|i| i.smooth_scroll_delta.y); + if dy.abs() > 0.5 { + app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127); + } + } + let top = app.midi.top_pitch; + let px_per_tick = roll.width() / len_ticks as f32; + let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick; + let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H; + let painter = ui.painter().with_clip_rect(roll.union(keys)); + // Background rows and keys. + for r in 0..=rows { + let pitch = top - r; + let y = roll.min.y + r as f32 * ROW_H; + let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H)); + painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) }); + let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0)); + painter.rect_filled(key, 1.0, if is_black(pitch) { Color32::from_rgb(20, 20, 20) } else { Color32::from_rgb(225, 225, 225) }); + if pitch.rem_euclid(12) == 0 && (0..=127).contains(&pitch) { + painter.text(pos2(key.max.x - 3.0, key.center().y), Align2::RIGHT_CENTER, soundcraft_midi::note_name(pitch as u8), regular(8.0), Color32::from_rgb(40, 40, 40)); + painter.line_segment([pos2(roll.min.x, y + ROW_H), pos2(roll.max.x, y + ROW_H)], Stroke::new(1.0, Color32::from_rgb(64, 64, 68))); + } + } + // Grid. + let mut tick = 0; + while tick <= len_ticks { + let bar = tick % (TICKS_PER_QUARTER * 4) == 0; + let x = x_of(tick); + if grid_ticks as f32 * px_per_tick > 4.0 || bar { + painter.line_segment([pos2(x, roll.min.y), pos2(x, roll.max.y)], Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) })); + } + tick += grid_ticks; + } + // Notes. + let base = egui::Color32::from_rgb(track_color[0], track_color[1], track_color[2]); + let drag = app.midi.drag.clone(); + for (i, n) in sequence.notes.iter().enumerate() { + let (mut st, mut ln, mut p) = (n.start, n.length, i32::from(n.pitch)); + if let Some(d) = &drag + && (d.index == i || (!d.resize && app.midi.selected.contains(&i) && app.midi.selected.contains(&d.index))) + { + if d.resize { + if d.index == i { + ln = (ln + d.dx_ticks).max(grid_ticks.min(ln).max(1)); + } + } else { + st = (st + d.dx_ticks).max(0); + p = (p + d.dy_semi as i32).clamp(0, 127); + } + } + let r = Rect::from_min_max(pos2(x_of(st), y_of(p) + 1.0), pos2(x_of(st + ln).max(x_of(st) + 3.0), y_of(p) + ROW_H - 1.0)); + let sel = app.midi.selected.contains(&i); + let k = 0.45 + 0.55 * f32::from(n.velocity) / 127.0; + let fill = if sel { Color32::WHITE } else { Color32::from_rgb((f32::from(base.r()) * k) as u8, (f32::from(base.g()) * k) as u8, (f32::from(base.b()) * k) as u8) }; + painter.rect(r, 2.0, fill, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); + } + // Interaction. + let resp = ui.interact(roll, ui.id().with(("roll", cid.0)), Sense::click_and_drag()); + let hit = |pos: egui::Pos2| -> Option<(usize, bool)> { + sequence.notes.iter().enumerate().rev().find_map(|(i, n)| { + let r = Rect::from_min_max(pos2(x_of(n.start), y_of(i32::from(n.pitch))), pos2(x_of(n.start + n.length).max(x_of(n.start) + 3.0), y_of(i32::from(n.pitch)) + ROW_H)); + r.contains(pos).then_some((i, pos.x > r.max.x - 5.0)) + }) + }; + let snap = |tk: f32| -> i64 { ((tk / grid_ticks as f32).round() as i64) * grid_ticks }; + if resp.drag_started() + && let Some(p) = resp.interact_pointer_pos() + && let Some((i, edge)) = hit(p) + { + if !app.midi.selected.contains(&i) { + app.midi.selected = vec![i]; + } + app.midi.drag = Some(NoteDrag { index: i, resize: edge, start: p, dx_ticks: 0, dy_semi: 0 }); + } + if resp.dragged() + && let (Some(d), Some(p)) = (&mut app.midi.drag, resp.interact_pointer_pos()) + { + d.dx_ticks = snap((p.x - d.start.x) / px_per_tick); + d.dy_semi = -((p.y - d.start.y) / ROW_H).round() as i64; + } + if resp.drag_stopped() + && let Some(d) = app.midi.drag.take() + { + if d.resize { + if let Some(n) = sequence.notes.get(d.index) { + let _ = app.run("midi.note_edit", json!({"clip": cid.0, "index": d.index, "length_ticks": (n.length + d.dx_ticks).max(1)})); + } + } else if d.dx_ticks != 0 || d.dy_semi != 0 { + let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": d.dx_ticks, "semitones": d.dy_semi})); + app.midi.selected.clear(); + } + } + if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + match hit(p) { + Some((i, _)) => { + if ui.input(|x| x.modifiers.shift) { + if !app.midi.selected.contains(&i) { + app.midi.selected.push(i); + } + } else { + app.midi.selected = vec![i]; + } + // Audition on the instrument would go here (needs a live MIDI input path). + } + None => { + let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127); + let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks; + let _ = app.run("midi.note_add", json!({"clip": cid.0, "pitch": pitch, "start_ticks": start.max(0), "length_ticks": grid_ticks, "velocity": 100})); + app.midi.selected.clear(); + } + } + } + if !app.midi.selected.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); + app.midi.selected.clear(); + } + // Velocity lane. + ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30)); + ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); + let vresp = ui.interact(vel, ui.id().with(("vel", cid.0)), Sense::click_and_drag()); + for (i, n) in sequence.notes.iter().enumerate() { + let x = x_of(n.start); + let h = (vel.height() - 4.0) * f32::from(n.velocity) / 127.0; + let col = if app.midi.selected.contains(&i) { Color32::WHITE } else { base }; + ui.painter().line_segment([pos2(x + 1.0, vel.max.y - 2.0), pos2(x + 1.0, vel.max.y - 2.0 - h)], Stroke::new(3.0, col)); + ui.painter().circle_filled(pos2(x + 1.0, vel.max.y - 2.0 - h), 2.5, col); + } + if (vresp.clicked() || vresp.dragged()) + && let Some(p) = vresp.interact_pointer_pos() + && let Some((i, n)) = sequence.notes.iter().enumerate().min_by_key(|(_, n)| ((x_of(n.start) - p.x).abs() * 10.0) as i64) + && (x_of(n.start) - p.x).abs() < 8.0 + { + let v = (((vel.max.y - 2.0 - p.y) / (vel.height() - 4.0)) * 127.0).round().clamp(1.0, 127.0) as i64; + let _ = app.engine.execute("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v})); + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs new file mode 100644 index 00000000..a96c9e45 --- /dev/null +++ b/crates/ui-egui/src/windows.rs @@ -0,0 +1,318 @@ +//! Secondary floating windows (Window menu). + +use crate::SoundApp; +use crate::theme::{Tokens, mono, rgb}; +use egui::{Color32, Sense, vec2}; +use serde_json::json; +use soundcraft_model::{AutomationMode, ClipContent}; + +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + automation(app, ctx); + color_palette(app, ctx); + disk_usage(app, ctx); + system_usage(app, ctx); + task_manager(app, ctx); + metadata(app, ctx); + event_list(app, ctx); + midi_keyboard(app, ctx); + workspace(app, ctx); + configurations(app, ctx); +} + +fn win(ctx: &egui::Context, open: &mut bool, title: &str, size: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) { + if !*open { + return; + } + egui::Window::new(title).open(open).default_size(size).show(ctx, body); +} + +fn automation(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_automation; + win(ctx, &mut open, "Automation", vec2(260.0, 260.0), |ui| { + ui.label("Write automation for the selected tracks:"); + let tracks = app.engine.session().edit.selected_tracks.clone(); + ui.horizontal_wrapped(|ui| { + for m in [AutomationMode::Off, AutomationMode::Read, AutomationMode::Touch, AutomationMode::Latch, AutomationMode::TouchLatch, AutomationMode::Write, AutomationMode::Trim] { + if ui.button(m.label()).clicked() { + let ids: Vec = tracks.iter().map(|t| t.0).collect(); + let _ = app.run("mix.automation_mode", json!({"tracks": ids, "mode": m.label()})); + } + } + }); + ui.separator(); + ui.label("Enabled parameters"); + for f in ["volume", "pan", "mute", "send level", "send pan", "send mute", "plugin"] { + let id = format!("automation.enable.{}", f.replace(' ', "_")); + let mut on = !app.engine.session().edit.flag(&format!("{id}.off")); + if ui.checkbox(&mut on, f).changed() { + app.engine.session_mut().edit.set_flag(&format!("{id}.off"), !on); + } + } + ui.separator(); + if ui.button("Write to Current").clicked() { + let _ = app.run("automation.write_to_current", json!({})); + } + if ui.button("Thin All").clicked() { + let _ = app.run("automation.thin_all", json!({})); + } + }); + app.ui.show_automation = open; +} + +fn color_palette(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_color_palette; + win(ctx, &mut open, "Color Palette", vec2(300.0, 140.0), |ui| { + ui.label("Apply to selected tracks (Shift: selected clips)"); + ui.horizontal_wrapped(|ui| { + for (i, c) in soundcraft_model::TRACK_COLORS.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + ui.painter().rect_filled(r, 3.0, rgb(*c)); + if resp.clicked() { + if ui.input(|x| x.modifiers.shift) { + let _ = app.run("clip.color", json!({"color": c})); + } else { + let _ = app.run("track.color", json!({"color": i})); + } + } + } + }); + }); + app.ui.show_color_palette = open; +} + +fn disk_usage(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_disk_usage; + win(ctx, &mut open, "Disk Usage", vec2(360.0, 160.0), |ui| { + let s = app.engine.session(); + let bytes: u64 = s.sources.iter().map(|x| x.frames * u64::from(x.channels) * 4).sum(); + ui.label(format!("Session media: {} files, {:.1} MB in memory (32-bit float)", s.sources.len(), bytes as f64 / 1_048_576.0)); + let per_min = f64::from(s.sample_rate.hz()) * 60.0 * 3.0 / 1_048_576.0; + ui.label(format!("Recording at 24-bit uses {per_min:.1} MB per mono track-minute")); + if let Some(p) = &app.engine.path { + ui.label(format!("Session file: {p}")); + } + }); + app.ui.show_disk_usage = open; +} + +fn system_usage(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_system_usage; + win(ctx, &mut open, "System Usage", vec2(320.0, 140.0), |ui| { + ui.label(format!("UI frame time: {:.1} ms", app.frame_ms)); + ui.label(format!("Audio: {}", app.player.as_ref().map_or("no engine".to_string(), |p| format!("{} @ {} Hz", p.device_name, p.device_rate)))); + let plugins: usize = app.engine.session().tracks.iter().map(|t| t.mixer.inserts.iter().flatten().count()).sum(); + ui.label(format!("Active plugin instances: {plugins}")); + ui.label(format!("Tracks: {}", app.engine.session().tracks.len())); + ui.label(format!("Undo steps: {}", app.engine.undo_history().len())); + }); + app.ui.show_system_usage = open; +} + +fn task_manager(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_task_manager; + win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| { + ui.label("No background tasks are running."); + ui.label(egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.").small().color(Tokens::DARK.text_dim)); + }); + app.ui.show_task_manager = open; +} + +fn metadata(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_metadata; + win(ctx, &mut open, "Metadata Inspector", vec2(380.0, 260.0), |ui| { + let s = app.engine.session(); + ui.label(egui::RichText::new(&s.name).strong()); + egui::Grid::new("meta").num_columns(2).striped(true).show(ui, |ui| { + for (k, v) in [ + ("Sample rate", format!("{} Hz", s.sample_rate.hz())), + ("Bit depth", format!("{:?}", s.bit_depth)), + ("Timecode rate", s.frame_rate.label().to_string()), + ("Tracks", s.tracks.len().to_string()), + ("Clips", s.tracks.iter().map(|t| t.clips().len()).sum::().to_string()), + ("Audio files", s.sources.len().to_string()), + ("Markers", s.markers.len().to_string()), + ("Length", soundcraft_time::format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, 0)), + ] { + ui.label(k); + ui.label(v); + ui.end_row(); + } + }); + ui.separator(); + let mut c = s.comments.clone(); + ui.label("Session comments"); + if ui.text_edit_multiline(&mut c).changed() { + app.engine.session_mut().comments = c; + } + }); + app.ui.show_metadata = open; +} + +fn event_list(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_event_list; + win(ctx, &mut open, "MIDI Event List", vec2(420.0, 360.0), |ui| { + let Some(cid) = crate::midi_editor::target_clip(app) else { + ui.label("Select a MIDI clip."); + return; + }; + let notes = match app.engine.session().find_clip(cid).map(|(_, c)| c.content.clone()) { + Some(ClipContent::Midi { sequence }) => sequence.notes, + _ => Vec::new(), + }; + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("evl").num_columns(5).striped(true).show(ui, |ui| { + for h in ["Start (ticks)", "Note", "Vel", "Length", ""] { + ui.label(egui::RichText::new(h).strong()); + } + ui.end_row(); + for (i, n) in notes.iter().enumerate() { + ui.label(egui::RichText::new(n.start.to_string()).font(mono(11.0))); + ui.label(soundcraft_midi::note_name(n.pitch)); + let mut v = i64::from(n.velocity); + if ui.add(egui::DragValue::new(&mut v).range(1..=127)).changed() { + let _ = app.engine.execute("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v})); + } + ui.label(n.length.to_string()); + if ui.small_button("✕").clicked() { + let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": [i]})); + } + ui.end_row(); + } + }); + }); + }); + app.ui.show_event_list = open; +} + +fn midi_keyboard(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_midi_keyboard; + win(ctx, &mut open, "MIDI Keyboard", vec2(640.0, 110.0), |ui| { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().max(400.0), 80.0), Sense::click()); + let whites = 28; + let w = r.width() / whites as f32; + let mut wi = 0; + let base = 48u8; + let mut clicked: Option = None; + let pos = resp.interact_pointer_pos(); + for p in base..base + 48 { + if !matches!(p % 12, 1 | 3 | 6 | 8 | 10) { + let kr = egui::Rect::from_min_size(egui::pos2(r.min.x + wi as f32 * w, r.min.y), vec2(w - 1.0, r.height())); + ui.painter().rect_filled(kr, 2.0, Color32::from_rgb(235, 235, 235)); + if resp.clicked() && pos.is_some_and(|q| kr.contains(q)) { + clicked = Some(p); + } + wi += 1; + } + } + wi = 0; + for p in base..base + 48 { + if matches!(p % 12, 1 | 3 | 6 | 8 | 10) { + let kr = egui::Rect::from_min_size(egui::pos2(r.min.x + wi as f32 * w - w * 0.3, r.min.y), vec2(w * 0.6, r.height() * 0.6)); + ui.painter().rect_filled(kr, 2.0, Color32::from_rgb(20, 20, 20)); + if resp.clicked() && pos.is_some_and(|q| kr.contains(q)) { + clicked = Some(p); + } + } else { + wi += 1; + } + } + if let Some(p) = clicked + && let Some(cid) = crate::midi_editor::target_clip(app) + { + // Step input: add the note at the insertion point (relative to the clip) and advance. + let s = app.engine.session(); + let (clip_start, at) = (s.find_clip(cid).map_or(0, |(_, c)| c.start), s.edit.selection.start); + let rel = s.tempo.samples_to_ticks(at, s.sample_rate) - s.tempo.samples_to_ticks(clip_start, s.sample_rate); + let len = soundcraft_time::TICKS_PER_QUARTER / 2; + let _ = app.run("midi.note_add", json!({"clip": cid.0, "pitch": p, "start_ticks": rel.max(0), "length_ticks": len})); + let s = app.engine.session(); + let next = s.tempo.tick_to_samples(s.tempo.samples_to_ticks(at, s.sample_rate) + len, s.sample_rate); + let _ = app.run("transport.locate", json!({"at": next})); + } + ui.label(egui::RichText::new("Click keys to step-enter notes into the selected MIDI clip at the insertion point.").small()); + }); + app.ui.show_midi_keyboard = open; +} + +fn workspace(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_workspace; + if !open { + return; + } + egui::Window::new("Workspace").open(&mut open).default_size(vec2(460.0, 420.0)).show(ctx, |ui| { + let dir = if app.ui.workspace_dir.is_empty() { std::env::var("HOME").unwrap_or_else(|_| ".".into()) } else { app.ui.workspace_dir.clone() }; + ui.horizontal(|ui| { + if ui.button("⬆").on_hover_text("Parent folder").clicked() + && let Some(p) = std::path::Path::new(&dir).parent() + { + app.ui.workspace_dir = p.to_string_lossy().into_owned(); + } + ui.label(egui::RichText::new(&dir).small()); + }); + ui.separator(); + let mut entries: Vec<(String, bool)> = std::fs::read_dir(&dir) + .map(|rd| rd.flatten().filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + return None; + } + let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); + let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase(); + (is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "caf", "mid", "midi", "scraft"].contains(&ext.as_str())).then_some((name, is_dir)) + }).collect()) + .unwrap_or_default(); + entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))); + egui::ScrollArea::vertical().show(ui, |ui| { + for (name, is_dir) in entries.into_iter().take(2000) { + let path = std::path::Path::new(&dir).join(&name).to_string_lossy().into_owned(); + let label = if is_dir { format!("📁 {name}") } else { format!("🔊 {name}") }; + let r = ui.selectable_label(false, label); + if r.double_clicked() { + if is_dir { + app.ui.workspace_dir = path; + } else if name.ends_with(".scraft") { + let _ = app.run("session.open", json!({"path": path})); + } else if name.ends_with(".mid") || name.ends_with(".midi") { + let _ = app.run("file.import_midi", json!({"path": path})); + } else { + let at = app.engine.session().edit.selection.start; + let _ = app.run("file.import_audio", json!({"path": path, "at": at})); + } + } + } + }); + ui.label(egui::RichText::new("Double-click to import audio/MIDI at the insertion point or open a session.").small()); + }); + app.ui.show_workspace = open; +} + +fn configurations(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_configurations; + win(ctx, &mut open, "Window Configurations", vec2(320.0, 240.0), |ui| { + if ui.button("New Configuration…").clicked() { + let n = app.ui.configurations.len() + 1; + let mut snap = app.ui.clone(); + snap.configurations.clear(); + if let Ok(v) = serde_json::to_value(&snap) { + app.ui.configurations.push((format!("Configuration {n}"), v)); + } + } + ui.separator(); + let list = app.ui.configurations.clone(); + for (i, (name, v)) in list.iter().enumerate() { + ui.horizontal(|ui| { + if ui.button(format!("{} {name}", i + 1)).clicked() + && let Ok(mut st) = serde_json::from_value::(v.clone()) + { + st.configurations = app.ui.configurations.clone(); + st.show_configurations = true; + app.ui = st; + } + if ui.small_button("✕").clicked() { + app.ui.configurations.remove(i); + } + }); + } + }); + app.ui.show_configurations = open; +}