mod clip_view;
mod input;
mod loop_editor;
mod menu;
mod track;
mod transport_ui;
pub mod undo;
pub use clip_view::*;
pub use input::*;
pub use loop_editor::*;
pub use menu::*;
pub use track::*;
pub use transport_ui::*;
mod navigation;
mod params;
mod track_ops;
pub use track_ops::initial_tracks;
use phosphor_core::project::TrackKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Transport,
Tracks,
ClipView,
}
impl Pane {
pub fn number(self) -> u8 {
match self {
Self::Transport => 1,
Self::Tracks => 2,
Self::ClipView => 3,
}
}
pub fn from_number(n: u8) -> Option<Self> {
match n {
1 => Some(Self::Transport),
2 => Some(Self::Tracks),
3 => Some(Self::ClipView),
_ => None,
}
}
pub fn next(self) -> Self {
match self {
Self::Transport => Self::Tracks,
Self::Tracks => Self::ClipView,
Self::ClipView => Self::Transport,
}
}
pub fn prev(self) -> Self {
match self {
Self::Transport => Self::ClipView,
Self::Tracks => Self::Transport,
Self::ClipView => Self::Tracks,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Transport => "transport",
Self::Tracks => "tracks",
Self::ClipView => "clip",
}
}
}
pub const MAX_VISIBLE_TRACKS: usize = 5;
pub const INST_CONFIG_PARAM_COUNT: usize = 15;
#[derive(Debug)]
pub struct NavState {
pub focused_pane: Pane,
pub track_cursor: usize,
pub track_scroll: usize,
pub track_selected: bool,
pub track_element: TrackElement,
pub number_buf: NumberBuffer,
pub space_menu: SpaceMenu,
pub clip_view: ClipViewState,
pub clip_view_visible: bool,
pub clip_view_target: Option<(usize, usize)>,
pub fx_menu: FxMenu,
pub instrument_modal: InstrumentModal,
pub loop_editor: LoopEditor,
pub transport_ui: TransportUiState,
pub tracks: Vec<TrackState>,
pub input_modal: InputModal,
pub confirm_modal: ConfirmModal,
pub undo_stack: undo::UndoStack,
pub quantize_modal: QuantizeModal,
pub element_locked: bool,
pub recording_grace: usize,
}
impl NavState {
pub fn new(tracks: Vec<TrackState>) -> Self {
Self {
focused_pane: Pane::Tracks,
track_cursor: 0,
track_scroll: 0,
track_selected: false,
track_element: TrackElement::Label,
number_buf: NumberBuffer::new(),
space_menu: SpaceMenu::new(),
clip_view: ClipViewState::new(),
clip_view_visible: false,
clip_view_target: None,
fx_menu: FxMenu::new(),
instrument_modal: InstrumentModal::new(),
loop_editor: LoopEditor::new(),
transport_ui: TransportUiState::new(),
tracks,
input_modal: InputModal::new(),
confirm_modal: ConfirmModal::new(),
undo_stack: undo::UndoStack::new(),
quantize_modal: QuantizeModal::new(),
element_locked: false,
recording_grace: 0,
}
}
pub fn visible_tracks(&self) -> &[TrackState] {
let end = (self.track_scroll + MAX_VISIBLE_TRACKS).min(self.tracks.len());
&self.tracks[self.track_scroll..end]
}
pub fn can_scroll_up(&self) -> bool { self.track_scroll > 0 }
pub fn can_scroll_down(&self) -> bool {
self.track_scroll + MAX_VISIBLE_TRACKS < self.tracks.len()
}
pub fn current_track(&self) -> Option<&TrackState> { self.tracks.get(self.track_cursor) }
pub fn current_track_mut(&mut self) -> Option<&mut TrackState> {
self.tracks.get_mut(self.track_cursor)
}
pub fn active_clip(&self) -> Option<&Clip> {
let (ti, ci) = self.clip_view_target?;
self.tracks.get(ti)?.clips.get(ci)
}
pub fn active_clip_mut(&mut self) -> Option<&mut Clip> {
let (ti, ci) = self.clip_view_target?;
self.tracks.get_mut(ti)?.clips.get_mut(ci)
}
pub fn active_clip_track(&self) -> Option<&TrackState> {
let (ti, _) = self.clip_view_target?;
self.tracks.get(ti)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pane_numbers() {
assert_eq!(Pane::Transport.number(), 1);
assert_eq!(Pane::Tracks.number(), 2);
assert_eq!(Pane::ClipView.number(), 3);
assert_eq!(Pane::from_number(1), Some(Pane::Transport));
assert_eq!(Pane::from_number(2), Some(Pane::Tracks));
assert_eq!(Pane::from_number(3), Some(Pane::ClipView));
assert_eq!(Pane::from_number(9), None);
}
#[test]
fn track_element_navigation_full() {
let e = TrackElement::Label;
assert_eq!(e.move_right(3), TrackElement::Fx);
assert_eq!(TrackElement::Fx.move_right(3), TrackElement::Volume);
assert_eq!(TrackElement::Volume.move_right(3), TrackElement::Mute);
assert_eq!(TrackElement::Mute.move_right(3), TrackElement::Solo);
assert_eq!(TrackElement::Solo.move_right(3), TrackElement::RecordArm);
assert_eq!(TrackElement::RecordArm.move_right(3), TrackElement::Clip(0));
assert_eq!(TrackElement::Clip(2).move_right(3), TrackElement::Clip(2));
}
#[test]
fn track_element_left_full() {
assert_eq!(TrackElement::Clip(0).move_left(), TrackElement::RecordArm);
assert_eq!(TrackElement::RecordArm.move_left(), TrackElement::Solo);
assert_eq!(TrackElement::Solo.move_left(), TrackElement::Mute);
assert_eq!(TrackElement::Mute.move_left(), TrackElement::Volume);
assert_eq!(TrackElement::Volume.move_left(), TrackElement::Fx);
assert_eq!(TrackElement::Fx.move_left(), TrackElement::Label);
assert_eq!(TrackElement::Label.move_left(), TrackElement::Label);
}
#[test]
fn initial_tracks_has_sends_and_master() {
let tracks = initial_tracks();
assert_eq!(tracks.len(), 3); assert_eq!(tracks[0].kind, TrackKind::SendA);
assert_eq!(tracks[1].kind, TrackKind::SendB);
assert_eq!(tracks[2].kind, TrackKind::Master);
}
#[test]
fn sends_are_at_end() {
let mut nav = NavState::new(initial_tracks());
nav.move_down();
nav.move_down();
assert_eq!(nav.track_cursor, 2);
assert_eq!(nav.tracks[nav.track_cursor].kind, TrackKind::Master);
}
#[test]
fn fx_menu_opens_and_closes() {
let mut nav = NavState::new(initial_tracks());
nav.enter(); nav.move_right(); assert_eq!(nav.track_element, TrackElement::Fx);
nav.enter(); assert!(nav.fx_menu.open);
nav.escape(); assert!(!nav.fx_menu.open);
}
#[test]
fn fx_menu_add_effect() {
let mut nav = NavState::new(initial_tracks());
let initial_count = nav.tracks[0].fx_chain.len();
nav.enter();
nav.move_right(); nav.enter(); nav.enter(); assert!(!nav.fx_menu.open);
assert_eq!(nav.tracks[0].fx_chain.len(), initial_count + 1);
assert_eq!(nav.tracks[0].fx_chain.last().unwrap().fx_type, FxType::Reverb);
}
#[test]
fn clip_view_focus_toggle() {
let mut nav = NavState::new(initial_tracks());
nav.clip_view_visible = true;
nav.clip_view_target = Some((0, 0));
nav.focus_pane(Pane::ClipView);
assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
nav.move_left(); assert_eq!(nav.clip_view.focus, ClipViewFocus::FxPanel);
}
#[test]
fn clip_view_tabs_cycle() {
let mut nav = NavState::new(initial_tracks());
nav.focused_pane = Pane::ClipView;
nav.clip_view.focus = ClipViewFocus::FxPanel;
assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::TrackFx);
nav.cycle_tab();
assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::Synth);
nav.cycle_tab();
assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
assert_eq!(nav.clip_view.clip_tab, ClipTab::InstConfig);
nav.cycle_tab();
assert_eq!(nav.clip_view.clip_tab, ClipTab::PianoRoll);
nav.cycle_tab();
assert_eq!(nav.clip_view.clip_tab, ClipTab::Settings);
nav.cycle_tab();
assert_eq!(nav.clip_view.focus, ClipViewFocus::FxPanel);
assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::TrackFx);
}
#[test]
fn arm_toggle() {
let mut nav = NavState::new(initial_tracks());
assert!(!nav.tracks[0].armed); nav.toggle_arm();
assert!(nav.tracks[0].armed);
nav.toggle_arm();
assert!(!nav.tracks[0].armed);
}
#[test]
fn space_menu_toggle() {
let mut nav = NavState::new(initial_tracks());
assert!(!nav.space_menu.open);
nav.toggle_space_menu();
assert!(nav.space_menu.open);
nav.toggle_space_menu();
assert!(!nav.space_menu.open);
}
#[test]
fn space_menu_handle_pane_jump() {
let mut nav = NavState::new(initial_tracks());
nav.toggle_space_menu();
let action = nav.space_menu_handle('2');
assert_eq!(nav.focused_pane, Pane::Tracks);
assert!(action.is_none());
assert!(!nav.space_menu.open);
nav.toggle_space_menu();
let action = nav.space_menu_handle('1');
assert_eq!(nav.focused_pane, Pane::Transport);
assert!(action.is_none());
}
#[test]
fn space_menu_handle_play_pause() {
let mut nav = NavState::new(initial_tracks());
nav.toggle_space_menu();
let action = nav.space_menu_handle('p');
assert_eq!(action, Some(SpaceAction::PlayPause));
assert!(!nav.space_menu.open);
}
#[test]
fn space_menu_enter_select() {
let mut nav = NavState::new(initial_tracks());
nav.toggle_space_menu();
let action = nav.enter();
assert!(action.is_none()); assert!(!nav.space_menu.open);
}
#[test]
fn space_menu_nav_and_help() {
let mut nav = NavState::new(initial_tracks());
nav.toggle_space_menu();
assert_eq!(nav.space_menu.section, SpaceMenuSection::Actions);
nav.space_menu.switch_section();
assert_eq!(nav.space_menu.section, SpaceMenuSection::Help);
assert_eq!(nav.space_menu.cursor, 0);
}
#[test]
fn number_buffer_commit() {
let mut buf = NumberBuffer::new();
buf.push_digit('1');
assert_eq!(buf.commit(), Some(1));
buf.push_digit('1');
buf.push_digit('2');
assert_eq!(buf.commit(), Some(12));
}
#[test]
fn number_buffer_empty_commit() {
assert_eq!(NumberBuffer::new().commit(), None);
}
#[test]
fn nav_cursor_bounds() {
let mut nav = NavState::new(initial_tracks());
for _ in 0..20 { nav.move_down(); }
assert_eq!(nav.track_cursor, 2); }
#[test]
fn enter_escape_track() {
let mut nav = NavState::new(initial_tracks());
nav.enter();
assert!(nav.track_selected);
nav.escape();
assert!(!nav.track_selected);
}
#[test]
fn mute_solo_toggle() {
let mut nav = NavState::new(initial_tracks());
nav.toggle_mute();
assert!(nav.tracks[0].muted);
nav.toggle_solo();
assert!(nav.tracks[0].soloed);
}
#[test]
fn volume_element_in_chain() {
let e = TrackElement::Fx;
assert_eq!(e.move_right(1), TrackElement::Volume);
assert_eq!(TrackElement::Volume.move_left(), TrackElement::Fx);
}
use phosphor_core::project::{TrackConfig, TrackHandle};
fn live_track() -> TrackState {
let mut t = TrackState::new("t", 0, false, TrackKind::Instrument, vec![]);
t.handle = Some(std::sync::Arc::new(TrackHandle::new(0, TrackKind::Instrument)));
t.mixer_id = Some(0);
t
}
fn handle_volume(t: &TrackState) -> f32 {
t.handle.as_ref().unwrap().config.get_volume()
}
#[test]
fn fader_steps_one_db_per_press() {
let mut t = live_track();
t.adjust_volume(1);
let start = t.volume_db().unwrap().round();
for i in 1..=6 {
t.adjust_volume(1);
let db = t.volume_db().unwrap();
assert!(
(db - (start + i as f32)).abs() < 0.01,
"press {i} landed at {db:.3} dB, expected {:.3}",
start + i as f32
);
}
}
#[test]
fn fader_lands_exactly_on_unity() {
let mut t = live_track();
for _ in 0..40 {
t.adjust_volume(1);
}
while t.volume_db().unwrap() > 0.5 {
t.adjust_volume(-1);
}
assert!(
(t.volume - TrackConfig::UNITY_VOLUME).abs() < 1.0e-3,
"fader stopped at {} instead of unity",
t.volume
);
}
#[test]
fn fader_travel_is_bounded_at_both_ends() {
let mut t = live_track();
for _ in 0..200 {
t.adjust_volume(1);
}
assert_eq!(t.volume, TrackConfig::MAX_VOLUME);
assert_eq!(handle_volume(&t), TrackConfig::MAX_VOLUME);
for _ in 0..200 {
t.adjust_volume(-1);
}
assert_eq!(t.volume, TrackConfig::MIN_VOLUME);
assert_eq!(handle_volume(&t), TrackConfig::MIN_VOLUME);
t.adjust_volume(1);
assert!(t.volume > 0.0, "fader stuck at silence");
}
#[test]
fn fader_syncs_to_the_audio_thread() {
let mut t = live_track();
for steps in [1, 1, -1, 3, -7] {
t.adjust_volume(steps);
assert_eq!(
handle_volume(&t),
t.volume,
"audio thread has {} while the UI shows {}",
handle_volume(&t),
t.volume
);
}
}
#[test]
fn fader_snaps_a_loaded_position_onto_the_grid() {
let mut t = live_track();
t.volume = 0.6234; t.adjust_volume(1);
let db = t.volume_db().unwrap();
assert!((db - db.round()).abs() < 0.01, "off the grid at {db:.3} dB");
}
#[test]
fn enter_locks_the_fader_only_on_tracks_that_have_one() {
let mut nav = NavState::new(initial_tracks()); nav.enter();
nav.move_right(); nav.move_right(); assert_eq!(nav.track_element, TrackElement::Volume);
nav.enter();
assert!(!nav.element_locked, "locked the fader on a bus track");
nav.tracks.push(live_track());
nav.track_cursor = nav.tracks.len() - 1;
nav.enter();
assert!(nav.element_locked, "did not lock the fader on an instrument track");
nav.escape();
assert!(!nav.element_locked);
assert_eq!(nav.track_element, TrackElement::Volume);
}
#[test]
fn fader_does_not_push_onto_the_undo_stack() {
let mut nav = NavState::new(vec![live_track()]);
assert!(!nav.undo_stack.can_undo());
nav.adjust_volume(3);
nav.adjust_volume(-1);
assert!(
!nav.undo_stack.can_undo(),
"the fader pushed an undo entry; mute and solo do not"
);
}
}