MIDI editor, recording, more windows, Edit-window view columns

- MIDI Editor dock (piano roll, velocity lane; add/move/resize/delete notes)
  with midi.* engine commands
- Recording: input capture (playback::record), takes become clips on
  record-armed tracks (punch into a selection), one undo step
- Windows: Automation, Color Palette, Disk/System Usage, Task Manager,
  Metadata Inspector, MIDI Event List, MIDI Keyboard (step input),
  Workspace browser, Window Configurations, Arrange
- Edit window honours view flags: I/O, inserts, sends, comments columns;
  rectified/power/outline waveforms; clip names; track numbers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 23:08:58 -04:00
co-authored by Claude Opus 5.5
parent 63bf6c5b4b
commit 6cff5ff3d9
10 changed files with 1166 additions and 12 deletions
+162
View File
@@ -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<CommandSpec> {
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<ClipId> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<usize> {
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<Value> {
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<Value> {
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());
}
}
+2
View File
@@ -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
})
}
+42
View File
@@ -376,3 +376,45 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
}
Ok(missing)
}
/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record").
/// `channels` is the planar input capture at `rate`; each track takes the input channels its
/// input route names ("In 3" → channel 2), defaulting to the first channels.
pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, rate: u32) -> Result<Vec<soundcraft_model::ClipId>> {
let frames = channels.first().map_or(0, Vec::len);
if frames == 0 {
return Ok(Vec::new());
}
let armed: Vec<(TrackId, usize, usize, String)> = e
.session()
.tracks
.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::<usize>().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<Vec<f32>> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: chans };
let clip_name = format!("{name}_{take_no:02}");
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)
}
+2
View File
@@ -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};
+128
View File
@@ -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<Vec<f32>>,
}
struct Shared {
armed: AtomicBool,
data: Mutex<Vec<f32>>,
}
pub struct Recorder {
shared: Arc<Shared>,
#[cfg(not(target_os = "freebsd"))]
_stream: Option<cpal::Stream>,
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<Recorder, String> {
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<f32> = 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<Recorder, String> {
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);
}
}
+119 -6
View File
@@ -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<String> = 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);
+93 -5
View File
@@ -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<String>,
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<Player>,
/// Input capture, opened on first record.
pub recorder: Option<soundcraft_playback::record::Recorder>,
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<Gesture>,
pub edit_layout: edit_window::EditLayout,
pub midi: midi_editor::MidiEditorState,
pub synthetic: Vec<egui::Event>,
control_rx: Option<Receiver<ControlRequest>>,
pending_shots: Vec<control::PendingShot>,
@@ -133,6 +164,8 @@ pub struct SoundApp {
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
pub quit_requested: bool,
/// Reset floating-window positions next frame (Window › Arrange).
pub arrange_request: bool,
pub frame_ms: f32,
last_frame: Option<f64>,
}
@@ -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);
}
+62 -1
View File
@@ -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<Result<
"window.narrow_mix" => 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;
+238
View File
@@ -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<usize>,
pub drag: Option<NoteDrag>,
pub top_pitch: i32,
pub clip: Option<ClipId>,
}
#[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<ClipId> {
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}));
}
}
+318
View File
@@ -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<u64> = 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::<usize>().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<u8> = 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::<crate::UiState>(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;
}