mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
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:
co-authored by
Claude Opus 5.5
parent
63bf6c5b4b
commit
6cff5ff3d9
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user