From 4127c141cc59f1310527fa5c3f1f1d6bbc31e3af Mon Sep 17 00:00:00 2001 From: unspeaker Date: Sat, 10 May 2025 21:21:12 +0300 Subject: [PATCH] editor: move to device crate --- crates/app/src/api.rs | 127 +------ crates/app/src/model.rs | 1 - crates/app/src/view.rs | 350 ------------------ crates/device/Cargo.toml | 5 +- crates/device/src/device.rs | 50 +-- crates/device/src/editor.rs | 7 + crates/device/src/editor/editor_api.rs | 126 +++++++ .../src/editor/editor_model.rs} | 10 +- crates/device/src/editor/editor_view.rs | 9 + crates/device/src/editor/editor_view_h.rs | 315 ++++++++++++++++ crates/device/src/editor/editor_view_v.rs | 37 ++ crates/device/src/lib.rs | 7 +- 12 files changed, 534 insertions(+), 510 deletions(-) create mode 100644 crates/device/src/editor.rs create mode 100644 crates/device/src/editor/editor_api.rs rename crates/{app/src/model/editor.rs => device/src/editor/editor_model.rs} (97%) create mode 100644 crates/device/src/editor/editor_view.rs create mode 100644 crates/device/src/editor/editor_view_h.rs create mode 100644 crates/device/src/editor/editor_view_v.rs diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 4e4ae4b0..ce88fe99 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -58,7 +58,7 @@ handle!(TuiIn: |self: App, input|Ok(if let Some(command) = self.config.keys.comm matches!(self.pool.as_ref().map(|p|p.mode.as_ref()).flatten(), Some(PoolMode::Length(..))) } fn editor_pitch (&self) -> Option { - Some((self.editor().map(|e|e.note_pos()).unwrap() as u8).into()) + Some((self.editor().map(|e|e.get_note_pos()).unwrap() as u8).into()) } /// Width of display pub(crate) fn w (&self) -> u16 { @@ -202,78 +202,6 @@ handle!(TuiIn: |self: App, input|Ok(if let Some(command) = self.config.keys.comm } } -#[tengri_proc::expose] impl MidiEditor { - fn _todo_opt_clip_stub (&self) -> Option>> { - todo!() - } - fn time_lock (&self) -> bool { - self.get_time_lock() - } - fn time_lock_toggled (&self) -> bool { - !self.get_time_lock() - } - - fn note_length (&self) -> usize { - self.get_note_len() - } - - fn note_pos (&self) -> usize { - self.get_note_pos() - } - fn note_pos_next (&self) -> usize { - self.get_note_pos() + 1 - } - fn note_pos_next_octave (&self) -> usize { - self.get_note_pos() + 12 - } - fn note_pos_prev (&self) -> usize { - self.get_note_pos().saturating_sub(1) - } - fn note_pos_prev_octave (&self) -> usize { - self.get_note_pos().saturating_sub(12) - } - - fn note_len (&self) -> usize { - self.get_note_len() - } - fn note_len_next (&self) -> usize { - self.get_note_len() + 1 - } - fn note_len_prev (&self) -> usize { - self.get_note_len().saturating_sub(1) - } - - fn note_range (&self) -> usize { - self.get_note_axis() - } - fn note_range_next (&self) -> usize { - self.get_note_axis() + 1 - } - fn note_range_prev (&self) -> usize { - self.get_note_axis().saturating_sub(1) - } - - fn time_pos (&self) -> usize { - self.get_time_pos() - } - fn time_pos_next (&self) -> usize { - self.get_time_pos() + self.time_zoom() - } - fn time_pos_prev (&self) -> usize { - self.get_time_pos().saturating_sub(self.time_zoom()) - } - - fn time_zoom (&self) -> usize { - self.get_time_zoom() - } - fn time_zoom_next (&self) -> usize { - self.get_time_zoom() + 1 - } - fn time_zoom_prev (&self) -> usize { - self.get_time_zoom().saturating_sub(1).max(1) - } -} - #[tengri_proc::command(App)] impl AppCommand { fn toggle_help (app: &mut App, value: bool) -> Perhaps { app.toggle_dialog(Some(Dialog::Help)); @@ -818,56 +746,3 @@ impl<'state> Context<'state, SamplerCommand> for App { todo!() } } - -#[tengri_proc::command(MidiEditor)] impl MidiEditCommand { - // TODO: 1-9 seek markers that by default start every 8th of the clip - fn note_append (editor: &mut MidiEditor) -> Perhaps { - editor.put_note(true); - Ok(None) - } - fn note_put (editor: &mut MidiEditor) -> Perhaps { - editor.put_note(false); - Ok(None) - } - fn note_del (editor: &mut MidiEditor) -> Perhaps { - todo!() - } - fn note_pos (editor: &mut MidiEditor, pos: usize) -> Perhaps { - editor.set_note_pos(pos.min(127)); - Ok(None) - } - fn note_len (editor: &mut MidiEditor, value: usize) -> Perhaps { - //let note_len = editor.get_note_len(); - //let time_zoom = editor.get_time_zoom(); - editor.set_note_len(value); - //if note_len / time_zoom != x / time_zoom { - editor.redraw(); - //} - Ok(None) - } - fn note_scroll (editor: &mut MidiEditor, value: usize) -> Perhaps { - editor.set_note_lo(value.min(127)); - Ok(None) - } - fn time_pos (editor: &mut MidiEditor, value: usize) -> Perhaps { - editor.set_time_pos(value); - Ok(None) - } - fn time_scroll (editor: &mut MidiEditor, value: usize) -> Perhaps { - editor.set_time_start(value); - Ok(None) - } - fn time_zoom (editor: &mut MidiEditor, value: usize) -> Perhaps { - editor.set_time_zoom(value); - editor.redraw(); - Ok(None) - } - fn time_lock (editor: &mut MidiEditor, value: bool) -> Perhaps { - editor.set_time_lock(value); - Ok(None) - } - fn show (editor: &mut MidiEditor, clip: Option>>) -> Perhaps { - editor.set_clip(clip.as_ref()); - Ok(None) - } -} diff --git a/crates/app/src/model.rs b/crates/app/src/model.rs index 41c4c875..46b52d58 100644 --- a/crates/app/src/model.rs +++ b/crates/app/src/model.rs @@ -1,7 +1,6 @@ use crate::*; mod dialog; pub use self::dialog::*; -mod editor; pub use self::editor::*; mod pool; pub use self::pool::*; mod selection; pub use self::selection::*; mod track; pub use self::track::*; diff --git a/crates/app/src/view.rs b/crates/app/src/view.rs index e0bb9340..9333d10a 100644 --- a/crates/app/src/view.rs +++ b/crates/app/src/view.rs @@ -927,353 +927,3 @@ content!(TuiOut: |self: ClipLength| { Some(Tick) => row!(" ", bars(), ".", beats(), "[", ticks()), } }); - -/// A clip, rendered as a horizontal piano roll. -#[derive(Clone)] -pub struct PianoHorizontal { - pub clip: Option>>, - /// Buffer where the whole clip is rerendered on change - pub buffer: Arc>, - /// Size of actual notes area - pub size: Measure, - /// The display window - pub range: MidiRangeModel, - /// The note cursor - pub point: MidiPointModel, - /// The highlight color palette - pub color: ItemTheme, - /// Width of the keyboard - pub keys_width: u16, -} - -impl PianoHorizontal { - pub fn new (clip: Option<&Arc>>) -> Self { - let size = Measure::new(); - let mut range = MidiRangeModel::from((12, true)); - range.time_axis = size.x.clone(); - range.note_axis = size.y.clone(); - let piano = Self { - keys_width: 5, - size, - range, - buffer: RwLock::new(Default::default()).into(), - point: MidiPointModel::default(), - clip: clip.cloned(), - color: clip.as_ref().map(|p|p.read().unwrap().color).unwrap_or(ItemTheme::G[64]), - }; - piano.redraw(); - piano - } -} - -pub(crate) fn note_y_iter (note_lo: usize, note_hi: usize, y0: u16) - -> impl Iterator -{ - (note_lo..=note_hi).rev().enumerate().map(move|(y, n)|(y, y0 + y as u16, n)) -} - -content!(TuiOut:|self: PianoHorizontal| Tui::bg(Tui::g(40), Bsp::s( - Bsp::e( - Fixed::x(5, format!("{}x{}", self.size.w(), self.size.h())), - self.timeline() - ), - Bsp::e( - self.keys(), - self.size.of(Tui::bg(Tui::g(32), Bsp::b( - Fill::xy(self.notes()), - Fill::xy(self.cursor()), - ))) - ), -))); - -impl PianoHorizontal { - /// Draw the piano roll background. - /// - /// This mode uses full blocks on note on and half blocks on legato: █▄ █▄ █▄ - fn draw_bg (buf: &mut BigBuffer, clip: &MidiClip, zoom: usize, note_len: usize) { - for (y, note) in (0..=127).rev().enumerate() { - for (x, time) in (0..buf.width).map(|x|(x, x*zoom)) { - let cell = buf.get_mut(x, y).unwrap(); - cell.set_bg(clip.color.darkest.rgb); - if time % 384 == 0 { - cell.set_fg(clip.color.darker.rgb); - cell.set_char('│'); - } else if time % 96 == 0 { - cell.set_fg(clip.color.dark.rgb); - cell.set_char('╎'); - } else if time % note_len == 0 { - cell.set_fg(clip.color.darker.rgb); - cell.set_char('┊'); - } else if (127 - note) % 12 == 0 { - cell.set_fg(clip.color.darker.rgb); - cell.set_char('='); - } else if (127 - note) % 6 == 0 { - cell.set_fg(clip.color.darker.rgb); - cell.set_char('—'); - } else { - cell.set_fg(clip.color.darker.rgb); - cell.set_char('·'); - } - } - } - } - /// Draw the piano roll foreground. - /// - /// This mode uses full blocks on note on and half blocks on legato: █▄ █▄ █▄ - fn draw_fg (buf: &mut BigBuffer, clip: &MidiClip, zoom: usize) { - let style = Style::default().fg(clip.color.base.rgb);//.bg(Rgb(0, 0, 0)); - let mut notes_on = [false;128]; - for (x, time_start) in (0..clip.length).step_by(zoom).enumerate() { - for (_y, note) in (0..=127).rev().enumerate() { - if let Some(cell) = buf.get_mut(x, note) { - if notes_on[note] { - cell.set_char('▂'); - cell.set_style(style); - } - } - } - let time_end = time_start + zoom; - for time in time_start..time_end.min(clip.length) { - for event in clip.notes[time].iter() { - match event { - MidiMessage::NoteOn { key, .. } => { - let note = key.as_int() as usize; - if let Some(cell) = buf.get_mut(x, note) { - cell.set_char('█'); - cell.set_style(style); - } - notes_on[note] = true - }, - MidiMessage::NoteOff { key, .. } => { - notes_on[key.as_int() as usize] = false - }, - _ => {} - } - } - } - - } - } - fn notes (&self) -> impl Content { - let time_start = self.get_time_start(); - let note_lo = self.get_note_lo(); - let note_hi = self.get_note_hi(); - let buffer = self.buffer.clone(); - ThunkRender::new(move|to: &mut TuiOut|{ - let source = buffer.read().unwrap(); - let [x0, y0, w, _h] = to.area().xywh(); - //if h as usize != note_axis { - //panic!("area height mismatch: {h} <> {note_axis}"); - //} - for (area_x, screen_x) in (x0..x0+w).enumerate() { - for (area_y, screen_y, _note) in note_y_iter(note_lo, note_hi, y0) { - let source_x = time_start + area_x; - let source_y = note_hi - area_y; - // TODO: enable loop rollover: - //let source_x = (time_start + area_x) % source.width.max(1); - //let source_y = (note_hi - area_y) % source.height.max(1); - let is_in_x = source_x < source.width; - let is_in_y = source_y < source.height; - if is_in_x && is_in_y { - if let Some(source_cell) = source.get(source_x, source_y) { - if let Some(cell) = to.buffer.cell_mut(ratatui::prelude::Position::from((screen_x, screen_y))) { - *cell = source_cell.clone(); - } - } - } - } - } - }) - } - fn cursor (&self) -> impl Content { - let note_hi = self.get_note_hi(); - let note_lo = self.get_note_lo(); - let note_pos = self.get_note_pos(); - let note_len = self.get_note_len(); - let time_pos = self.get_time_pos(); - let time_start = self.get_time_start(); - let time_zoom = self.get_time_zoom(); - let style = Some(Style::default().fg(self.color.lightest.rgb)); - ThunkRender::new(move|to: &mut TuiOut|{ - let [x0, y0, w, _] = to.area().xywh(); - for (_area_y, screen_y, note) in note_y_iter(note_lo, note_hi, y0) { - if note == note_pos { - for x in 0..w { - let screen_x = x0 + x; - let time_1 = time_start + x as usize * time_zoom; - let time_2 = time_1 + time_zoom; - if time_1 <= time_pos && time_pos < time_2 { - to.blit(&"█", screen_x, screen_y, style); - let tail = note_len as u16 / time_zoom as u16; - for x_tail in (screen_x + 1)..(screen_x + tail) { - to.blit(&"▂", x_tail, screen_y, style); - } - break - } - } - break - } - } - }) - } - fn keys (&self) -> impl Content { - let state = self; - let color = state.color; - let note_lo = state.get_note_lo(); - let note_hi = state.get_note_hi(); - let note_pos = state.get_note_pos(); - let key_style = Some(Style::default().fg(Rgb(192, 192, 192)).bg(Rgb(0, 0, 0))); - let off_style = Some(Style::default().fg(Tui::g(255))); - let on_style = Some(Style::default().fg(Rgb(255,0,0)).bg(color.base.rgb).bold()); - Fill::y(Fixed::x(self.keys_width, ThunkRender::new(move|to: &mut TuiOut|{ - let [x, y0, _w, _h] = to.area().xywh(); - for (_area_y, screen_y, note) in note_y_iter(note_lo, note_hi, y0) { - to.blit(&to_key(note), x, screen_y, key_style); - if note > 127 { - continue - } - if note == note_pos { - to.blit(&format!("{:<5}", Note::pitch_to_name(note)), x, screen_y, on_style) - } else { - to.blit(&Note::pitch_to_name(note), x, screen_y, off_style) - }; - } - }))) - } - fn timeline (&self) -> impl Content + '_ { - Fill::x(Fixed::y(1, ThunkRender::new(move|to: &mut TuiOut|{ - let [x, y, w, _h] = to.area(); - let style = Some(Style::default().dim()); - let length = self.clip.as_ref().map(|p|p.read().unwrap().length).unwrap_or(1); - for (area_x, screen_x) in (0..w).map(|d|(d, d+x)) { - let t = area_x as usize * self.time_zoom().get(); - if t < length { - to.blit(&"|", screen_x, y, style); - } - } - }))) - } -} - -has_size!(|self:PianoHorizontal|&self.size); - -impl TimeRange for PianoHorizontal { - fn time_len (&self) -> &AtomicUsize { self.range.time_len() } - fn time_zoom (&self) -> &AtomicUsize { self.range.time_zoom() } - fn time_lock (&self) -> &AtomicBool { self.range.time_lock() } - fn time_start (&self) -> &AtomicUsize { self.range.time_start() } - fn time_axis (&self) -> &AtomicUsize { self.range.time_axis() } -} - -impl NoteRange for PianoHorizontal { - fn note_lo (&self) -> &AtomicUsize { self.range.note_lo() } - fn note_axis (&self) -> &AtomicUsize { self.range.note_axis() } -} - -impl NotePoint for PianoHorizontal { - fn note_len (&self) -> &AtomicUsize { self.point.note_len() } - fn note_pos (&self) -> &AtomicUsize { self.point.note_pos() } -} - -impl TimePoint for PianoHorizontal { - fn time_pos (&self) -> &AtomicUsize { self.point.time_pos() } -} - -impl MidiViewer for PianoHorizontal { - fn clip (&self) -> &Option>> { - &self.clip - } - fn clip_mut (&mut self) -> &mut Option>> { - &mut self.clip - } - /// Determine the required space to render the clip. - fn buffer_size (&self, clip: &MidiClip) -> (usize, usize) { - (clip.length / self.range.time_zoom().get(), 128) - } - fn redraw (&self) { - *self.buffer.write().unwrap() = if let Some(clip) = self.clip.as_ref() { - let clip = clip.read().unwrap(); - let buf_size = self.buffer_size(&clip); - let mut buffer = BigBuffer::from(buf_size); - let note_len = self.get_note_len(); - let time_zoom = self.get_time_zoom(); - self.time_len().set(clip.length); - PianoHorizontal::draw_bg(&mut buffer, &clip, time_zoom, note_len); - PianoHorizontal::draw_fg(&mut buffer, &clip, time_zoom); - buffer - } else { - Default::default() - } - } - fn set_clip (&mut self, clip: Option<&Arc>>) { - *self.clip_mut() = clip.cloned(); - self.color = clip.map(|p|p.read().unwrap().color) - .unwrap_or(ItemTheme::G[64]); - self.redraw(); - } -} - -impl std::fmt::Debug for PianoHorizontal { - fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { - let buffer = self.buffer.read().unwrap(); - f.debug_struct("PianoHorizontal") - .field("time_zoom", &self.range.time_zoom) - .field("buffer", &format!("{}x{}", buffer.width, buffer.height)) - .finish() - } -} - // Update sequencer playhead indicator - //self.now().set(0.); - //if let Some((ref started_at, Some(ref playing))) = self.sequencer.play_clip { - //let clip = clip.read().unwrap(); - //if *playing.read().unwrap() == *clip { - //let pulse = self.current().pulse.get(); - //let start = started_at.pulse.get(); - //let now = (pulse - start) % clip.length as f64; - //self.now().set(now); - //} - //} - -fn to_key (note: usize) -> &'static str { - match note % 12 { - 11 | 9 | 7 | 5 | 4 | 2 | 0 => "████▌", - 10 | 8 | 6 | 3 | 1 => " ", - _ => unreachable!(), - } -} - -pub struct OctaveVertical { - on: [bool; 12], - colors: [Color; 3] -} - -impl Default for OctaveVertical { - fn default () -> Self { - Self { - on: [false; 12], - colors: [Rgb(255,255,255), Rgb(0,0,0), Rgb(255,0,0)] - } - } -} - -impl OctaveVertical { - fn color (&self, pitch: usize) -> Color { - let pitch = pitch % 12; - self.colors[if self.on[pitch] { 2 } else { - match pitch { 0 | 2 | 4 | 5 | 6 | 8 | 10 => 0, _ => 1 } - }] - } -} - -impl Content for OctaveVertical { - fn content (&self) -> impl Render { - row!( - Tui::fg_bg(self.color(0), self.color(1), "▙"), - Tui::fg_bg(self.color(2), self.color(3), "▙"), - Tui::fg_bg(self.color(4), self.color(5), "▌"), - Tui::fg_bg(self.color(6), self.color(7), "▟"), - Tui::fg_bg(self.color(8), self.color(9), "▟"), - Tui::fg_bg(self.color(10), self.color(11), "▟"), - ) - } -} diff --git a/crates/device/Cargo.toml b/crates/device/Cargo.toml index f07255a4..aacae057 100644 --- a/crates/device/Cargo.toml +++ b/crates/device/Cargo.toml @@ -16,10 +16,11 @@ wavers = { workspace = true, optional = true } winit = { workspace = true, optional = true } [features] -default = [ "clock", "sequencer", "sampler", "lv2" ] +default = [ "clock", "editor", "sequencer", "sampler", "lv2" ] clock = [] -sampler = [ "symphonia", "wavers" ] +editor = [] sequencer = [ "clock", "uuid" ] +sampler = [ "symphonia", "wavers" ] lv2 = [ "livi", "winit" ] vst2 = [] vst3 = [] diff --git a/crates/device/src/device.rs b/crates/device/src/device.rs index 60ecaf1a..ee6a404f 100644 --- a/crates/device/src/device.rs +++ b/crates/device/src/device.rs @@ -2,13 +2,18 @@ use crate::*; #[derive(Debug)] pub enum Device { - #[cfg(feature = "sequencer")] Sequencer(Sequencer), - #[cfg(feature = "sampler")] Sampler(Sampler), - #[cfg(feature = "lv2")] Lv2(Lv2), // TODO - #[cfg(feature = "vst2")] Vst2, // TODO - #[cfg(feature = "vst3")] Vst3, // TODO - #[cfg(feature = "clap")] Clap, // TODO - #[cfg(feature = "sf2")] Sf2, // TODO + #[cfg(feature = "sampler")] + Sampler(Sampler), + #[cfg(feature = "lv2")] // TODO + Lv2(Lv2), + #[cfg(feature = "vst2")] // TODO + Vst2, + #[cfg(feature = "vst3")] // TODO + Vst3, + #[cfg(feature = "clap")] // TODO + Clap, + #[cfg(feature = "sf2")] // TODO + Sf2, } impl Device { @@ -25,19 +30,22 @@ pub struct DeviceAudio<'a>(pub &'a mut Device); audio!(|self: DeviceAudio<'a>, client, scope|{ use Device::*; match self.0 { - #[cfg(feature = "sequencer")] Sequencer(sequencer) => - { Control::Continue /* TODO */ }, - #[cfg(feature = "sampler")] Sampler(sampler) => - SamplerAudio(sampler).process(client, scope), - #[cfg(feature = "lv2")] Lv2(lv2) => - { todo!() }, // TODO - #[cfg(feature = "vst2")] Vst2 => - { todo!() }, // TODO - #[cfg(feature = "vst3")] Vst3 => - { todo!() }, // TODO - #[cfg(feature = "clap")] Clap => - { todo!() }, // TODO - #[cfg(feature = "sf2")] Sf2 => - { todo!() }, // TODO + #[cfg(feature = "sampler")] + Sampler(sampler) => SamplerAudio(sampler).process(client, scope), + + #[cfg(feature = "lv2")] + Lv2(lv2) => lv2.process(client, scope), + + #[cfg(feature = "vst2")] + Vst2 => { todo!() }, // TODO + + #[cfg(feature = "vst3")] + Vst3 => { todo!() }, // TODO + + #[cfg(feature = "clap")] + Clap => { todo!() }, // TODO + + #[cfg(feature = "sf2")] + Sf2 => { todo!() }, // TODO } }); diff --git a/crates/device/src/editor.rs b/crates/device/src/editor.rs new file mode 100644 index 00000000..eb81e5b9 --- /dev/null +++ b/crates/device/src/editor.rs @@ -0,0 +1,7 @@ +use crate::*; + +mod editor_api; pub use self::editor_api::*; +mod editor_model; pub use self::editor_model::*; +mod editor_view; pub use self::editor_view::*; +mod editor_view_h; pub use self::editor_view_h::*; +mod editor_view_v; pub use self::editor_view_v::*; diff --git a/crates/device/src/editor/editor_api.rs b/crates/device/src/editor/editor_api.rs new file mode 100644 index 00000000..d2cee7db --- /dev/null +++ b/crates/device/src/editor/editor_api.rs @@ -0,0 +1,126 @@ +use crate::*; + +#[tengri_proc::expose] impl MidiEditor { + fn _todo_opt_clip_stub (&self) -> Option>> { + todo!() + } + fn time_lock (&self) -> bool { + self.get_time_lock() + } + fn time_lock_toggled (&self) -> bool { + !self.get_time_lock() + } + + fn note_length (&self) -> usize { + self.get_note_len() + } + + fn note_pos (&self) -> usize { + self.get_note_pos() + } + fn note_pos_next (&self) -> usize { + self.get_note_pos() + 1 + } + fn note_pos_next_octave (&self) -> usize { + self.get_note_pos() + 12 + } + fn note_pos_prev (&self) -> usize { + self.get_note_pos().saturating_sub(1) + } + fn note_pos_prev_octave (&self) -> usize { + self.get_note_pos().saturating_sub(12) + } + + fn note_len (&self) -> usize { + self.get_note_len() + } + fn note_len_next (&self) -> usize { + self.get_note_len() + 1 + } + fn note_len_prev (&self) -> usize { + self.get_note_len().saturating_sub(1) + } + + fn note_range (&self) -> usize { + self.get_note_axis() + } + fn note_range_next (&self) -> usize { + self.get_note_axis() + 1 + } + fn note_range_prev (&self) -> usize { + self.get_note_axis().saturating_sub(1) + } + + fn time_pos (&self) -> usize { + self.get_time_pos() + } + fn time_pos_next (&self) -> usize { + self.get_time_pos() + self.time_zoom() + } + fn time_pos_prev (&self) -> usize { + self.get_time_pos().saturating_sub(self.time_zoom()) + } + + fn time_zoom (&self) -> usize { + self.get_time_zoom() + } + fn time_zoom_next (&self) -> usize { + self.get_time_zoom() + 1 + } + fn time_zoom_prev (&self) -> usize { + self.get_time_zoom().saturating_sub(1).max(1) + } +} + +#[tengri_proc::command(MidiEditor)] impl MidiEditCommand { + // TODO: 1-9 seek markers that by default start every 8th of the clip + fn note_append (editor: &mut MidiEditor) -> Perhaps { + editor.put_note(true); + Ok(None) + } + fn note_put (editor: &mut MidiEditor) -> Perhaps { + editor.put_note(false); + Ok(None) + } + fn note_del (editor: &mut MidiEditor) -> Perhaps { + todo!() + } + fn note_pos (editor: &mut MidiEditor, pos: usize) -> Perhaps { + editor.set_note_pos(pos.min(127)); + Ok(None) + } + fn note_len (editor: &mut MidiEditor, value: usize) -> Perhaps { + //let note_len = editor.get_note_len(); + //let time_zoom = editor.get_time_zoom(); + editor.set_note_len(value); + //if note_len / time_zoom != x / time_zoom { + editor.redraw(); + //} + Ok(None) + } + fn note_scroll (editor: &mut MidiEditor, value: usize) -> Perhaps { + editor.set_note_lo(value.min(127)); + Ok(None) + } + fn time_pos (editor: &mut MidiEditor, value: usize) -> Perhaps { + editor.set_time_pos(value); + Ok(None) + } + fn time_scroll (editor: &mut MidiEditor, value: usize) -> Perhaps { + editor.set_time_start(value); + Ok(None) + } + fn time_zoom (editor: &mut MidiEditor, value: usize) -> Perhaps { + editor.set_time_zoom(value); + editor.redraw(); + Ok(None) + } + fn time_lock (editor: &mut MidiEditor, value: bool) -> Perhaps { + editor.set_time_lock(value); + Ok(None) + } + fn show (editor: &mut MidiEditor, clip: Option>>) -> Perhaps { + editor.set_clip(clip.as_ref()); + Ok(None) + } +} diff --git a/crates/app/src/model/editor.rs b/crates/device/src/editor/editor_model.rs similarity index 97% rename from crates/app/src/model/editor.rs rename to crates/device/src/editor/editor_model.rs index fec878b1..5c648b70 100644 --- a/crates/app/src/model/editor.rs +++ b/crates/device/src/editor/editor_model.rs @@ -25,15 +25,6 @@ impl Default for MidiEditor { } } - -has_size!(|self: MidiEditor|&self.size); - -content!(TuiOut: |self: MidiEditor| { - self.autoscroll(); - //self.autozoom(); - self.size.of(&self.mode) -}); - from!(|clip: &Arc>|MidiEditor = { let model = Self::from(Some(clip.clone())); model.redraw(); @@ -166,3 +157,4 @@ pub trait HasEditor { } }; } + diff --git a/crates/device/src/editor/editor_view.rs b/crates/device/src/editor/editor_view.rs new file mode 100644 index 00000000..9a7e4f48 --- /dev/null +++ b/crates/device/src/editor/editor_view.rs @@ -0,0 +1,9 @@ +use crate::*; + +has_size!(|self: MidiEditor|&self.size); + +content!(TuiOut: |self: MidiEditor| { + self.autoscroll(); + //self.autozoom(); + self.size.of(&self.mode) +}); diff --git a/crates/device/src/editor/editor_view_h.rs b/crates/device/src/editor/editor_view_h.rs new file mode 100644 index 00000000..72365a09 --- /dev/null +++ b/crates/device/src/editor/editor_view_h.rs @@ -0,0 +1,315 @@ +use crate::*; + +/// A clip, rendered as a horizontal piano roll. +#[derive(Clone)] +pub struct PianoHorizontal { + pub clip: Option>>, + /// Buffer where the whole clip is rerendered on change + pub buffer: Arc>, + /// Size of actual notes area + pub size: Measure, + /// The display window + pub range: MidiRangeModel, + /// The note cursor + pub point: MidiPointModel, + /// The highlight color palette + pub color: ItemTheme, + /// Width of the keyboard + pub keys_width: u16, +} + +impl PianoHorizontal { + pub fn new (clip: Option<&Arc>>) -> Self { + let size = Measure::new(); + let mut range = MidiRangeModel::from((12, true)); + range.time_axis = size.x.clone(); + range.note_axis = size.y.clone(); + let piano = Self { + keys_width: 5, + size, + range, + buffer: RwLock::new(Default::default()).into(), + point: MidiPointModel::default(), + clip: clip.cloned(), + color: clip.as_ref().map(|p|p.read().unwrap().color).unwrap_or(ItemTheme::G[64]), + }; + piano.redraw(); + piano + } +} + +pub(crate) fn note_y_iter (note_lo: usize, note_hi: usize, y0: u16) + -> impl Iterator +{ + (note_lo..=note_hi).rev().enumerate().map(move|(y, n)|(y, y0 + y as u16, n)) +} + +content!(TuiOut:|self: PianoHorizontal| Tui::bg(Tui::g(40), Bsp::s( + Bsp::e( + Fixed::x(5, format!("{}x{}", self.size.w(), self.size.h())), + self.timeline() + ), + Bsp::e( + self.keys(), + self.size.of(Tui::bg(Tui::g(32), Bsp::b( + Fill::xy(self.notes()), + Fill::xy(self.cursor()), + ))) + ), +))); + +impl PianoHorizontal { + /// Draw the piano roll background. + /// + /// This mode uses full blocks on note on and half blocks on legato: █▄ █▄ █▄ + fn draw_bg (buf: &mut BigBuffer, clip: &MidiClip, zoom: usize, note_len: usize) { + for (y, note) in (0..=127).rev().enumerate() { + for (x, time) in (0..buf.width).map(|x|(x, x*zoom)) { + let cell = buf.get_mut(x, y).unwrap(); + cell.set_bg(clip.color.darkest.rgb); + if time % 384 == 0 { + cell.set_fg(clip.color.darker.rgb); + cell.set_char('│'); + } else if time % 96 == 0 { + cell.set_fg(clip.color.dark.rgb); + cell.set_char('╎'); + } else if time % note_len == 0 { + cell.set_fg(clip.color.darker.rgb); + cell.set_char('┊'); + } else if (127 - note) % 12 == 0 { + cell.set_fg(clip.color.darker.rgb); + cell.set_char('='); + } else if (127 - note) % 6 == 0 { + cell.set_fg(clip.color.darker.rgb); + cell.set_char('—'); + } else { + cell.set_fg(clip.color.darker.rgb); + cell.set_char('·'); + } + } + } + } + /// Draw the piano roll foreground. + /// + /// This mode uses full blocks on note on and half blocks on legato: █▄ █▄ █▄ + fn draw_fg (buf: &mut BigBuffer, clip: &MidiClip, zoom: usize) { + let style = Style::default().fg(clip.color.base.rgb);//.bg(Rgb(0, 0, 0)); + let mut notes_on = [false;128]; + for (x, time_start) in (0..clip.length).step_by(zoom).enumerate() { + for (_y, note) in (0..=127).rev().enumerate() { + if let Some(cell) = buf.get_mut(x, note) { + if notes_on[note] { + cell.set_char('▂'); + cell.set_style(style); + } + } + } + let time_end = time_start + zoom; + for time in time_start..time_end.min(clip.length) { + for event in clip.notes[time].iter() { + match event { + MidiMessage::NoteOn { key, .. } => { + let note = key.as_int() as usize; + if let Some(cell) = buf.get_mut(x, note) { + cell.set_char('█'); + cell.set_style(style); + } + notes_on[note] = true + }, + MidiMessage::NoteOff { key, .. } => { + notes_on[key.as_int() as usize] = false + }, + _ => {} + } + } + } + + } + } + fn notes (&self) -> impl Content { + let time_start = self.get_time_start(); + let note_lo = self.get_note_lo(); + let note_hi = self.get_note_hi(); + let buffer = self.buffer.clone(); + ThunkRender::new(move|to: &mut TuiOut|{ + let source = buffer.read().unwrap(); + let [x0, y0, w, _h] = to.area().xywh(); + //if h as usize != note_axis { + //panic!("area height mismatch: {h} <> {note_axis}"); + //} + for (area_x, screen_x) in (x0..x0+w).enumerate() { + for (area_y, screen_y, _note) in note_y_iter(note_lo, note_hi, y0) { + let source_x = time_start + area_x; + let source_y = note_hi - area_y; + // TODO: enable loop rollover: + //let source_x = (time_start + area_x) % source.width.max(1); + //let source_y = (note_hi - area_y) % source.height.max(1); + let is_in_x = source_x < source.width; + let is_in_y = source_y < source.height; + if is_in_x && is_in_y { + if let Some(source_cell) = source.get(source_x, source_y) { + if let Some(cell) = to.buffer.cell_mut(ratatui::prelude::Position::from((screen_x, screen_y))) { + *cell = source_cell.clone(); + } + } + } + } + } + }) + } + fn cursor (&self) -> impl Content { + let note_hi = self.get_note_hi(); + let note_lo = self.get_note_lo(); + let note_pos = self.get_note_pos(); + let note_len = self.get_note_len(); + let time_pos = self.get_time_pos(); + let time_start = self.get_time_start(); + let time_zoom = self.get_time_zoom(); + let style = Some(Style::default().fg(self.color.lightest.rgb)); + ThunkRender::new(move|to: &mut TuiOut|{ + let [x0, y0, w, _] = to.area().xywh(); + for (_area_y, screen_y, note) in note_y_iter(note_lo, note_hi, y0) { + if note == note_pos { + for x in 0..w { + let screen_x = x0 + x; + let time_1 = time_start + x as usize * time_zoom; + let time_2 = time_1 + time_zoom; + if time_1 <= time_pos && time_pos < time_2 { + to.blit(&"█", screen_x, screen_y, style); + let tail = note_len as u16 / time_zoom as u16; + for x_tail in (screen_x + 1)..(screen_x + tail) { + to.blit(&"▂", x_tail, screen_y, style); + } + break + } + } + break + } + } + }) + } + fn keys (&self) -> impl Content { + let state = self; + let color = state.color; + let note_lo = state.get_note_lo(); + let note_hi = state.get_note_hi(); + let note_pos = state.get_note_pos(); + let key_style = Some(Style::default().fg(Rgb(192, 192, 192)).bg(Rgb(0, 0, 0))); + let off_style = Some(Style::default().fg(Tui::g(255))); + let on_style = Some(Style::default().fg(Rgb(255,0,0)).bg(color.base.rgb).bold()); + Fill::y(Fixed::x(self.keys_width, ThunkRender::new(move|to: &mut TuiOut|{ + let [x, y0, _w, _h] = to.area().xywh(); + for (_area_y, screen_y, note) in note_y_iter(note_lo, note_hi, y0) { + to.blit(&to_key(note), x, screen_y, key_style); + if note > 127 { + continue + } + if note == note_pos { + to.blit(&format!("{:<5}", Note::pitch_to_name(note)), x, screen_y, on_style) + } else { + to.blit(&Note::pitch_to_name(note), x, screen_y, off_style) + }; + } + }))) + } + fn timeline (&self) -> impl Content + '_ { + Fill::x(Fixed::y(1, ThunkRender::new(move|to: &mut TuiOut|{ + let [x, y, w, _h] = to.area(); + let style = Some(Style::default().dim()); + let length = self.clip.as_ref().map(|p|p.read().unwrap().length).unwrap_or(1); + for (area_x, screen_x) in (0..w).map(|d|(d, d+x)) { + let t = area_x as usize * self.time_zoom().get(); + if t < length { + to.blit(&"|", screen_x, y, style); + } + } + }))) + } +} + +has_size!(|self:PianoHorizontal|&self.size); + +impl TimeRange for PianoHorizontal { + fn time_len (&self) -> &AtomicUsize { self.range.time_len() } + fn time_zoom (&self) -> &AtomicUsize { self.range.time_zoom() } + fn time_lock (&self) -> &AtomicBool { self.range.time_lock() } + fn time_start (&self) -> &AtomicUsize { self.range.time_start() } + fn time_axis (&self) -> &AtomicUsize { self.range.time_axis() } +} + +impl NoteRange for PianoHorizontal { + fn note_lo (&self) -> &AtomicUsize { self.range.note_lo() } + fn note_axis (&self) -> &AtomicUsize { self.range.note_axis() } +} + +impl NotePoint for PianoHorizontal { + fn note_len (&self) -> &AtomicUsize { self.point.note_len() } + fn note_pos (&self) -> &AtomicUsize { self.point.note_pos() } +} + +impl TimePoint for PianoHorizontal { + fn time_pos (&self) -> &AtomicUsize { self.point.time_pos() } +} + +impl MidiViewer for PianoHorizontal { + fn clip (&self) -> &Option>> { + &self.clip + } + fn clip_mut (&mut self) -> &mut Option>> { + &mut self.clip + } + /// Determine the required space to render the clip. + fn buffer_size (&self, clip: &MidiClip) -> (usize, usize) { + (clip.length / self.range.time_zoom().get(), 128) + } + fn redraw (&self) { + *self.buffer.write().unwrap() = if let Some(clip) = self.clip.as_ref() { + let clip = clip.read().unwrap(); + let buf_size = self.buffer_size(&clip); + let mut buffer = BigBuffer::from(buf_size); + let note_len = self.get_note_len(); + let time_zoom = self.get_time_zoom(); + self.time_len().set(clip.length); + PianoHorizontal::draw_bg(&mut buffer, &clip, time_zoom, note_len); + PianoHorizontal::draw_fg(&mut buffer, &clip, time_zoom); + buffer + } else { + Default::default() + } + } + fn set_clip (&mut self, clip: Option<&Arc>>) { + *self.clip_mut() = clip.cloned(); + self.color = clip.map(|p|p.read().unwrap().color) + .unwrap_or(ItemTheme::G[64]); + self.redraw(); + } +} + +impl std::fmt::Debug for PianoHorizontal { + fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + let buffer = self.buffer.read().unwrap(); + f.debug_struct("PianoHorizontal") + .field("time_zoom", &self.range.time_zoom) + .field("buffer", &format!("{}x{}", buffer.width, buffer.height)) + .finish() + } +} + // Update sequencer playhead indicator + //self.now().set(0.); + //if let Some((ref started_at, Some(ref playing))) = self.sequencer.play_clip { + //let clip = clip.read().unwrap(); + //if *playing.read().unwrap() == *clip { + //let pulse = self.current().pulse.get(); + //let start = started_at.pulse.get(); + //let now = (pulse - start) % clip.length as f64; + //self.now().set(now); + //} + //} + +fn to_key (note: usize) -> &'static str { + match note % 12 { + 11 | 9 | 7 | 5 | 4 | 2 | 0 => "████▌", + 10 | 8 | 6 | 3 | 1 => " ", + _ => unreachable!(), + } +} diff --git a/crates/device/src/editor/editor_view_v.rs b/crates/device/src/editor/editor_view_v.rs new file mode 100644 index 00000000..493c6de9 --- /dev/null +++ b/crates/device/src/editor/editor_view_v.rs @@ -0,0 +1,37 @@ +use crate::*; + +pub struct OctaveVertical { + on: [bool; 12], + colors: [Color; 3] +} + +impl Default for OctaveVertical { + fn default () -> Self { + Self { + on: [false; 12], + colors: [Rgb(255,255,255), Rgb(0,0,0), Rgb(255,0,0)] + } + } +} + +impl OctaveVertical { + fn color (&self, pitch: usize) -> Color { + let pitch = pitch % 12; + self.colors[if self.on[pitch] { 2 } else { + match pitch { 0 | 2 | 4 | 5 | 6 | 8 | 10 => 0, _ => 1 } + }] + } +} + +impl Content for OctaveVertical { + fn content (&self) -> impl Render { + row!( + Tui::fg_bg(self.color(0), self.color(1), "▙"), + Tui::fg_bg(self.color(2), self.color(3), "▙"), + Tui::fg_bg(self.color(4), self.color(5), "▌"), + Tui::fg_bg(self.color(6), self.color(7), "▟"), + Tui::fg_bg(self.color(8), self.color(9), "▟"), + Tui::fg_bg(self.color(10), self.color(11), "▟"), + ) + } +} diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 16f34bf2..88c4cd6f 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -3,7 +3,8 @@ pub(crate) use std::cmp::Ord; pub(crate) use std::fmt::{Debug, Formatter}; pub(crate) use std::thread::JoinHandle; -pub(crate) use std::sync::{Arc, RwLock, atomic::{AtomicUsize, Ordering::Relaxed}}; +pub(crate) use std::sync::{Arc, RwLock}; +pub(crate) use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; pub(crate) use std::fs::File; pub(crate) use std::path::PathBuf; pub(crate) use std::error::Error; @@ -14,6 +15,7 @@ pub(crate) use ::tek_engine::*; pub(crate) use ::tek_engine::midi::{u7, LiveEvent, MidiMessage}; pub(crate) use ::tek_engine::jack::{Control, ProcessScope, MidiWriter, RawMidi}; pub(crate) use ratatui::{prelude::Rect, widgets::{Widget, canvas::{Canvas, Line}}}; +pub(crate) use Color::*; mod device; pub use self::device::*; @@ -21,6 +23,9 @@ pub use self::device::*; #[cfg(feature = "clock")] mod clock; #[cfg(feature = "clock")] pub use self::clock::*; +#[cfg(feature = "editor")] mod editor; +#[cfg(feature = "editor")] pub use self::editor::*; + #[cfg(feature = "sequencer")] mod sequencer; #[cfg(feature = "sequencer")] pub use self::sequencer::*;