diff --git a/crates/tek/src/api.rs b/crates/tek/src/api.rs index a3c2e9ca..c4047c4c 100644 --- a/crates/tek/src/api.rs +++ b/crates/tek/src/api.rs @@ -3,7 +3,6 @@ use crate::*; mod phrase; pub(crate) use phrase::*; mod jack; pub(crate) use self::jack::*; mod clip; pub(crate) use clip::*; -mod color; pub(crate) use color::*; mod clock; pub(crate) use clock::*; mod player; pub(crate) use player::*; mod scene; pub(crate) use scene::*; diff --git a/crates/tek/src/api/color.rs b/crates/tek/src/api/color.rs deleted file mode 100644 index c23e6ba7..00000000 --- a/crates/tek/src/api/color.rs +++ /dev/null @@ -1,6 +0,0 @@ -use crate::*; - -pub trait HasColor { - fn color (&self) -> ItemColor; - fn color_mut (&self) -> &mut ItemColor; -} diff --git a/crates/tek/src/api/player.rs b/crates/tek/src/api/player.rs index a536716b..f1a6809e 100644 --- a/crates/tek/src/api/player.rs +++ b/crates/tek/src/api/player.rs @@ -25,7 +25,8 @@ pub trait HasPlayPhrase: HasClock { fn pulses_since_start_looped (&self) -> Option { if let Some((started, Some(phrase))) = self.play_phrase().as_ref() { let elapsed = self.clock().playhead.pulse.get() - started.pulse.get(); - let elapsed = (elapsed as usize % phrase.read().unwrap().length) as f64; + let length = phrase.read().unwrap().length.max(1); // prevent div0 on empty phrase + let elapsed = (elapsed as usize % length) as f64; Some(elapsed) } else { None diff --git a/crates/tek/src/core/color.rs b/crates/tek/src/core/color.rs index a86ac49f..99b9a48c 100644 --- a/crates/tek/src/core/color.rs +++ b/crates/tek/src/core/color.rs @@ -2,6 +2,11 @@ use crate::*; use rand::{thread_rng, distributions::uniform::UniformSampler}; pub use ratatui::prelude::Color; +pub trait HasColor { + fn color (&self) -> ItemColor; + fn color_mut (&self) -> &mut ItemColor; +} + /// A color in OKHSL and RGB representations. #[derive(Debug, Default, Copy, Clone, PartialEq)] pub struct ItemColor { @@ -48,15 +53,19 @@ impl ItemColor { self.okhsl.mix(other.okhsl, distance).into() } } +impl From for ItemPalette { + fn from (base: Color) -> Self { + Self::from(ItemColor::from(base)) + } +} impl From for ItemPalette { - fn from (base: ItemColor) -> Self { let mut light = base.okhsl.clone(); - light.lightness = (light.lightness * 1.2).min(Okhsl::::max_lightness()); + light.lightness = (light.lightness * 1.33).min(Okhsl::::max_lightness()); let mut lighter = light.clone(); - lighter.lightness = (lighter.lightness * 1.2).min(Okhsl::::max_lightness()); + lighter.lightness = (lighter.lightness * 1.33).min(Okhsl::::max_lightness()); let mut lightest = lighter.clone(); - lightest.lightness = (lightest.lightness * 1.2).min(Okhsl::::max_lightness()); + lightest.lightness = (lightest.lightness * 1.33).min(Okhsl::::max_lightness()); let mut dark = base.okhsl.clone(); dark.lightness = (dark.lightness * 0.75).max(Okhsl::::min_lightness()); diff --git a/crates/tek/src/tui.rs b/crates/tek/src/tui.rs index 6011ae72..f1072b7d 100644 --- a/crates/tek/src/tui.rs +++ b/crates/tek/src/tui.rs @@ -9,19 +9,21 @@ mod engine_output; pub(crate) use engine_output::*; mod app_transport; pub(crate) use app_transport::*; mod app_sequencer; pub(crate) use app_sequencer::*; +mod app_groovebox; pub(crate) use app_groovebox::*; mod app_arranger; pub(crate) use app_arranger::*; //////////////////////////////////////////////////////// -mod status_bar; pub(crate) use status_bar::*; -mod file_browser; pub(crate) use file_browser::*; -mod phrase_editor; pub(crate) use phrase_editor::*; -mod phrase_length; pub(crate) use phrase_length::*; -mod phrase_rename; pub(crate) use phrase_rename::*; -mod phrase_list; pub(crate) use phrase_list::*; -mod phrase_player; pub(crate) use phrase_player::*; -mod phrase_select; pub(crate) use phrase_select::*; -mod port_select; pub(crate) use port_select::*; +mod status_bar; pub(crate) use status_bar::*; +mod file_browser; pub(crate) use file_browser::*; +mod phrase_editor; pub(crate) use phrase_editor::*; +mod piano_horizontal; pub(crate) use piano_horizontal::*; +mod phrase_length; pub(crate) use phrase_length::*; +mod phrase_rename; pub(crate) use phrase_rename::*; +mod phrase_list; pub(crate) use phrase_list::*; +mod phrase_player; pub(crate) use phrase_player::*; +mod phrase_select; pub(crate) use phrase_select::*; +mod port_select; pub(crate) use port_select::*; //////////////////////////////////////////////////////// diff --git a/crates/tek/src/tui/app_sequencer.rs b/crates/tek/src/tui/app_sequencer.rs index db3e575e..0dcf3c32 100644 --- a/crates/tek/src/tui/app_sequencer.rs +++ b/crates/tek/src/tui/app_sequencer.rs @@ -103,7 +103,7 @@ impl Audio for SequencerTui { render!(|self: SequencerTui|lay!([self.size, Tui::split_up(false, 1, Tui::fill_xy(SequencerStatusBar::from(self)), - Tui::split_right(false, 20, + Tui::split_right(false, if self.size.w() > 60 { 20 } else if self.size.w() > 40 { 15 } else { 10 }, Tui::fixed_x(20, Tui::split_down(false, 4, col!([ PhraseSelector::play_phrase(&self.player), PhraseSelector::next_phrase(&self.player), @@ -273,6 +273,10 @@ pub fn to_sequencer_command (state: &SequencerTui, input: &TuiInput) -> Option PhraseEditMode::Scroll, })), + // Enqueue currently edited phrase + key!(Char('q')) => + Enqueue(Some(state.phrases.phrases[state.phrases.phrase.load(Ordering::Relaxed)].clone())), + // E: Toggle between editing currently playing or other phrase key!(Char('e')) => if let Some((_, Some(playing_phrase))) = state.player.play_phrase() { let editing_phrase = state.editor.phrase.as_ref().map(|p|p.read().unwrap().clone()); @@ -302,10 +306,6 @@ pub fn to_sequencer_command (state: &SequencerTui, input: &TuiInput) -> Option Phrases(PhrasesCommand::input_to_command(&state.phrases, input)?), - // Enqueue currently edited phrase - key!(Enter) => - Enqueue(Some(state.phrases.phrases[state.phrases.phrase.load(Ordering::Relaxed)].clone())), - // Delegate to focused control: _ => match state.focus { PhraseEditor => Editor(PhraseCommand::input_to_command(&state.editor, input)?), diff --git a/crates/tek/src/tui/phrase_editor.rs b/crates/tek/src/tui/phrase_editor.rs index 2b033038..9672b742 100644 --- a/crates/tek/src/tui/phrase_editor.rs +++ b/crates/tek/src/tui/phrase_editor.rs @@ -9,26 +9,55 @@ pub trait HasEditor { /// Contains state for viewing and editing a phrase pub struct PhraseEditorModel { /// Phrase being played - pub(crate) phrase: Option>>, - /// Length of note that will be inserted, in pulses - pub(crate) note_len: usize, - /// Notes currently held at input - pub(crate) notes_in: Arc>, - /// Notes currently held at output - pub(crate) notes_out: Arc>, - /// Current position of global playhead - pub(crate) now: Arc, - /// Width and height of notes area at last render - pub(crate) size: Measure, - + pub(crate) phrase: Option>>, + pub(crate) view_mode: Box, + pub(crate) edit_mode: PhraseEditMode, + // Lowest note displayed pub(crate) note_lo: AtomicUsize, + /// Note coordinate of cursor pub(crate) note_point: AtomicUsize, - + /// Length of note that will be inserted, in pulses + pub(crate) note_len: usize, + /// Notes currently held at input + pub(crate) notes_in: Arc>, + /// Notes currently held at output + pub(crate) notes_out: Arc>, + /// Earliest time displayed pub(crate) time_start: AtomicUsize, + /// Time coordinate of cursor pub(crate) time_point: AtomicUsize, + /// Current position of global playhead + pub(crate) now: Arc, + /// Width and height of notes area at last render + pub(crate) size: Measure, +} - pub(crate) edit_mode: PhraseEditMode, - pub(crate) view_mode: Box, +#[derive(Copy, Clone, Debug)] +pub enum PhraseEditMode { + Note, + Scroll, +} + +pub trait PhraseViewMode: Debug + Send + Sync { + fn show (&mut self, phrase: Option<&Phrase>, note_len: usize); + fn time_zoom (&self) -> Option; + fn set_time_zoom (&mut self, time_zoom: Option); + fn buffer_width (&self, phrase: &Phrase) -> usize; + fn buffer_height (&self, phrase: &Phrase) -> usize; + fn render_keys (&self, + to: &mut TuiOutput, color: Color, point: Option, range: (usize, usize)); + fn render_notes (&self, + to: &mut TuiOutput, time_start: usize, note_hi: usize); + fn render_cursor ( + &self, + to: &mut TuiOutput, + time_point: usize, + time_start: usize, + note_point: usize, + note_len: usize, + note_hi: usize, + note_lo: usize, + ); } impl std::fmt::Debug for PhraseEditorModel { @@ -105,6 +134,121 @@ impl PhraseEditorModel { } } +#[derive(Clone, Debug)] +pub enum PhraseCommand { + // TODO: 1-9 seek markers that by default start every 8th of the phrase + AppendNote, + PutNote, + SetNoteCursor(usize), + SetNoteLength(usize), + SetNoteScroll(usize), + SetTimeCursor(usize), + SetTimeScroll(usize), + SetTimeZoom(Option), + Show(Option>>), + SetEditMode(PhraseEditMode), + ToggleDirection, +} + +impl InputToCommand for PhraseCommand { + fn input_to_command (state: &PhraseEditorModel, from: &TuiInput) -> Option { + use PhraseCommand::*; + use KeyCode::{Char, Esc, Up, Down, PageUp, PageDown, Left, Right}; + let note_lo = state.note_lo.load(Ordering::Relaxed); + let note_point = state.note_point.load(Ordering::Relaxed); + let time_start = state.time_start.load(Ordering::Relaxed); + let time_point = state.time_point.load(Ordering::Relaxed); + let time_zoom = state.view_mode.time_zoom(); + let length = state.phrase.as_ref().map(|p|p.read().unwrap().length).unwrap_or(1); + let note_len = state.note_len; + Some(match from.event() { + key!(Char('`')) => ToggleDirection, + key!(Esc) => SetEditMode(PhraseEditMode::Scroll), + key!(Char('z')) => SetTimeZoom(None), + key!(Char('-')) => SetTimeZoom(time_zoom.map(next_note_length)), + key!(Char('_')) => SetTimeZoom(time_zoom.map(next_note_length)), + key!(Char('=')) => SetTimeZoom(time_zoom.map(prev_note_length)), + key!(Char('+')) => SetTimeZoom(time_zoom.map(prev_note_length)), + key!(Char('a')) => AppendNote, + key!(Char('s')) => PutNote, + // TODO: no triplet/dotted + key!(Char(',')) => SetNoteLength(prev_note_length(state.note_len)), + key!(Char('.')) => SetNoteLength(next_note_length(state.note_len)), + // TODO: with triplet/dotted + key!(Char('<')) => SetNoteLength(prev_note_length(state.note_len)), + key!(Char('>')) => SetNoteLength(next_note_length(state.note_len)), + // TODO: '/' set triplet, '?' set dotted + _ => match state.edit_mode { + PhraseEditMode::Scroll => match from.event() { + key!(Char('e')) => SetEditMode(PhraseEditMode::Note), + + key!(Up) => SetNoteScroll(note_lo + 1), + key!(Down) => SetNoteScroll(note_lo.saturating_sub(1)), + key!(PageUp) => SetNoteScroll(note_lo + 3), + key!(PageDown) => SetNoteScroll(note_lo.saturating_sub(3)), + + key!(Left) => SetTimeScroll(time_start.saturating_sub(note_len)), + key!(Right) => SetTimeScroll(time_start + note_len), + key!(Shift-Left) => SetTimeScroll(time_point.saturating_sub(time_zoom.unwrap())), + key!(Shift-Right) => SetTimeScroll((time_point + time_zoom.unwrap()) % length), + _ => return None + }, + PhraseEditMode::Note => match from.event() { + key!(Char('e')) => SetEditMode(PhraseEditMode::Scroll), + + key!(Up) => SetNoteCursor(note_point + 1), + key!(Down) => SetNoteCursor(note_point.saturating_sub(1)), + key!(PageUp) => SetNoteCursor(note_point + 3), + key!(PageDown) => SetNoteCursor(note_point.saturating_sub(3)), + + key!(Shift-Up) => SetNoteScroll(note_lo + 1), + key!(Shift-Down) => SetNoteScroll(note_lo.saturating_sub(1)), + key!(Shift-PageUp) => SetNoteScroll(note_point + 3), + key!(Shift-PageDown) => SetNoteScroll(note_point.saturating_sub(3)), + + key!(Left) => SetTimeCursor(time_point.saturating_sub(note_len)), + key!(Right) => SetTimeCursor((time_point + note_len) % length), + + key!(Shift-Left) => SetTimeCursor(time_point.saturating_sub(time_zoom.unwrap())), + key!(Shift-Right) => SetTimeCursor((time_point + time_zoom.unwrap()) % length), + + _ => return None + }, + } + }) + } +} + +impl Command for PhraseCommand { + fn execute (self, state: &mut PhraseEditorModel) -> Perhaps { + use PhraseCommand::*; + match self { + Show(phrase) => { state.show_phrase(phrase); }, + SetEditMode(mode) => { state.edit_mode = mode; } + PutNote => { state.put_note(); }, + AppendNote => { state.put_note(); + state.time_cursor_advance(); }, + SetTimeZoom(zoom) => { state.view_mode.set_time_zoom(zoom); + state.show_phrase(state.phrase.clone()); }, + SetTimeScroll(time) => { state.time_start.store(time, Ordering::Relaxed); }, + SetTimeCursor(time) => { state.time_point.store(time, Ordering::Relaxed); }, + SetNoteLength(time) => { state.note_len = time; }, + SetNoteScroll(note) => { state.note_lo.store(note, Ordering::Relaxed); }, + SetNoteCursor(note) => { + let note = 127.min(note); + let start = state.note_lo.load(Ordering::Relaxed); + state.note_point.store(note, Ordering::Relaxed); + if note < start { + state.note_lo.store(note, Ordering::Relaxed); + } + }, + + _ => todo!("{:?}", self) + } + Ok(None) + } +} + pub struct PhraseView<'a> { note_point: usize, note_range: (usize, usize), @@ -112,22 +256,30 @@ pub struct PhraseView<'a> { time_point: usize, note_len: usize, phrase: &'a Option>>, - view_mode: &'a Box, + view_mode: &'a Box, now: &'a Arc, size: &'a Measure, focused: bool, entered: bool, } + render!(|self: PhraseView<'a>|{ let bg = if self.focused { TuiTheme::g(32) } else { Color::Reset }; let fg = self.phrase.as_ref() .map(|p|p.read().unwrap().color.clone()) .unwrap_or(ItemPalette::from(ItemColor::from(TuiTheme::g(64)))); Tui::bg(bg, Tui::split_up(false, 2, - Tui::bg(fg.dark.rgb, lay!([PhraseTimeline(&self, fg), PhraseViewStats(&self, fg),])), - Split::right(false, 5, PhraseKeys(&self, fg), lay!([PhraseNotes(&self, fg), PhraseCursor(&self), ])), + Tui::bg(fg.dark.rgb, lay!([ + PhraseTimeline(&self, fg), + PhraseViewStats(&self, fg), + ])), + Split::right(false, 5, PhraseKeys(&self, fg), lay!([ + PhraseNotes(&self, fg), + PhraseCursor(&self), + ])), )) }); + impl<'a, T: HasEditor> From<&'a T> for PhraseView<'a> { fn from (state: &'a T) -> Self { let editor = state.editor(); @@ -211,374 +363,3 @@ render!(|self: PhraseCursor<'a>|Tui::fill_xy(render(|to: &mut TuiOutput|Ok( self.0.note_range.0, ) )))); - -#[derive(Copy, Clone, Debug)] -pub enum PhraseEditMode { - Note, - Scroll, -} - -pub trait PhraseViewMode { - fn show (&mut self, phrase: Option<&Phrase>, note_len: usize); - fn time_zoom (&self) -> Option; - fn set_time_zoom (&mut self, time_zoom: Option); - fn buffer_width (&self, phrase: &Phrase) -> usize; - fn buffer_height (&self, phrase: &Phrase) -> usize; - fn render_keys (&self, - to: &mut TuiOutput, color: Color, point: Option, range: (usize, usize)); - fn render_notes (&self, - to: &mut TuiOutput, time_start: usize, note_hi: usize); - fn render_cursor ( - &self, - to: &mut TuiOutput, - time_point: usize, - time_start: usize, - note_point: usize, - note_len: usize, - note_hi: usize, - note_lo: usize, - ); -} - -pub struct PianoHorizontal { - time_zoom: Option, - note_zoom: PhraseViewNoteZoom, - buffer: BigBuffer, -} - -#[derive(Copy, Clone, Debug)] -pub enum PhraseViewNoteZoom { - N(usize), - Half, - Octant, -} - -impl PhraseViewMode for PianoHorizontal { - fn time_zoom (&self) -> Option { - self.time_zoom - } - fn set_time_zoom (&mut self, time_zoom: Option) { - self.time_zoom = time_zoom - } - fn show (&mut self, phrase: Option<&Phrase>, note_len: usize) { - if let Some(phrase) = phrase { - self.buffer = BigBuffer::new(self.buffer_width(phrase), self.buffer_height(phrase)); - draw_piano_horizontal_bg(&mut self.buffer, phrase, self.time_zoom.unwrap(), note_len); - draw_piano_horizontal_fg(&mut self.buffer, phrase, self.time_zoom.unwrap()); - } else { - self.buffer = Default::default(); - } - } - fn buffer_width (&self, phrase: &Phrase) -> usize { - phrase.length / self.time_zoom.unwrap() - } - /// Determine the required height to render the phrase. - fn buffer_height (&self, phrase: &Phrase) -> usize { - match self.note_zoom { - PhraseViewNoteZoom::Half => 64, - PhraseViewNoteZoom::N(n) => 128*n, - _ => unimplemented!() - } - } - fn render_notes ( - &self, - target: &mut TuiOutput, - time_start: usize, - note_hi: usize, - ) { - let [x0, y0, w, h] = target.area().xywh(); - let source = &self.buffer; - let target = &mut target.buffer; - for (x, target_x) in (x0..x0+w).enumerate() { - for (y, target_y) in (y0..y0+h).enumerate() { - if y > note_hi { - break - } - let source_x = time_start + x; - let source_y = note_hi - y; - // TODO: enable loop rollover: - //let source_x = (time_start + x) % source.width.max(1); - //let source_y = (note_hi - y) % source.height.max(1); - if source_x < source.width && source_y < source.height { - let target_cell = target.get_mut(target_x, target_y); - if let Some(source_cell) = source.get(source_x, source_y) { - *target_cell = source_cell.clone(); - } - } - } - } - } - fn render_keys ( - &self, - to: &mut TuiOutput, - color: Color, - point: Option, - (note_lo, note_hi): (usize, usize) - ) { - let [x, y0, _, _] = to.area().xywh(); - let key_style = Some(Style::default().fg(Color::Rgb(192, 192, 192)).bg(Color::Rgb(0, 0, 0))); - let note_off_style = Some(Style::default().fg(TuiTheme::g(160))); - let note_on_style = Some(Style::default().fg(TuiTheme::g(255)).bg(color).bold()); - for (y, note) in (note_lo..=note_hi).rev().enumerate().map(|(y, n)|(y0 + y as u16, n)) { - let key = match note % 12 { - 11 => "████▌", - 10 => " ", - 9 => "████▌", - 8 => " ", - 7 => "████▌", - 6 => " ", - 5 => "████▌", - 4 => "████▌", - 3 => " ", - 2 => "████▌", - 1 => " ", - 0 => "████▌", - _ => unreachable!(), - }; - to.blit(&key, x, y, key_style); - - if Some(note) == point { - to.blit(&format!("{:<5}", to_note_name(note)), x, y, note_on_style) - } else { - to.blit(&to_note_name(note), x, y, note_off_style) - }; - } - } - fn render_cursor ( - &self, - to: &mut TuiOutput, - time_point: usize, - time_start: usize, - note_point: usize, - note_len: usize, - note_hi: usize, - note_lo: usize, - ) { - let time_zoom = self.time_zoom.unwrap(); - let [x0, y0, w, _] = to.area().xywh(); - let style = Some(Style::default().fg(Color::Rgb(0,255,0))); - for (y, note) in (note_lo..=note_hi).rev().enumerate() { - if note == note_point { - for x in 0..w { - let time_1 = time_start + x as usize * time_zoom; - let time_2 = time_1 + time_zoom; - if time_1 <= time_point && time_point < time_2 { - to.blit(&"█", x0 + x as u16, y0 + y as u16, style); - let tail = note_len as u16 / time_zoom as u16; - for x_tail in (x0 + x + 1)..(x0 + x + tail) { - to.blit(&"▂", x_tail, y0 + y as u16, style); - } - break - } - } - break - } - } - } -} - -/// Draw the piano roll using full blocks on note on and half blocks on legato: █▄ █▄ █▄ -fn draw_piano_horizontal ( - target: &mut BigBuffer, - phrase: &Phrase, - time_zoom: usize, - note_len: usize, -) { - draw_piano_horizontal_bg(target, phrase, time_zoom, note_len); - draw_piano_horizontal_fg(target, phrase, time_zoom); -} - -fn draw_piano_horizontal_bg ( - target: &mut BigBuffer, - phrase: &Phrase, - time_zoom: usize, - note_len: usize, -) { - for (y, note) in (0..127).rev().enumerate() { - for (x, time) in (0..target.width).map(|x|(x, x*time_zoom)) { - let cell = target.get_mut(x, y).unwrap(); - cell.set_bg(phrase.color.darkest.rgb); - cell.set_fg(phrase.color.darker.rgb); - cell.set_char(if time % 384 == 0 { - '│' - } else if time % 96 == 0 { - '╎' - } else if time % note_len == 0 { - '┊' - } else if (127 - note) % 12 == 1 { - '=' - } else { - '·' - }); - } - } -} - -fn draw_piano_horizontal_fg ( - target: &mut BigBuffer, - phrase: &Phrase, - time_zoom: usize, -) { - let style = Style::default().fg(phrase.color.lightest.rgb);//.bg(Color::Rgb(0, 0, 0)); - let mut notes_on = [false;128]; - for (x, time_start) in (0..phrase.length).step_by(time_zoom).enumerate() { - - for (y, note) in (0..127).rev().enumerate() { - let cell = target.get_mut(x, note).unwrap(); - if notes_on[note] { - cell.set_char('▂'); - cell.set_style(style); - } - } - - let time_end = time_start + time_zoom; - for time in time_start..time_end { - for event in phrase.notes[time].iter() { - match event { - MidiMessage::NoteOn { key, .. } => { - let note = key.as_int() as usize; - let cell = target.get_mut(x, note).unwrap(); - cell.set_char('█'); - cell.set_style(style); - notes_on[note] = true - }, - MidiMessage::NoteOff { key, .. } => { - notes_on[key.as_int() as usize] = false - }, - _ => {} - } - } - } - - } -} - -#[derive(Clone, Debug)] -pub enum PhraseCommand { - // TODO: 1-9 seek markers that by default start every 8th of the phrase - AppendNote, - PutNote, - SetNoteCursor(usize), - SetNoteLength(usize), - SetNoteScroll(usize), - SetTimeCursor(usize), - SetTimeScroll(usize), - SetTimeZoom(Option), - Show(Option>>), - SetEditMode(PhraseEditMode), - ToggleDirection, -} - -impl InputToCommand for PhraseCommand { - fn input_to_command (state: &PhraseEditorModel, from: &TuiInput) -> Option { - use PhraseCommand::*; - use KeyCode::{Char, Esc, Up, Down, PageUp, PageDown, Left, Right}; - let note_lo = state.note_lo.load(Ordering::Relaxed); - let note_point = state.note_point.load(Ordering::Relaxed); - let time_start = state.time_start.load(Ordering::Relaxed); - let time_point = state.time_point.load(Ordering::Relaxed); - let time_zoom = state.view_mode.time_zoom(); - let length = state.phrase.as_ref().map(|p|p.read().unwrap().length).unwrap_or(1); - let note_len = state.note_len; - Some(match from.event() { - key!(Char('`')) => ToggleDirection, - key!(Esc) => SetEditMode(PhraseEditMode::Scroll), - key!(Char('z')) => SetTimeZoom(None), - key!(Char('-')) => SetTimeZoom(time_zoom.map(next_note_length)), - key!(Char('_')) => SetTimeZoom(time_zoom.map(next_note_length)), - key!(Char('=')) => SetTimeZoom(time_zoom.map(prev_note_length)), - key!(Char('+')) => SetTimeZoom(time_zoom.map(prev_note_length)), - key!(Char('a')) => AppendNote, - key!(Char('s')) => PutNote, - // TODO: no triplet/dotted - key!(Char(',')) => SetNoteLength(prev_note_length(state.note_len)), - key!(Char('.')) => SetNoteLength(next_note_length(state.note_len)), - // TODO: with triplet/dotted - key!(Char('<')) => SetNoteLength(prev_note_length(state.note_len)), - key!(Char('>')) => SetNoteLength(next_note_length(state.note_len)), - // TODO: '/' set triplet, '?' set dotted - _ => match state.edit_mode { - PhraseEditMode::Scroll => match from.event() { - key!(Char('e')) => SetEditMode(PhraseEditMode::Note), - key!(Up) => SetNoteScroll(note_lo + 1), - key!(Down) => SetNoteScroll(note_lo.saturating_sub(1)), - key!(PageUp) => SetNoteScroll(note_lo + 3), - key!(PageDown) => SetNoteScroll(note_lo.saturating_sub(3)), - key!(Left) => SetTimeScroll(time_start.saturating_sub(time_zoom.unwrap())), - key!(Right) => SetTimeScroll(time_start + time_zoom.unwrap()), - _ => return None - }, - PhraseEditMode::Note => match from.event() { - key!(Char('e')) => SetEditMode(PhraseEditMode::Scroll), - key!(Up) => SetNoteCursor(note_point + 1), - key!(Down) => SetNoteCursor(note_point.saturating_sub(1)), - key!(PageUp) => SetNoteCursor(note_point + 3), - key!(PageDown) => SetNoteCursor(note_point.saturating_sub(3)), - key!(Left) => SetTimeCursor(time_point.saturating_sub(note_len)), - key!(Right) => SetTimeCursor((time_point + note_len) % length), - key!(Shift-Left) => SetTimeCursor(time_point.saturating_sub(time_zoom.unwrap())), - key!(Shift-Right) => SetTimeCursor((time_point + time_zoom.unwrap()) % length), - _ => return None - }, - } - }) - } -} - -impl Command for PhraseCommand { - fn execute (self, state: &mut PhraseEditorModel) -> Perhaps { - use PhraseCommand::*; - Ok(match self { - Show(phrase) => { - state.show_phrase(phrase); - None - }, - ToggleDirection => { - todo!() - }, - SetEditMode(mode) => { - state.edit_mode = mode; - None - } - AppendNote => { - state.put_note(); - state.time_cursor_advance(); - None - }, - PutNote => { - state.put_note(); - None - }, - SetTimeCursor(time) => { - state.time_point.store(time, Ordering::Relaxed); - None - }, - SetTimeScroll(time) => { - state.time_start.store(time, Ordering::Relaxed); - None - }, - SetTimeZoom(zoom) => { - state.view_mode.set_time_zoom(zoom); - state.show_phrase(state.phrase.clone()); - None - }, - SetNoteScroll(note) => { - state.note_lo.store(note, Ordering::Relaxed); - None - }, - SetNoteLength(time) => { - state.note_len = time; - None - }, - SetNoteCursor(note) => { - let note = 127.min(note); - let start = state.note_lo.load(Ordering::Relaxed); - state.note_point.store(note, Ordering::Relaxed); - if note < start { - state.note_lo.store(note, Ordering::Relaxed); - } - None - }, - }) - } -} diff --git a/crates/tek/src/tui/phrase_list.rs b/crates/tek/src/tui/phrase_list.rs index 7c301d55..d014e134 100644 --- a/crates/tek/src/tui/phrase_list.rs +++ b/crates/tek/src/tui/phrase_list.rs @@ -1,11 +1,9 @@ use super::*; use crate::{ + tui::phrase_rename::PhraseRenameCommand as Rename, + tui::phrase_length::PhraseLengthCommand as Length, + tui::file_browser::FileBrowserCommand as Browse, api::PhrasePoolCommand as Pool, - tui::{ - phrase_rename::PhraseRenameCommand as Rename, - phrase_length::PhraseLengthCommand as Length, - file_browser::FileBrowserCommand as Browse, - } }; #[derive(Debug)] @@ -44,10 +42,22 @@ impl Default for PhraseListModel { } } -impl PhraseListModel { - pub(crate) fn phrase (&self) -> &Arc> { +impl HasPhrases for PhraseListModel { + fn phrases (&self) -> &Vec>> { + &self.phrases + } + fn phrases_mut (&mut self) -> &mut Vec>> { + &mut self.phrases + } +} + +impl HasPhrase for PhraseListModel { + fn phrase (&self) -> &Arc> { &self.phrases[self.phrase_index()] } +} + +impl PhraseListModel { pub(crate) fn phrase_index (&self) -> usize { self.phrase.load(Ordering::Relaxed) } @@ -142,11 +152,17 @@ render!(|self: PhraseListView<'a>|{ #[derive(Clone, PartialEq, Debug)] pub enum PhrasesCommand { - Select(usize), + /// Update the contents of the phrase pool Phrase(Pool), + /// Select a phrase from the phrase pool + Select(usize), + /// Rename a phrase Rename(Rename), + /// Change the length of a phrase Length(Length), + /// Import from file Import(Browse), + /// Export to file Export(Browse), } @@ -201,15 +217,6 @@ impl Command for PhrasesCommand { } } -impl HasPhrases for PhraseListModel { - fn phrases (&self) -> &Vec>> { - &self.phrases - } - fn phrases_mut (&mut self) -> &mut Vec>> { - &mut self.phrases - } -} - impl InputToCommand for PhrasesCommand { fn input_to_command (state: &PhraseListModel, input: &TuiInput) -> Option { Some(match state.phrases_mode() { @@ -233,10 +240,10 @@ fn to_phrases_command (state: &PhraseListModel, input: &TuiInput) -> Option Cmd::Import(Browse::Begin), key!(Char('x')) => Cmd::Export(Browse::Begin), key!(Char('c')) => Cmd::Phrase(Pool::SetColor(index, ItemColor::random())), - key!(Up) | key!(Char('[')) => Cmd::Select( + key!(Char('[')) | key!(Up) => Cmd::Select( index.overflowing_sub(1).0.min(state.phrases().len() - 1) ), - key!(Down) | key!(Char(']')) => Cmd::Select( + key!(Char(']')) | key!(Down) => Cmd::Select( index.saturating_add(1) % state.phrases().len() ), key!(Char('<')) => if index > 1 { diff --git a/crates/tek/src/tui/phrase_select.rs b/crates/tek/src/tui/phrase_select.rs index f2fbe626..c23c234f 100644 --- a/crates/tek/src/tui/phrase_select.rs +++ b/crates/tek/src/tui/phrase_select.rs @@ -2,55 +2,38 @@ use crate::*; pub struct PhraseSelector { pub(crate) title: &'static str, - pub(crate) phrase: Option<(Moment, Option>>)>, + pub(crate) name: String, + pub(crate) color: ItemPalette, pub(crate) time: String, } // TODO: Display phrases always in order of appearance -render!(|self: PhraseSelector|{ - let Self { title, phrase, time } = self; - let border_bg = TuiTheme::border_bg(); - let border_color = TuiTheme::bo1(); - let border = Lozenge(Style::default().bg(border_bg).fg(border_color)); - let title_color = TuiTheme::g(200); - Tui::fixed_y(2, lay!(move|add|{ - //if phrase.is_none() { - //add(&Tui::fill_x(border))?; - //} - add(&Tui::push_x(1, Tui::fg(title_color, *title)))?; - if let Some((_started, Some(phrase))) = phrase { - let Phrase { ref name, color, .. } = *phrase.read().unwrap(); - add(&Tui::bg(color.base.rgb, Tui::fill_x(col!([ - Tui::fill_x(lay!([ - Tui::fill_x(Tui::at_w(Tui::fg(TuiTheme::g(255), format!(" ")))), - Tui::inset_x(1, Tui::fill_x(Tui::at_e(Tui::fg(TuiTheme::g(255), time)))) - ])), - Tui::bold(true, Tui::fg(TuiTheme::g(255), format!(" {name}"))) - ]))))?; - } - Ok(()) - })) -}); +render!(|self: PhraseSelector|Tui::fixed_y(1, lay!(move|add|{ + add(&Tui::push_x(1, Tui::fg(TuiTheme::g(240), self.title)))?; + add(&Tui::bg(self.color.base.rgb, Tui::fill_x(Tui::inset_x(1, Tui::fill_x(Tui::at_e(Tui::fg(self.color.lightest.rgb, &self.time)))))))?; + Ok(()) +}))); impl PhraseSelector { // beats elapsed pub fn play_phrase (state: &T) -> Self { - let phrase = state.play_phrase().clone(); - Self { - title: "Now:", - time: if let Some(elapsed) = state.pulses_since_start_looped() { - format!("+{:>}", state.clock().timebase.format_beats_0(elapsed)) - } else { - String::from("") - }, - phrase, - } + let (name, color) = if let Some((_, Some(phrase))) = state.play_phrase() { + let Phrase { ref name, color, .. } = *phrase.read().unwrap(); + (name.clone(), color) + } else { + ("".to_string(), ItemPalette::from(TuiTheme::g(64))) + }; + let time = if let Some(elapsed) = state.pulses_since_start_looped() { + format!("+{:>}", state.clock().timebase.format_beats_0(elapsed)) + } else { + String::from("") + }; + Self { title: "Now:", time, name, color, } } // beats until switchover pub fn next_phrase (state: &T) -> Self { - let phrase = state.next_phrase().clone(); - Self { - title: "Next:", - time: phrase.as_ref().map(|(t, _)|{ + let (time, name, color) = if let Some((t, Some(phrase))) = state.next_phrase() { + let Phrase { ref name, color, .. } = *phrase.read().unwrap(); + let time = { let target = t.pulse.get(); let current = state.clock().playhead.pulse.get(); if target > current { @@ -59,16 +42,20 @@ impl PhraseSelector { } else { String::new() } - }).unwrap_or(String::from("")), - phrase, - } + }; + (time, name.clone(), color) + } else { + ("".into(), "".into(), TuiTheme::g(64).into()) + }; + Self { title: "Next:", time, name, color, } } pub fn edit_phrase (phrase: &Option>>) -> Self { - let phrase = phrase.clone(); - Self { - title: "Edit:", - time: phrase.as_ref().map(|p|format!("{}", p.read().unwrap().length)).unwrap_or(String::new()), - phrase: Some((Moment::default(), phrase)), - } + let (time, name, color) = if let Some(phrase) = phrase { + let phrase = phrase.read().unwrap(); + (format!("{}", phrase.length), phrase.name.clone(), phrase.color) + } else { + ("".to_string(), "".to_string(), ItemPalette::from(TuiTheme::g(64))) + }; + Self { title: "Editing:", time, name, color } } } diff --git a/crates/tek/src/tui/piano_horizontal.rs b/crates/tek/src/tui/piano_horizontal.rs new file mode 100644 index 00000000..82b5322b --- /dev/null +++ b/crates/tek/src/tui/piano_horizontal.rs @@ -0,0 +1,207 @@ +use crate::*; +use super::*; + +pub struct PianoHorizontal { + pub(crate) time_zoom: Option, + pub(crate) note_zoom: PhraseViewNoteZoom, + pub(crate) buffer: BigBuffer, +} + +impl std::fmt::Debug for PianoHorizontal { + fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + f.debug_struct("PianoHorizontal") + .field("time_zoom", &self.time_zoom) + .field("note_zoom", &self.note_zoom) + .field("buffer", &format!("{}x{}", self.buffer.width, self.buffer.height)) + .finish() + } +} + +#[derive(Copy, Clone, Debug)] +pub enum PhraseViewNoteZoom { + N(usize), + Half, + Octant, +} + +impl PhraseViewMode for PianoHorizontal { + fn time_zoom (&self) -> Option { + self.time_zoom + } + fn set_time_zoom (&mut self, time_zoom: Option) { + self.time_zoom = time_zoom + } + fn show (&mut self, phrase: Option<&Phrase>, note_len: usize) { + if let Some(phrase) = phrase { + self.buffer = BigBuffer::new(self.buffer_width(phrase), self.buffer_height(phrase)); + draw_piano_horizontal_bg(&mut self.buffer, phrase, self.time_zoom.unwrap(), note_len); + draw_piano_horizontal_fg(&mut self.buffer, phrase, self.time_zoom.unwrap()); + } else { + self.buffer = Default::default(); + } + } + fn buffer_width (&self, phrase: &Phrase) -> usize { + phrase.length / self.time_zoom.unwrap() + } + /// Determine the required height to render the phrase. + fn buffer_height (&self, phrase: &Phrase) -> usize { + match self.note_zoom { + PhraseViewNoteZoom::Half => 64, + PhraseViewNoteZoom::N(n) => 128*n, + _ => unimplemented!() + } + } + fn render_notes ( + &self, + target: &mut TuiOutput, + time_start: usize, + note_hi: usize, + ) { + let [x0, y0, w, h] = target.area().xywh(); + let source = &self.buffer; + let target = &mut target.buffer; + for (x, target_x) in (x0..x0+w).enumerate() { + for (y, target_y) in (y0..y0+h).enumerate() { + if y > note_hi { + break + } + let source_x = time_start + x; + let source_y = note_hi - y; + // TODO: enable loop rollover: + //let source_x = (time_start + x) % source.width.max(1); + //let source_y = (note_hi - y) % source.height.max(1); + if source_x < source.width && source_y < source.height { + let target_cell = target.get_mut(target_x, target_y); + if let Some(source_cell) = source.get(source_x, source_y) { + *target_cell = source_cell.clone(); + } + } + } + } + } + fn render_keys ( + &self, + to: &mut TuiOutput, + color: Color, + point: Option, + (note_lo, note_hi): (usize, usize) + ) { + let [x, y0, _, _] = to.area().xywh(); + let key_style = Some(Style::default().fg(Color::Rgb(192, 192, 192)).bg(Color::Rgb(0, 0, 0))); + let note_off_style = Some(Style::default().fg(TuiTheme::g(160))); + let note_on_style = Some(Style::default().fg(TuiTheme::g(255)).bg(color).bold()); + for (y, note) in (note_lo..=note_hi).rev().enumerate().map(|(y, n)|(y0 + y as u16, n)) { + let key = match note % 12 { + 11 => "████▌", + 10 => " ", + 9 => "████▌", + 8 => " ", + 7 => "████▌", + 6 => " ", + 5 => "████▌", + 4 => "████▌", + 3 => " ", + 2 => "████▌", + 1 => " ", + 0 => "████▌", + _ => unreachable!(), + }; + to.blit(&key, x, y, key_style); + + if Some(note) == point { + to.blit(&format!("{:<5}", to_note_name(note)), x, y, note_on_style) + } else { + to.blit(&to_note_name(note), x, y, note_off_style) + }; + } + } + fn render_cursor ( + &self, + to: &mut TuiOutput, + time_point: usize, + time_start: usize, + note_point: usize, + note_len: usize, + note_hi: usize, + note_lo: usize, + ) { + let time_zoom = self.time_zoom.unwrap(); + let [x0, y0, w, h] = to.area().xywh(); + let style = Some(Style::default().fg(Color::Rgb(0,255,0))); + for (y, note) in (note_lo..=note_hi).rev().enumerate() { + if note == note_point { + for x in 0..w { + let time_1 = time_start + x as usize * time_zoom; + let time_2 = time_1 + time_zoom; + if time_1 <= time_point && time_point < time_2 { + to.blit(&"█", x0 + x as u16, y0 + y as u16, style); + let tail = note_len as u16 / time_zoom as u16; + for x_tail in (x0 + x + 1)..(x0 + x + tail) { + to.blit(&"▂", x_tail, y0 + y as u16, style); + } + break + } + } + break + } + } + } +} + +/// Draw the piano roll foreground using full blocks on note on and half blocks on legato: █▄ █▄ █▄ +fn draw_piano_horizontal_bg (buf: &mut BigBuffer, phrase: &Phrase, 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(phrase.color.darkest.rgb); + cell.set_fg(phrase.color.darker.rgb); + cell.set_char(if time % 384 == 0 { + '│' + } else if time % 96 == 0 { + '╎' + } else if time % note_len == 0 { + '┊' + } else if (127 - note) % 12 == 1 { + '=' + } else { + '·' + }); + } + } +} + +/// Draw the piano roll background using full blocks on note on and half blocks on legato: █▄ █▄ █▄ +fn draw_piano_horizontal_fg (buf: &mut BigBuffer, phrase: &Phrase, zoom: usize) { + let style = Style::default().fg(phrase.color.lightest.rgb);//.bg(Color::Rgb(0, 0, 0)); + let mut notes_on = [false;128]; + for (x, time_start) in (0..phrase.length).step_by(zoom).enumerate() { + + for (y, note) in (0..127).rev().enumerate() { + let cell = buf.get_mut(x, note).unwrap(); + if notes_on[note] { + cell.set_char('▂'); + cell.set_style(style); + } + } + + let time_end = time_start + zoom; + for time in time_start..time_end { + for event in phrase.notes[time].iter() { + match event { + MidiMessage::NoteOn { key, .. } => { + let note = key.as_int() as usize; + let cell = buf.get_mut(x, note).unwrap(); + cell.set_char('█'); + cell.set_style(style); + notes_on[note] = true + }, + MidiMessage::NoteOff { key, .. } => { + notes_on[key.as_int() as usize] = false + }, + _ => {} + } + } + } + + } +} diff --git a/crates/tek/src/tui/status_bar.rs b/crates/tek/src/tui/status_bar.rs index 70cfa678..ff6660ed 100644 --- a/crates/tek/src/tui/status_bar.rs +++ b/crates/tek/src/tui/status_bar.rs @@ -116,7 +116,7 @@ impl From<&SequencerTui> for SequencerStatusBar { } render!(|self: SequencerStatusBar|{ - lay!(|add|if self.width > 60 { + lay!(|add|if self.width > 40 { add(&Tui::fill_x(Tui::fixed_y(1, lay!([ Tui::fill_x(Tui::at_w(SequencerMode::from(self))), Tui::fill_x(Tui::at_e(SequencerStats::from(self))),