Refactoring
This commit is contained in:
566
seq/src/app.rs
566
seq/src/app.rs
@@ -1,4 +1,3 @@
|
|||||||
use doux::audio::AudioDeviceInfo;
|
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -6,299 +5,20 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use tui_textarea::TextArea;
|
|
||||||
|
|
||||||
use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS};
|
use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS};
|
||||||
use crate::file;
|
use crate::file;
|
||||||
use crate::link::LinkState;
|
use crate::link::LinkState;
|
||||||
use crate::model::{Pattern, Project};
|
use crate::model::{Pattern, Project};
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::script::{ScriptEngine, StepContext, Variables, Rng};
|
use crate::script::{Rng, ScriptEngine, StepContext, Variables};
|
||||||
use crate::sequencer::SlotState;
|
use crate::sequencer::{SequencerSnapshot, SlotChange};
|
||||||
|
use crate::services::pattern_editor;
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
use crate::state::{
|
||||||
pub enum SlotChange {
|
AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsViewLevel,
|
||||||
Add { slot: usize, bank: usize, pattern: usize },
|
};
|
||||||
Remove { slot: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Focus {
|
|
||||||
Sequencer,
|
|
||||||
Editor,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum PatternField {
|
|
||||||
Length,
|
|
||||||
Speed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub enum Modal {
|
|
||||||
None,
|
|
||||||
ConfirmQuit { selected: bool },
|
|
||||||
SaveAs(String),
|
|
||||||
LoadFrom(String),
|
|
||||||
RenameBank { bank: usize, name: String },
|
|
||||||
RenamePattern { bank: usize, pattern: usize, name: String },
|
|
||||||
SetPattern { field: PatternField, input: String },
|
|
||||||
AddSamplePath(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum PatternsViewLevel {
|
|
||||||
#[default]
|
|
||||||
Banks,
|
|
||||||
Patterns { bank: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AudioConfig {
|
|
||||||
pub output_device: Option<String>,
|
|
||||||
pub input_device: Option<String>,
|
|
||||||
pub channels: u16,
|
|
||||||
pub buffer_size: u32,
|
|
||||||
pub sample_rate: f32,
|
|
||||||
pub sample_paths: Vec<PathBuf>,
|
|
||||||
pub sample_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
output_device: None,
|
|
||||||
input_device: None,
|
|
||||||
channels: 2,
|
|
||||||
buffer_size: 512,
|
|
||||||
sample_rate: 44100.0,
|
|
||||||
sample_paths: Vec::new(),
|
|
||||||
sample_count: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum AudioFocus {
|
|
||||||
#[default]
|
|
||||||
OutputDevice,
|
|
||||||
InputDevice,
|
|
||||||
Channels,
|
|
||||||
BufferSize,
|
|
||||||
SamplePaths,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Metrics {
|
|
||||||
pub event_count: usize,
|
|
||||||
pub active_voices: usize,
|
|
||||||
pub peak_voices: usize,
|
|
||||||
pub cpu_load: f32,
|
|
||||||
pub schedule_depth: usize,
|
|
||||||
pub scope: [f32; 64],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Metrics {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
event_count: 0,
|
|
||||||
active_voices: 0,
|
|
||||||
peak_voices: 0,
|
|
||||||
cpu_load: 0.0,
|
|
||||||
schedule_depth: 0,
|
|
||||||
scope: [0.0; 64],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct EditorContext {
|
|
||||||
pub bank: usize,
|
|
||||||
pub pattern: usize,
|
|
||||||
pub step: usize,
|
|
||||||
pub focus: Focus,
|
|
||||||
pub text: TextArea<'static>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EditorContext {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
bank: 0,
|
|
||||||
pattern: 0,
|
|
||||||
step: 0,
|
|
||||||
focus: Focus::Sequencer,
|
|
||||||
text: TextArea::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AudioSettings {
|
|
||||||
pub config: AudioConfig,
|
|
||||||
pub focus: AudioFocus,
|
|
||||||
pub output_devices: Vec<AudioDeviceInfo>,
|
|
||||||
pub input_devices: Vec<AudioDeviceInfo>,
|
|
||||||
pub restart_pending: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AudioSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
config: AudioConfig::default(),
|
|
||||||
focus: AudioFocus::default(),
|
|
||||||
output_devices: doux::audio::list_output_devices(),
|
|
||||||
input_devices: doux::audio::list_input_devices(),
|
|
||||||
restart_pending: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioSettings {
|
|
||||||
pub fn refresh_devices(&mut self) {
|
|
||||||
self.output_devices = doux::audio::list_output_devices();
|
|
||||||
self.input_devices = doux::audio::list_input_devices();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_focus(&mut self) {
|
|
||||||
self.focus = match self.focus {
|
|
||||||
AudioFocus::OutputDevice => AudioFocus::InputDevice,
|
|
||||||
AudioFocus::InputDevice => AudioFocus::Channels,
|
|
||||||
AudioFocus::Channels => AudioFocus::BufferSize,
|
|
||||||
AudioFocus::BufferSize => AudioFocus::SamplePaths,
|
|
||||||
AudioFocus::SamplePaths => AudioFocus::OutputDevice,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_focus(&mut self) {
|
|
||||||
self.focus = match self.focus {
|
|
||||||
AudioFocus::OutputDevice => AudioFocus::SamplePaths,
|
|
||||||
AudioFocus::InputDevice => AudioFocus::OutputDevice,
|
|
||||||
AudioFocus::Channels => AudioFocus::InputDevice,
|
|
||||||
AudioFocus::BufferSize => AudioFocus::Channels,
|
|
||||||
AudioFocus::SamplePaths => AudioFocus::BufferSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_output_device(&mut self) {
|
|
||||||
if self.output_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_output_device_index();
|
|
||||||
let next_idx = (current_idx + 1) % self.output_devices.len();
|
|
||||||
self.config.output_device = Some(self.output_devices[next_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_output_device(&mut self) {
|
|
||||||
if self.output_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_output_device_index();
|
|
||||||
let prev_idx = (current_idx + self.output_devices.len() - 1) % self.output_devices.len();
|
|
||||||
self.config.output_device = Some(self.output_devices[prev_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_output_device_index(&self) -> usize {
|
|
||||||
match &self.config.output_device {
|
|
||||||
Some(name) => self
|
|
||||||
.output_devices
|
|
||||||
.iter()
|
|
||||||
.position(|d| &d.name == name)
|
|
||||||
.unwrap_or(0),
|
|
||||||
None => self
|
|
||||||
.output_devices
|
|
||||||
.iter()
|
|
||||||
.position(|d| d.is_default)
|
|
||||||
.unwrap_or(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_input_device(&mut self) {
|
|
||||||
if self.input_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_input_device_index();
|
|
||||||
let next_idx = (current_idx + 1) % self.input_devices.len();
|
|
||||||
self.config.input_device = Some(self.input_devices[next_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_input_device(&mut self) {
|
|
||||||
if self.input_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_input_device_index();
|
|
||||||
let prev_idx = (current_idx + self.input_devices.len() - 1) % self.input_devices.len();
|
|
||||||
self.config.input_device = Some(self.input_devices[prev_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_input_device_index(&self) -> usize {
|
|
||||||
match &self.config.input_device {
|
|
||||||
Some(name) => self
|
|
||||||
.input_devices
|
|
||||||
.iter()
|
|
||||||
.position(|d| &d.name == name)
|
|
||||||
.unwrap_or(0),
|
|
||||||
None => self
|
|
||||||
.input_devices
|
|
||||||
.iter()
|
|
||||||
.position(|d| d.is_default)
|
|
||||||
.unwrap_or(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn adjust_channels(&mut self, delta: i16) {
|
|
||||||
let new_val = (self.config.channels as i16 + delta).clamp(1, 64) as u16;
|
|
||||||
self.config.channels = new_val;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn adjust_buffer_size(&mut self, delta: i32) {
|
|
||||||
let new_val = (self.config.buffer_size as i32 + delta).clamp(64, 4096) as u32;
|
|
||||||
self.config.buffer_size = new_val;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_output_device_name(&self) -> &str {
|
|
||||||
match &self.config.output_device {
|
|
||||||
Some(name) => name,
|
|
||||||
None => self
|
|
||||||
.output_devices
|
|
||||||
.iter()
|
|
||||||
.find(|d| d.is_default)
|
|
||||||
.map(|d| d.name.as_str())
|
|
||||||
.unwrap_or("Default"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_input_device_name(&self) -> &str {
|
|
||||||
match &self.config.input_device {
|
|
||||||
Some(name) => name,
|
|
||||||
None => self
|
|
||||||
.input_devices
|
|
||||||
.iter()
|
|
||||||
.find(|d| d.is_default)
|
|
||||||
.map(|d| d.name.as_str())
|
|
||||||
.unwrap_or("None"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_sample_path(&mut self, path: PathBuf) {
|
|
||||||
if !self.config.sample_paths.contains(&path) {
|
|
||||||
self.config.sample_paths.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_last_sample_path(&mut self) {
|
|
||||||
self.config.sample_paths.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn trigger_restart(&mut self) {
|
|
||||||
self.restart_pending = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub tempo: f64,
|
|
||||||
pub beat: f64,
|
|
||||||
pub phase: f64,
|
|
||||||
pub peers: u64,
|
|
||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub quantum: f64,
|
|
||||||
|
|
||||||
pub project: Project,
|
pub project: Project,
|
||||||
pub page: Page,
|
pub page: Page,
|
||||||
@@ -307,8 +27,6 @@ pub struct App {
|
|||||||
pub patterns_view_level: PatternsViewLevel,
|
pub patterns_view_level: PatternsViewLevel,
|
||||||
pub patterns_cursor: usize,
|
pub patterns_cursor: usize,
|
||||||
|
|
||||||
pub slot_data: [SlotState; MAX_SLOTS],
|
|
||||||
pub slot_steps: [usize; MAX_SLOTS],
|
|
||||||
pub queued_changes: Vec<SlotChange>,
|
pub queued_changes: Vec<SlotChange>,
|
||||||
|
|
||||||
pub metrics: Metrics,
|
pub metrics: Metrics,
|
||||||
@@ -329,18 +47,13 @@ pub struct App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(tempo: f64, quantum: f64) -> Self {
|
pub fn new() -> Self {
|
||||||
let variables = Arc::new(Mutex::new(HashMap::new()));
|
let variables = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
|
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
|
||||||
let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&rng));
|
let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&rng));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
tempo,
|
|
||||||
beat: 0.0,
|
|
||||||
phase: 0.0,
|
|
||||||
peers: 0,
|
|
||||||
playing: true,
|
playing: true,
|
||||||
quantum,
|
|
||||||
|
|
||||||
project: Project::default(),
|
project: Project::default(),
|
||||||
page: Page::default(),
|
page: Page::default(),
|
||||||
@@ -349,8 +62,6 @@ impl App {
|
|||||||
patterns_view_level: PatternsViewLevel::default(),
|
patterns_view_level: PatternsViewLevel::default(),
|
||||||
patterns_cursor: 0,
|
patterns_cursor: 0,
|
||||||
|
|
||||||
slot_data: [SlotState::default(); MAX_SLOTS],
|
|
||||||
slot_steps: [0; MAX_SLOTS],
|
|
||||||
queued_changes: Vec::new(),
|
queued_changes: Vec::new(),
|
||||||
|
|
||||||
metrics: Metrics::default(),
|
metrics: Metrics::default(),
|
||||||
@@ -371,6 +82,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_bank_pattern(&self) -> (usize, usize) {
|
||||||
|
(self.editor_ctx.bank, self.editor_ctx.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_current_dirty(&mut self) {
|
||||||
|
self.dirty_patterns.insert(self.current_bank_pattern());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mark_all_patterns_dirty(&mut self) {
|
pub fn mark_all_patterns_dirty(&mut self) {
|
||||||
for bank in 0..MAX_BANKS {
|
for bank in 0..MAX_BANKS {
|
||||||
for pattern in 0..MAX_PATTERNS {
|
for pattern in 0..MAX_PATTERNS {
|
||||||
@@ -379,29 +98,21 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_from_link(&mut self, link: &LinkState) {
|
|
||||||
let (tempo, beat, phase, peers) = link.query();
|
|
||||||
self.tempo = tempo;
|
|
||||||
self.beat = beat;
|
|
||||||
self.phase = phase;
|
|
||||||
self.peers = peers;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_playing(&mut self) {
|
pub fn toggle_playing(&mut self) {
|
||||||
self.playing = !self.playing;
|
self.playing = !self.playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tempo_up(&mut self, link: &LinkState) {
|
pub fn tempo_up(&self, link: &LinkState) {
|
||||||
self.tempo = (self.tempo + 1.0).min(300.0);
|
let current = link.tempo();
|
||||||
link.set_tempo(self.tempo);
|
link.set_tempo((current + 1.0).min(300.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tempo_down(&mut self, link: &LinkState) {
|
pub fn tempo_down(&self, link: &LinkState) {
|
||||||
self.tempo = (self.tempo - 1.0).max(20.0);
|
let current = link.tempo();
|
||||||
link.set_tempo(self.tempo);
|
link.set_tempo((current - 1.0).max(20.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_focus(&mut self) {
|
pub fn toggle_focus(&mut self, link: &LinkState) {
|
||||||
match self.editor_ctx.focus {
|
match self.editor_ctx.focus {
|
||||||
Focus::Sequencer => {
|
Focus::Sequencer => {
|
||||||
self.editor_ctx.focus = Focus::Editor;
|
self.editor_ctx.focus = Focus::Editor;
|
||||||
@@ -409,14 +120,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
Focus::Editor => {
|
Focus::Editor => {
|
||||||
self.save_editor_to_step();
|
self.save_editor_to_step();
|
||||||
self.compile_current_step();
|
self.compile_current_step(link);
|
||||||
self.editor_ctx.focus = Focus::Sequencer;
|
self.editor_ctx.focus = Focus::Sequencer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_edit_pattern(&self) -> &Pattern {
|
pub fn current_edit_pattern(&self) -> &Pattern {
|
||||||
self.project.pattern_at(self.editor_ctx.bank, self.editor_ctx.pattern)
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
|
self.project.pattern_at(bank, pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_step(&mut self) {
|
pub fn next_step(&mut self) {
|
||||||
@@ -464,86 +176,80 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_step(&mut self) {
|
pub fn toggle_step(&mut self) {
|
||||||
let step_idx = self.editor_ctx.step;
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
pattern_editor::toggle_step(&mut self.project, bank, pattern, self.editor_ctx.step);
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
self.mark_current_dirty();
|
||||||
step.active = !step.active;
|
|
||||||
}
|
|
||||||
self.dirty_patterns.insert((bank, pattern));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn length_increase(&mut self) {
|
pub fn length_increase(&mut self) {
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let current_len = self.project.pattern_at(bank, pattern).length;
|
pattern_editor::increase_length(&mut self.project, bank, pattern);
|
||||||
self.project
|
self.mark_current_dirty();
|
||||||
.pattern_at_mut(bank, pattern)
|
|
||||||
.set_length(current_len + 1);
|
|
||||||
self.dirty_patterns.insert((bank, pattern));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn length_decrease(&mut self) {
|
pub fn length_decrease(&mut self) {
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let current_len = self.project.pattern_at(bank, pattern).length;
|
pattern_editor::decrease_length(&mut self.project, bank, pattern);
|
||||||
self.project
|
let new_len = pattern_editor::get_length(&self.project, bank, pattern);
|
||||||
.pattern_at_mut(bank, pattern)
|
|
||||||
.set_length(current_len.saturating_sub(1));
|
|
||||||
let new_len = self.project.pattern_at(bank, pattern).length;
|
|
||||||
if self.editor_ctx.step >= new_len {
|
if self.editor_ctx.step >= new_len {
|
||||||
self.editor_ctx.step = new_len - 1;
|
self.editor_ctx.step = new_len - 1;
|
||||||
self.load_step_to_editor();
|
self.load_step_to_editor();
|
||||||
}
|
}
|
||||||
self.dirty_patterns.insert((bank, pattern));
|
self.mark_current_dirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn speed_increase(&mut self) {
|
pub fn speed_increase(&mut self) {
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let pat = self.project.pattern_at_mut(bank, pattern);
|
pattern_editor::increase_speed(&mut self.project, bank, pattern);
|
||||||
pat.speed = pat.speed.next();
|
self.mark_current_dirty();
|
||||||
self.dirty_patterns.insert((bank, pattern));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn speed_decrease(&mut self) {
|
pub fn speed_decrease(&mut self) {
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let pat = self.project.pattern_at_mut(bank, pattern);
|
pattern_editor::decrease_speed(&mut self.project, bank, pattern);
|
||||||
pat.speed = pat.speed.prev();
|
self.mark_current_dirty();
|
||||||
self.dirty_patterns.insert((bank, pattern));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_step_to_editor(&mut self) {
|
fn load_step_to_editor(&mut self) {
|
||||||
let step_idx = self.editor_ctx.step;
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
if let Some(step) = self.current_edit_pattern().step(step_idx) {
|
if let Some(script) =
|
||||||
let lines: Vec<String> = if step.script.is_empty() {
|
pattern_editor::get_step_script(&self.project, bank, pattern, self.editor_ctx.step)
|
||||||
|
{
|
||||||
|
let lines: Vec<String> = if script.is_empty() {
|
||||||
vec![String::new()]
|
vec![String::new()]
|
||||||
} else {
|
} else {
|
||||||
step.script.lines().map(String::from).collect()
|
script.lines().map(String::from).collect()
|
||||||
};
|
};
|
||||||
self.editor_ctx.text = TextArea::new(lines);
|
self.editor_ctx.text = tui_textarea::TextArea::new(lines);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_editor_to_step(&mut self) {
|
pub fn save_editor_to_step(&mut self) {
|
||||||
let text = self.editor_ctx.text.lines().join("\n");
|
let text = self.editor_ctx.text.lines().join("\n");
|
||||||
let step_idx = self.editor_ctx.step;
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
pattern_editor::set_step_script(
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
&mut self.project,
|
||||||
step.script = text;
|
bank,
|
||||||
}
|
pattern,
|
||||||
self.dirty_patterns.insert((bank, pattern));
|
self.editor_ctx.step,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
self.mark_current_dirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compile_current_step(&mut self) {
|
pub fn compile_current_step(&mut self, link: &LinkState) {
|
||||||
let step_idx = self.editor_ctx.step;
|
let step_idx = self.editor_ctx.step;
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
|
|
||||||
let script = self
|
let script = pattern_editor::get_step_script(&self.project, bank, pattern, step_idx)
|
||||||
.project
|
|
||||||
.pattern_at(bank, pattern)
|
|
||||||
.step(step_idx)
|
|
||||||
.map(|s| s.script.clone())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if script.trim().is_empty() {
|
if script.trim().is_empty() {
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
if let Some(step) = self
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.step_mut(step_idx)
|
||||||
|
{
|
||||||
step.command = None;
|
step.command = None;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -551,24 +257,32 @@ impl App {
|
|||||||
|
|
||||||
let ctx = StepContext {
|
let ctx = StepContext {
|
||||||
step: step_idx,
|
step: step_idx,
|
||||||
beat: self.beat,
|
beat: link.beat(),
|
||||||
bank,
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
tempo: self.tempo,
|
tempo: link.tempo(),
|
||||||
phase: self.phase,
|
phase: link.phase(),
|
||||||
slot: 0,
|
slot: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.script_engine.evaluate(&script, &ctx) {
|
match self.script_engine.evaluate(&script, &ctx) {
|
||||||
Ok(cmd) => {
|
Ok(cmd) => {
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
if let Some(step) = self
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.step_mut(step_idx)
|
||||||
|
{
|
||||||
step.command = Some(cmd);
|
step.command = Some(cmd);
|
||||||
}
|
}
|
||||||
self.status_message = Some("Script compiled".to_string());
|
self.status_message = Some("Script compiled".to_string());
|
||||||
self.flash_until = Some(Instant::now() + std::time::Duration::from_millis(150));
|
self.flash_until = Some(Instant::now() + std::time::Duration::from_millis(150));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
if let Some(step) = self
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.step_mut(step_idx)
|
||||||
|
{
|
||||||
step.command = None;
|
step.command = None;
|
||||||
}
|
}
|
||||||
self.status_message = Some(format!("Script error: {e}"));
|
self.status_message = Some(format!("Script error: {e}"));
|
||||||
@@ -576,20 +290,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compile_all_steps(&mut self) {
|
pub fn compile_all_steps(&mut self, link: &LinkState) {
|
||||||
let pattern_len = self.current_edit_pattern().length;
|
let pattern_len = self.current_edit_pattern().length;
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
|
|
||||||
for step_idx in 0..pattern_len {
|
for step_idx in 0..pattern_len {
|
||||||
let script = self
|
let script = pattern_editor::get_step_script(&self.project, bank, pattern, step_idx)
|
||||||
.project
|
|
||||||
.pattern_at(bank, pattern)
|
|
||||||
.step(step_idx)
|
|
||||||
.map(|s| s.script.clone())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if script.trim().is_empty() {
|
if script.trim().is_empty() {
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
if let Some(step) = self
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.step_mut(step_idx)
|
||||||
|
{
|
||||||
step.command = None;
|
step.command = None;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -600,26 +314,37 @@ impl App {
|
|||||||
beat: 0.0,
|
beat: 0.0,
|
||||||
bank,
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
tempo: self.tempo,
|
tempo: link.tempo(),
|
||||||
phase: 0.0,
|
phase: 0.0,
|
||||||
slot: 0,
|
slot: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx) {
|
if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx) {
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
if let Some(step) = self
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.step_mut(step_idx)
|
||||||
|
{
|
||||||
step.command = Some(cmd);
|
step.command = Some(cmd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_pattern_queued(&self, bank: usize, pattern: usize) -> Option<bool> {
|
pub fn is_pattern_queued(
|
||||||
|
&self,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
snapshot: &SequencerSnapshot,
|
||||||
|
) -> Option<bool> {
|
||||||
self.queued_changes.iter().find_map(|c| match c {
|
self.queued_changes.iter().find_map(|c| match c {
|
||||||
SlotChange::Add { slot: _, bank: b, pattern: p } if *b == bank && *p == pattern => {
|
SlotChange::Add {
|
||||||
Some(true)
|
slot: _,
|
||||||
}
|
bank: b,
|
||||||
|
pattern: p,
|
||||||
|
} if *b == bank && *p == pattern => Some(true),
|
||||||
SlotChange::Remove { slot } => {
|
SlotChange::Remove { slot } => {
|
||||||
let s = self.slot_data[*slot];
|
let s = snapshot.slot_data[*slot];
|
||||||
if s.active && s.bank == bank && s.pattern == pattern {
|
if s.active && s.bank == bank && s.pattern == pattern {
|
||||||
Some(false)
|
Some(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -630,8 +355,13 @@ impl App {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_pattern_playback(&mut self, bank: usize, pattern: usize) {
|
pub fn toggle_pattern_playback(
|
||||||
let playing_slot = self.slot_data.iter().enumerate().find_map(|(i, s)| {
|
&mut self,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
snapshot: &SequencerSnapshot,
|
||||||
|
) {
|
||||||
|
let playing_slot = snapshot.slot_data.iter().enumerate().find_map(|(i, s)| {
|
||||||
if s.active && s.bank == bank && s.pattern == pattern {
|
if s.active && s.bank == bank && s.pattern == pattern {
|
||||||
Some(i)
|
Some(i)
|
||||||
} else {
|
} else {
|
||||||
@@ -640,24 +370,45 @@ impl App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let pending = self.queued_changes.iter().position(|c| match c {
|
let pending = self.queued_changes.iter().position(|c| match c {
|
||||||
SlotChange::Add { bank: b, pattern: p, .. } => *b == bank && *p == pattern,
|
SlotChange::Add {
|
||||||
|
bank: b,
|
||||||
|
pattern: p,
|
||||||
|
..
|
||||||
|
} => *b == bank && *p == pattern,
|
||||||
SlotChange::Remove { slot } => {
|
SlotChange::Remove { slot } => {
|
||||||
let s = self.slot_data[*slot];
|
let s = snapshot.slot_data[*slot];
|
||||||
s.bank == bank && s.pattern == pattern
|
s.bank == bank && s.pattern == pattern
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(idx) = pending {
|
if let Some(idx) = pending {
|
||||||
self.queued_changes.remove(idx);
|
self.queued_changes.remove(idx);
|
||||||
self.status_message = Some(format!("B{:02}:P{:02} change cancelled", bank + 1, pattern + 1));
|
self.status_message = Some(format!(
|
||||||
|
"B{:02}:P{:02} change cancelled",
|
||||||
|
bank + 1,
|
||||||
|
pattern + 1
|
||||||
|
));
|
||||||
} else if let Some(slot_idx) = playing_slot {
|
} else if let Some(slot_idx) = playing_slot {
|
||||||
self.queued_changes.push(SlotChange::Remove { slot: slot_idx });
|
self.queued_changes
|
||||||
self.status_message = Some(format!("B{:02}:P{:02} queued to stop", bank + 1, pattern + 1));
|
.push(SlotChange::Remove { slot: slot_idx });
|
||||||
|
self.status_message = Some(format!(
|
||||||
|
"B{:02}:P{:02} queued to stop",
|
||||||
|
bank + 1,
|
||||||
|
pattern + 1
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
let free_slot = (0..MAX_SLOTS).find(|&i| !self.slot_data[i].active);
|
let free_slot = (0..MAX_SLOTS).find(|&i| !snapshot.slot_data[i].active);
|
||||||
if let Some(slot_idx) = free_slot {
|
if let Some(slot_idx) = free_slot {
|
||||||
self.queued_changes.push(SlotChange::Add { slot: slot_idx, bank, pattern });
|
self.queued_changes.push(SlotChange::Add {
|
||||||
self.status_message = Some(format!("B{:02}:P{:02} queued to play", bank + 1, pattern + 1));
|
slot: slot_idx,
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
});
|
||||||
|
self.status_message = Some(format!(
|
||||||
|
"B{:02}:P{:02} queued to play",
|
||||||
|
bank + 1,
|
||||||
|
pattern + 1
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
self.status_message = Some("All slots occupied".to_string());
|
self.status_message = Some("All slots occupied".to_string());
|
||||||
}
|
}
|
||||||
@@ -690,13 +441,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&mut self, path: PathBuf) {
|
pub fn load(&mut self, path: PathBuf, link: &LinkState) {
|
||||||
match file::load(&path) {
|
match file::load(&path) {
|
||||||
Ok(project) => {
|
Ok(project) => {
|
||||||
self.project = project;
|
self.project = project;
|
||||||
self.editor_ctx.step = 0;
|
self.editor_ctx.step = 0;
|
||||||
self.load_step_to_editor();
|
self.load_step_to_editor();
|
||||||
self.compile_all_steps();
|
self.compile_all_steps(link);
|
||||||
self.mark_all_patterns_dirty();
|
self.mark_all_patterns_dirty();
|
||||||
self.status_message = Some(format!("Loaded: {}", path.display()));
|
self.status_message = Some(format!("Loaded: {}", path.display()));
|
||||||
self.file_path = Some(path);
|
self.file_path = Some(path);
|
||||||
@@ -718,11 +469,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn copy_step(&mut self) {
|
pub fn copy_step(&mut self) {
|
||||||
let step_idx = self.editor_ctx.step;
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let script = self
|
let script =
|
||||||
.current_edit_pattern()
|
pattern_editor::get_step_script(&self.project, bank, pattern, self.editor_ctx.step);
|
||||||
.step(step_idx)
|
|
||||||
.map(|s| s.script.clone());
|
|
||||||
|
|
||||||
if let Some(script) = script {
|
if let Some(script) = script {
|
||||||
if let Some(clip) = &mut self.clipboard {
|
if let Some(clip) = &mut self.clipboard {
|
||||||
@@ -733,21 +482,24 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paste_step(&mut self) {
|
pub fn paste_step(&mut self, link: &LinkState) {
|
||||||
let text = self
|
let text = self
|
||||||
.clipboard
|
.clipboard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.and_then(|clip| clip.get_text().ok());
|
.and_then(|clip| clip.get_text().ok());
|
||||||
|
|
||||||
if let Some(text) = text {
|
if let Some(text) = text {
|
||||||
let step_idx = self.editor_ctx.step;
|
let (bank, pattern) = self.current_bank_pattern();
|
||||||
let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
|
pattern_editor::set_step_script(
|
||||||
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
|
&mut self.project,
|
||||||
step.script = text;
|
bank,
|
||||||
}
|
pattern,
|
||||||
self.dirty_patterns.insert((bank, pattern));
|
self.editor_ctx.step,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
self.mark_current_dirty();
|
||||||
self.load_step_to_editor();
|
self.load_step_to_editor();
|
||||||
self.compile_current_step();
|
self.compile_current_step(link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,7 +508,9 @@ impl App {
|
|||||||
PatternField::Length => self.current_edit_pattern().length.to_string(),
|
PatternField::Length => self.current_edit_pattern().length.to_string(),
|
||||||
PatternField::Speed => self.current_edit_pattern().speed.label().to_string(),
|
PatternField::Speed => self.current_edit_pattern().speed.label().to_string(),
|
||||||
};
|
};
|
||||||
self.modal = Modal::SetPattern { field, input: current };
|
self.modal = Modal::SetPattern {
|
||||||
|
field,
|
||||||
|
input: current,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
416
seq/src/input.rs
Normal file
416
seq/src/input.rs
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
use crossbeam_channel::Sender;
|
||||||
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::link::LinkState;
|
||||||
|
use crate::model::PatternSpeed;
|
||||||
|
use crate::page::Page;
|
||||||
|
use crate::sequencer::{AudioCommand, SequencerSnapshot};
|
||||||
|
use crate::state::{AudioFocus, Focus, Modal, PatternField, PatternsViewLevel};
|
||||||
|
use crate::views::doc_view;
|
||||||
|
|
||||||
|
pub enum InputResult {
|
||||||
|
Continue,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InputContext<'a> {
|
||||||
|
pub app: &'a mut App,
|
||||||
|
pub link: &'a LinkState,
|
||||||
|
pub snapshot: &'a SequencerSnapshot,
|
||||||
|
pub playing: &'a Arc<AtomicBool>,
|
||||||
|
pub audio_tx: &'a Sender<AudioCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
ctx.app.clear_status();
|
||||||
|
|
||||||
|
if matches!(ctx.app.modal, Modal::None) {
|
||||||
|
handle_normal_input(ctx, key)
|
||||||
|
} else {
|
||||||
|
handle_modal_input(ctx, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
match &mut ctx.app.modal {
|
||||||
|
Modal::ConfirmQuit { selected } => match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('Y') => return InputResult::Quit,
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Left | KeyCode::Right => {
|
||||||
|
*selected = !*selected;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if *selected {
|
||||||
|
return InputResult::Quit;
|
||||||
|
} else {
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::SaveAs(path) => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let save_path = PathBuf::from(path.as_str());
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
ctx.app.save(save_path);
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.app.modal = Modal::None,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => path.push(c),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::LoadFrom(path) => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let load_path = PathBuf::from(path.as_str());
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
ctx.app.load(load_path, ctx.link);
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.app.modal = Modal::None,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => path.push(c),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::RenameBank { bank, name } => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let bank_idx = *bank;
|
||||||
|
let new_name = if name.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name.clone())
|
||||||
|
};
|
||||||
|
ctx.app.project.banks[bank_idx].name = new_name;
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.app.modal = Modal::None,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
name.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => name.push(c),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::RenamePattern {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
name,
|
||||||
|
} => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let (bank_idx, pattern_idx) = (*bank, *pattern);
|
||||||
|
let new_name = if name.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name.clone())
|
||||||
|
};
|
||||||
|
ctx.app.project.banks[bank_idx].patterns[pattern_idx].name = new_name;
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.app.modal = Modal::None,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
name.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => name.push(c),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::SetPattern { field, input } => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let field = *field;
|
||||||
|
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||||
|
match field {
|
||||||
|
PatternField::Length => {
|
||||||
|
if let Ok(len) = input.parse::<usize>() {
|
||||||
|
ctx.app
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.set_length(len);
|
||||||
|
let new_len = ctx.app.project.pattern_at(bank, pattern).length;
|
||||||
|
if ctx.app.editor_ctx.step >= new_len {
|
||||||
|
ctx.app.editor_ctx.step = new_len - 1;
|
||||||
|
}
|
||||||
|
ctx.app.dirty_patterns.insert((bank, pattern));
|
||||||
|
ctx.app.status_message = Some(format!("Length set to {new_len}"));
|
||||||
|
} else {
|
||||||
|
ctx.app.status_message = Some("Invalid length".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PatternField::Speed => {
|
||||||
|
if let Some(speed) = PatternSpeed::from_label(input) {
|
||||||
|
ctx.app.project.pattern_at_mut(bank, pattern).speed = speed;
|
||||||
|
ctx.app.dirty_patterns.insert((bank, pattern));
|
||||||
|
ctx.app.status_message =
|
||||||
|
Some(format!("Speed set to {}", speed.label()));
|
||||||
|
} else {
|
||||||
|
ctx.app.status_message = Some(
|
||||||
|
"Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.app.modal = Modal::None,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
input.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => input.push(c),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::AddSamplePath(path) => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let sample_path = PathBuf::from(path.as_str());
|
||||||
|
if sample_path.is_dir() {
|
||||||
|
let index = doux::loader::scan_samples_dir(&sample_path);
|
||||||
|
let count = index.len();
|
||||||
|
let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index));
|
||||||
|
ctx.app.audio.config.sample_count += count;
|
||||||
|
ctx.app.audio.add_sample_path(sample_path);
|
||||||
|
ctx.app.status_message = Some(format!("Added {count} samples"));
|
||||||
|
} else {
|
||||||
|
ctx.app.status_message = Some("Path is not a directory".to_string());
|
||||||
|
}
|
||||||
|
ctx.app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.app.modal = Modal::None,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => path.push(c),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::None => unreachable!(),
|
||||||
|
}
|
||||||
|
InputResult::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
// Global navigation with Ctrl+arrows
|
||||||
|
if ctrl {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left => {
|
||||||
|
ctx.app.page.left();
|
||||||
|
return InputResult::Continue;
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
ctx.app.page.right();
|
||||||
|
return InputResult::Continue;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
ctx.app.page.up();
|
||||||
|
return InputResult::Continue;
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
ctx.app.page.down();
|
||||||
|
return InputResult::Continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match ctx.app.page {
|
||||||
|
Page::Main => handle_main_page(ctx, key, ctrl),
|
||||||
|
Page::Patterns => handle_patterns_page(ctx, key),
|
||||||
|
Page::Audio => handle_audio_page(ctx, key),
|
||||||
|
Page::Doc => handle_doc_page(ctx, key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult {
|
||||||
|
match ctx.app.editor_ctx.focus {
|
||||||
|
Focus::Sequencer => match key.code {
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
ctx.app.modal = Modal::ConfirmQuit { selected: false };
|
||||||
|
}
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
ctx.app.toggle_playing();
|
||||||
|
ctx.playing.store(ctx.app.playing, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
KeyCode::Tab => ctx.app.toggle_focus(ctx.link),
|
||||||
|
KeyCode::Left => ctx.app.prev_step(),
|
||||||
|
KeyCode::Right => ctx.app.next_step(),
|
||||||
|
KeyCode::Up => ctx.app.step_up(),
|
||||||
|
KeyCode::Down => ctx.app.step_down(),
|
||||||
|
KeyCode::Enter => ctx.app.toggle_step(),
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
let default = ctx
|
||||||
|
.app
|
||||||
|
.file_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "project.buboseq".to_string());
|
||||||
|
ctx.app.modal = Modal::SaveAs(default);
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') => {
|
||||||
|
ctx.app.modal = Modal::LoadFrom(String::new());
|
||||||
|
}
|
||||||
|
KeyCode::Char('+') | KeyCode::Char('=') => ctx.app.tempo_up(ctx.link),
|
||||||
|
KeyCode::Char('-') => ctx.app.tempo_down(ctx.link),
|
||||||
|
KeyCode::Char('<') | KeyCode::Char(',') => ctx.app.length_decrease(),
|
||||||
|
KeyCode::Char('>') | KeyCode::Char('.') => ctx.app.length_increase(),
|
||||||
|
KeyCode::Char('[') => ctx.app.speed_decrease(),
|
||||||
|
KeyCode::Char(']') => ctx.app.speed_increase(),
|
||||||
|
KeyCode::Char('L') => ctx.app.open_pattern_modal(PatternField::Length),
|
||||||
|
KeyCode::Char('S') => ctx.app.open_pattern_modal(PatternField::Speed),
|
||||||
|
KeyCode::Char('c') if ctrl => ctx.app.copy_step(),
|
||||||
|
KeyCode::Char('v') if ctrl => ctx.app.paste_step(ctx.link),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Focus::Editor => match key.code {
|
||||||
|
KeyCode::Tab | KeyCode::Esc => ctx.app.toggle_focus(ctx.link),
|
||||||
|
KeyCode::Char('e') if ctrl => {
|
||||||
|
ctx.app.save_editor_to_step();
|
||||||
|
ctx.app.compile_current_step(ctx.link);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ctx.app.editor_ctx.text.input(Event::Key(key));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
InputResult::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left => ctx.app.patterns_cursor = (ctx.app.patterns_cursor + 15) % 16,
|
||||||
|
KeyCode::Right => ctx.app.patterns_cursor = (ctx.app.patterns_cursor + 1) % 16,
|
||||||
|
KeyCode::Up => ctx.app.patterns_cursor = (ctx.app.patterns_cursor + 12) % 16,
|
||||||
|
KeyCode::Down => ctx.app.patterns_cursor = (ctx.app.patterns_cursor + 4) % 16,
|
||||||
|
KeyCode::Esc | KeyCode::Backspace => match ctx.app.patterns_view_level {
|
||||||
|
PatternsViewLevel::Banks => ctx.app.page.down(),
|
||||||
|
PatternsViewLevel::Patterns { .. } => {
|
||||||
|
ctx.app.patterns_view_level = PatternsViewLevel::Banks;
|
||||||
|
ctx.app.patterns_cursor = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Enter => match ctx.app.patterns_view_level {
|
||||||
|
PatternsViewLevel::Banks => {
|
||||||
|
let bank = ctx.app.patterns_cursor;
|
||||||
|
ctx.app.patterns_view_level = PatternsViewLevel::Patterns { bank };
|
||||||
|
ctx.app.patterns_cursor = 0;
|
||||||
|
}
|
||||||
|
PatternsViewLevel::Patterns { bank } => {
|
||||||
|
let pattern = ctx.app.patterns_cursor;
|
||||||
|
ctx.app.select_edit_bank(bank);
|
||||||
|
ctx.app.select_edit_pattern(pattern);
|
||||||
|
ctx.app.patterns_view_level = PatternsViewLevel::Banks;
|
||||||
|
ctx.app.patterns_cursor = 0;
|
||||||
|
ctx.app.page.down();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if let PatternsViewLevel::Patterns { bank } = ctx.app.patterns_view_level {
|
||||||
|
let pattern = ctx.app.patterns_cursor;
|
||||||
|
ctx.app.toggle_pattern_playback(bank, pattern, ctx.snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
ctx.app.modal = Modal::ConfirmQuit { selected: false };
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => match ctx.app.patterns_view_level {
|
||||||
|
PatternsViewLevel::Banks => {
|
||||||
|
let bank = ctx.app.patterns_cursor;
|
||||||
|
let current_name = ctx.app.project.banks[bank].name.clone().unwrap_or_default();
|
||||||
|
ctx.app.modal = Modal::RenameBank {
|
||||||
|
bank,
|
||||||
|
name: current_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PatternsViewLevel::Patterns { bank } => {
|
||||||
|
let pattern = ctx.app.patterns_cursor;
|
||||||
|
let current_name = ctx.app.project.banks[bank].patterns[pattern]
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default();
|
||||||
|
ctx.app.modal = Modal::RenamePattern {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
name: current_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
InputResult::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
ctx.app.modal = Modal::ConfirmQuit { selected: false };
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => ctx.app.audio.prev_focus(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => ctx.app.audio.next_focus(),
|
||||||
|
KeyCode::Left => match ctx.app.audio.focus {
|
||||||
|
AudioFocus::OutputDevice => ctx.app.audio.prev_output_device(),
|
||||||
|
AudioFocus::InputDevice => ctx.app.audio.prev_input_device(),
|
||||||
|
AudioFocus::Channels => ctx.app.audio.adjust_channels(-1),
|
||||||
|
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
|
||||||
|
AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(),
|
||||||
|
},
|
||||||
|
KeyCode::Right => match ctx.app.audio.focus {
|
||||||
|
AudioFocus::OutputDevice => ctx.app.audio.next_output_device(),
|
||||||
|
AudioFocus::InputDevice => ctx.app.audio.next_input_device(),
|
||||||
|
AudioFocus::Channels => ctx.app.audio.adjust_channels(1),
|
||||||
|
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64),
|
||||||
|
AudioFocus::SamplePaths => {}
|
||||||
|
},
|
||||||
|
KeyCode::Char('R') => ctx.app.audio.trigger_restart(),
|
||||||
|
KeyCode::Char('A') => ctx.app.modal = Modal::AddSamplePath(String::new()),
|
||||||
|
KeyCode::Char('D') => {
|
||||||
|
ctx.app.audio.refresh_devices();
|
||||||
|
let out_count = ctx.app.audio.output_devices.len();
|
||||||
|
let in_count = ctx.app.audio.input_devices.len();
|
||||||
|
ctx.app.status_message = Some(format!(
|
||||||
|
"Found {out_count} output, {in_count} input devices"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
KeyCode::Char('h') => {
|
||||||
|
let _ = ctx.audio_tx.send(AudioCommand::Hush);
|
||||||
|
}
|
||||||
|
KeyCode::Char('p') => {
|
||||||
|
let _ = ctx.audio_tx.send(AudioCommand::Panic);
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0,
|
||||||
|
KeyCode::Char('t') => {
|
||||||
|
let _ = ctx
|
||||||
|
.audio_tx
|
||||||
|
.send(AudioCommand::Evaluate("sin 440 * 0.3".into()));
|
||||||
|
}
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
ctx.app.toggle_playing();
|
||||||
|
ctx.playing.store(ctx.app.playing, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
InputResult::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
let topic_count = doc_view::topic_count();
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
|
ctx.app.doc_topic = (ctx.app.doc_topic + 1) % topic_count;
|
||||||
|
ctx.app.doc_scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
|
ctx.app.doc_topic = (ctx.app.doc_topic + topic_count - 1) % topic_count;
|
||||||
|
ctx.app.doc_scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => ctx.app.doc_scroll = ctx.app.doc_scroll.saturating_add(10),
|
||||||
|
KeyCode::PageUp => ctx.app.doc_scroll = ctx.app.doc_scroll.saturating_sub(10),
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
ctx.app.modal = Modal::ConfirmQuit { selected: false };
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
InputResult::Continue
|
||||||
|
}
|
||||||
@@ -19,15 +19,28 @@ impl LinkState {
|
|||||||
self.link.clock_micros()
|
self.link.clock_micros()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn query(&self) -> (f64, f64, f64, u64) {
|
pub fn tempo(&self) -> f64 {
|
||||||
|
let mut state = SessionState::new();
|
||||||
|
self.link.capture_app_session_state(&mut state);
|
||||||
|
state.tempo()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn beat(&self) -> f64 {
|
||||||
let mut state = SessionState::new();
|
let mut state = SessionState::new();
|
||||||
self.link.capture_app_session_state(&mut state);
|
self.link.capture_app_session_state(&mut state);
|
||||||
let time = self.link.clock_micros();
|
let time = self.link.clock_micros();
|
||||||
let tempo = state.tempo();
|
state.beat_at_time(time, self.quantum)
|
||||||
let beat = state.beat_at_time(time, self.quantum);
|
}
|
||||||
let phase = state.phase_at_time(time, self.quantum);
|
|
||||||
let peers = self.link.num_peers();
|
pub fn phase(&self) -> f64 {
|
||||||
(tempo, beat, phase, peers)
|
let mut state = SessionState::new();
|
||||||
|
self.link.capture_app_session_state(&mut state);
|
||||||
|
let time = self.link.clock_micros();
|
||||||
|
state.phase_at_time(time, self.quantum)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn peers(&self) -> u64 {
|
||||||
|
self.link.num_peers()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_tempo(&self, tempo: f64) {
|
pub fn set_tempo(&self, tempo: f64) {
|
||||||
|
|||||||
441
seq/src/main.rs
441
seq/src/main.rs
@@ -2,11 +2,14 @@ mod app;
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod config;
|
mod config;
|
||||||
mod file;
|
mod file;
|
||||||
|
mod input;
|
||||||
mod link;
|
mod link;
|
||||||
mod model;
|
mod model;
|
||||||
mod page;
|
mod page;
|
||||||
mod script;
|
mod script;
|
||||||
mod sequencer;
|
mod sequencer;
|
||||||
|
mod services;
|
||||||
|
mod state;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod views;
|
mod views;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
@@ -18,7 +21,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
use crossterm::event::{self, Event};
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
};
|
};
|
||||||
@@ -27,11 +30,11 @@ use doux::EngineMetrics;
|
|||||||
use ratatui::prelude::CrosstermBackend;
|
use ratatui::prelude::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
||||||
use app::{App, AudioFocus, Focus, Modal, PatternField};
|
use app::App;
|
||||||
use audio::{AudioStreamConfig, ScopeBuffer};
|
use audio::{AudioStreamConfig, ScopeBuffer};
|
||||||
|
use input::{handle_key, InputContext, InputResult};
|
||||||
use link::LinkState;
|
use link::LinkState;
|
||||||
use page::Page;
|
use sequencer::{spawn_sequencer, PatternSnapshot, SeqCommand, SlotChange, StepSnapshot};
|
||||||
use sequencer::{spawn_sequencer, AudioCommand, PatternSnapshot, SeqCommand, StepSnapshot};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "seq", about = "A step sequencer with Ableton Link support")]
|
#[command(name = "seq", about = "A step sequencer with Ableton Link support")]
|
||||||
@@ -67,7 +70,7 @@ fn main() -> io::Result<()> {
|
|||||||
|
|
||||||
let playing = Arc::new(AtomicBool::new(true));
|
let playing = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
let mut app = App::new(DEFAULT_TEMPO, DEFAULT_QUANTUM);
|
let mut app = App::new();
|
||||||
|
|
||||||
app.audio.config.output_device = args.output;
|
app.audio.config.output_device = args.output;
|
||||||
app.audio.config.input_device = args.input;
|
app.audio.config.input_device = args.input;
|
||||||
@@ -171,7 +174,6 @@ fn main() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.update_from_link(&link);
|
|
||||||
app.playing = playing.load(Ordering::Relaxed);
|
app.playing = playing.load(Ordering::Relaxed);
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -183,16 +185,22 @@ fn main() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let seq_snapshot = sequencer.snapshot();
|
let seq_snapshot = sequencer.snapshot();
|
||||||
app.slot_data = seq_snapshot.slot_data;
|
|
||||||
app.slot_steps = seq_snapshot.slot_steps;
|
|
||||||
app.metrics.event_count = seq_snapshot.event_count;
|
app.metrics.event_count = seq_snapshot.event_count;
|
||||||
|
|
||||||
for change in app.queued_changes.drain(..) {
|
for change in app.queued_changes.drain(..) {
|
||||||
match change {
|
match change {
|
||||||
app::SlotChange::Add { slot, bank, pattern } => {
|
SlotChange::Add {
|
||||||
let _ = sequencer.cmd_tx.send(SeqCommand::SlotAdd { slot, bank, pattern });
|
slot,
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
} => {
|
||||||
|
let _ = sequencer.cmd_tx.send(SeqCommand::SlotAdd {
|
||||||
|
slot,
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
app::SlotChange::Remove { slot } => {
|
SlotChange::Remove { slot } => {
|
||||||
let _ = sequencer.cmd_tx.send(SeqCommand::SlotRemove { slot });
|
let _ = sequencer.cmd_tx.send(SeqCommand::SlotRemove { slot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,400 +211,37 @@ fn main() -> io::Result<()> {
|
|||||||
let snapshot = PatternSnapshot {
|
let snapshot = PatternSnapshot {
|
||||||
speed: pat.speed,
|
speed: pat.speed,
|
||||||
length: pat.length,
|
length: pat.length,
|
||||||
steps: pat.steps.iter().take(pat.length).map(|s| StepSnapshot {
|
steps: pat
|
||||||
active: s.active,
|
.steps
|
||||||
script: s.script.clone(),
|
.iter()
|
||||||
}).collect(),
|
.take(pat.length)
|
||||||
|
.map(|s| StepSnapshot {
|
||||||
|
active: s.active,
|
||||||
|
script: s.script.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
};
|
};
|
||||||
let _ = sequencer.cmd_tx.send(SeqCommand::PatternUpdate { bank, pattern, data: snapshot });
|
let _ = sequencer.cmd_tx.send(SeqCommand::PatternUpdate {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
data: snapshot,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.draw(|frame| ui::render(frame, &mut app))?;
|
terminal.draw(|frame| ui::render(frame, &mut app, &link, &seq_snapshot))?;
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(16))? {
|
if event::poll(Duration::from_millis(16))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
app.clear_status();
|
let mut ctx = InputContext {
|
||||||
|
app: &mut app,
|
||||||
|
link: &link,
|
||||||
|
snapshot: &seq_snapshot,
|
||||||
|
playing: &playing,
|
||||||
|
audio_tx: &sequencer.audio_tx,
|
||||||
|
};
|
||||||
|
|
||||||
match &mut app.modal {
|
if let InputResult::Quit = handle_key(&mut ctx, key) {
|
||||||
Modal::ConfirmQuit { ref mut selected } => match key.code {
|
break;
|
||||||
KeyCode::Char('y') | KeyCode::Char('Y') => break,
|
|
||||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Left | KeyCode::Right => {
|
|
||||||
*selected = !*selected;
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
if *selected {
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::SaveAs(path) => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let save_path = PathBuf::from(path.as_str());
|
|
||||||
app.modal = Modal::None;
|
|
||||||
app.save(save_path);
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
path.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
path.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::LoadFrom(path) => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let load_path = PathBuf::from(path.as_str());
|
|
||||||
app.modal = Modal::None;
|
|
||||||
app.load(load_path);
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
path.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
path.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::RenameBank { bank, name } => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let bank_idx = *bank;
|
|
||||||
let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) };
|
|
||||||
app.project.banks[bank_idx].name = new_name;
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
name.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
name.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::RenamePattern { bank, pattern, name } => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let (bank_idx, pattern_idx) = (*bank, *pattern);
|
|
||||||
let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) };
|
|
||||||
app.project.banks[bank_idx].patterns[pattern_idx].name = new_name;
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
name.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
name.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::SetPattern { field, input } => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let field = *field;
|
|
||||||
let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern);
|
|
||||||
match field {
|
|
||||||
PatternField::Length => {
|
|
||||||
if let Ok(len) = input.parse::<usize>() {
|
|
||||||
app.project.pattern_at_mut(bank, pattern).set_length(len);
|
|
||||||
let new_len = app.project.pattern_at(bank, pattern).length;
|
|
||||||
if app.editor_ctx.step >= new_len {
|
|
||||||
app.editor_ctx.step = new_len - 1;
|
|
||||||
}
|
|
||||||
app.dirty_patterns.insert((bank, pattern));
|
|
||||||
app.status_message = Some(format!("Length set to {new_len}"));
|
|
||||||
} else {
|
|
||||||
app.status_message = Some("Invalid length".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PatternField::Speed => {
|
|
||||||
if let Some(speed) = model::PatternSpeed::from_label(input) {
|
|
||||||
app.project.pattern_at_mut(bank, pattern).speed = speed;
|
|
||||||
app.dirty_patterns.insert((bank, pattern));
|
|
||||||
app.status_message = Some(format!("Speed set to {}", speed.label()));
|
|
||||||
} else {
|
|
||||||
app.status_message = Some("Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
input.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
input.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::AddSamplePath(path) => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let sample_path = PathBuf::from(path.as_str());
|
|
||||||
if sample_path.is_dir() {
|
|
||||||
let index = doux::loader::scan_samples_dir(&sample_path);
|
|
||||||
let count = index.len();
|
|
||||||
let _ = sequencer.audio_tx.send(AudioCommand::LoadSamples(index));
|
|
||||||
app.audio.config.sample_count += count;
|
|
||||||
app.audio.add_sample_path(sample_path);
|
|
||||||
app.status_message = Some(format!("Added {count} samples"));
|
|
||||||
} else {
|
|
||||||
app.status_message = Some("Path is not a directory".to_string());
|
|
||||||
}
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.modal = Modal::None;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
path.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
path.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::None => {
|
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
||||||
|
|
||||||
if ctrl && key.code == KeyCode::Left {
|
|
||||||
app.page.left();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ctrl && key.code == KeyCode::Right {
|
|
||||||
app.page.right();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ctrl && key.code == KeyCode::Up {
|
|
||||||
app.page.up();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ctrl && key.code == KeyCode::Down {
|
|
||||||
app.page.down();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match app.page {
|
|
||||||
Page::Main => match app.editor_ctx.focus {
|
|
||||||
Focus::Sequencer => match key.code {
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
app.modal = Modal::ConfirmQuit { selected: false };
|
|
||||||
}
|
|
||||||
KeyCode::Char(' ') => {
|
|
||||||
app.toggle_playing();
|
|
||||||
playing.store(app.playing, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
KeyCode::Tab => app.toggle_focus(),
|
|
||||||
KeyCode::Left => app.prev_step(),
|
|
||||||
KeyCode::Right => app.next_step(),
|
|
||||||
KeyCode::Up => app.step_up(),
|
|
||||||
KeyCode::Down => app.step_down(),
|
|
||||||
KeyCode::Enter => app.toggle_step(),
|
|
||||||
KeyCode::Char('s') => {
|
|
||||||
let default = app
|
|
||||||
.file_path
|
|
||||||
.as_ref()
|
|
||||||
.map(|p| p.display().to_string())
|
|
||||||
.unwrap_or_else(|| "project.buboseq".to_string());
|
|
||||||
app.modal = Modal::SaveAs(default);
|
|
||||||
}
|
|
||||||
KeyCode::Char('l') => {
|
|
||||||
app.modal = Modal::LoadFrom(String::new());
|
|
||||||
}
|
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => app.tempo_up(&link),
|
|
||||||
KeyCode::Char('-') => app.tempo_down(&link),
|
|
||||||
KeyCode::Char('<') | KeyCode::Char(',') => {
|
|
||||||
app.length_decrease()
|
|
||||||
}
|
|
||||||
KeyCode::Char('>') | KeyCode::Char('.') => {
|
|
||||||
app.length_increase()
|
|
||||||
}
|
|
||||||
KeyCode::Char('[') => app.speed_decrease(),
|
|
||||||
KeyCode::Char(']') => app.speed_increase(),
|
|
||||||
KeyCode::Char('L') => app.open_pattern_modal(PatternField::Length),
|
|
||||||
KeyCode::Char('S') => app.open_pattern_modal(PatternField::Speed),
|
|
||||||
KeyCode::Char('c') if ctrl => app.copy_step(),
|
|
||||||
KeyCode::Char('v') if ctrl => app.paste_step(),
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Focus::Editor => match key.code {
|
|
||||||
KeyCode::Tab | KeyCode::Esc => app.toggle_focus(),
|
|
||||||
KeyCode::Char('e') if ctrl => {
|
|
||||||
app.save_editor_to_step();
|
|
||||||
app.compile_current_step();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
app.editor_ctx.text.input(Event::Key(key));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Page::Patterns => {
|
|
||||||
use app::PatternsViewLevel;
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Left => {
|
|
||||||
app.patterns_cursor = (app.patterns_cursor + 15) % 16;
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
app.patterns_cursor = (app.patterns_cursor + 1) % 16;
|
|
||||||
}
|
|
||||||
KeyCode::Up => {
|
|
||||||
app.patterns_cursor = (app.patterns_cursor + 12) % 16;
|
|
||||||
}
|
|
||||||
KeyCode::Down => {
|
|
||||||
app.patterns_cursor = (app.patterns_cursor + 4) % 16;
|
|
||||||
}
|
|
||||||
KeyCode::Esc | KeyCode::Backspace => {
|
|
||||||
match app.patterns_view_level {
|
|
||||||
PatternsViewLevel::Banks => {
|
|
||||||
app.page.down();
|
|
||||||
}
|
|
||||||
PatternsViewLevel::Patterns { .. } => {
|
|
||||||
app.patterns_view_level = PatternsViewLevel::Banks;
|
|
||||||
app.patterns_cursor = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
match app.patterns_view_level {
|
|
||||||
PatternsViewLevel::Banks => {
|
|
||||||
let bank = app.patterns_cursor;
|
|
||||||
app.patterns_view_level =
|
|
||||||
PatternsViewLevel::Patterns { bank };
|
|
||||||
app.patterns_cursor = 0;
|
|
||||||
}
|
|
||||||
PatternsViewLevel::Patterns { bank } => {
|
|
||||||
let pattern = app.patterns_cursor;
|
|
||||||
app.select_edit_bank(bank);
|
|
||||||
app.select_edit_pattern(pattern);
|
|
||||||
app.patterns_view_level = PatternsViewLevel::Banks;
|
|
||||||
app.patterns_cursor = 0;
|
|
||||||
app.page.down();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char(' ') => {
|
|
||||||
if let PatternsViewLevel::Patterns { bank } =
|
|
||||||
app.patterns_view_level
|
|
||||||
{
|
|
||||||
let pattern = app.patterns_cursor;
|
|
||||||
app.toggle_pattern_playback(bank, pattern);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
app.modal = Modal::ConfirmQuit { selected: false };
|
|
||||||
}
|
|
||||||
KeyCode::Char('r') => {
|
|
||||||
match app.patterns_view_level {
|
|
||||||
PatternsViewLevel::Banks => {
|
|
||||||
let bank = app.patterns_cursor;
|
|
||||||
let current_name = app.project.banks[bank].name.clone().unwrap_or_default();
|
|
||||||
app.modal = Modal::RenameBank { bank, name: current_name };
|
|
||||||
}
|
|
||||||
PatternsViewLevel::Patterns { bank } => {
|
|
||||||
let pattern = app.patterns_cursor;
|
|
||||||
let current_name = app.project.banks[bank].patterns[pattern].name.clone().unwrap_or_default();
|
|
||||||
app.modal = Modal::RenamePattern { bank, pattern, name: current_name };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Page::Audio => match key.code {
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
app.modal = Modal::ConfirmQuit { selected: false };
|
|
||||||
}
|
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
|
||||||
app.audio.prev_focus();
|
|
||||||
}
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
|
||||||
app.audio.next_focus();
|
|
||||||
}
|
|
||||||
KeyCode::Left => match app.audio.focus {
|
|
||||||
AudioFocus::OutputDevice => app.audio.prev_output_device(),
|
|
||||||
AudioFocus::InputDevice => app.audio.prev_input_device(),
|
|
||||||
AudioFocus::Channels => app.audio.adjust_channels(-1),
|
|
||||||
AudioFocus::BufferSize => app.audio.adjust_buffer_size(-64),
|
|
||||||
AudioFocus::SamplePaths => app.audio.remove_last_sample_path(),
|
|
||||||
},
|
|
||||||
KeyCode::Right => match app.audio.focus {
|
|
||||||
AudioFocus::OutputDevice => app.audio.next_output_device(),
|
|
||||||
AudioFocus::InputDevice => app.audio.next_input_device(),
|
|
||||||
AudioFocus::Channels => app.audio.adjust_channels(1),
|
|
||||||
AudioFocus::BufferSize => app.audio.adjust_buffer_size(64),
|
|
||||||
AudioFocus::SamplePaths => {}
|
|
||||||
},
|
|
||||||
KeyCode::Char('R') => {
|
|
||||||
app.audio.trigger_restart();
|
|
||||||
}
|
|
||||||
KeyCode::Char('A') => {
|
|
||||||
app.modal = Modal::AddSamplePath(String::new());
|
|
||||||
}
|
|
||||||
KeyCode::Char('D') => {
|
|
||||||
app.audio.refresh_devices();
|
|
||||||
let out_count = app.audio.output_devices.len();
|
|
||||||
let in_count = app.audio.input_devices.len();
|
|
||||||
app.status_message = Some(format!("Found {out_count} output, {in_count} input devices"));
|
|
||||||
}
|
|
||||||
KeyCode::Char('h') => {
|
|
||||||
let _ = sequencer.audio_tx.send(AudioCommand::Hush);
|
|
||||||
}
|
|
||||||
KeyCode::Char('p') => {
|
|
||||||
let _ = sequencer.audio_tx.send(AudioCommand::Panic);
|
|
||||||
}
|
|
||||||
KeyCode::Char('r') => {
|
|
||||||
app.metrics.peak_voices = 0;
|
|
||||||
}
|
|
||||||
KeyCode::Char('t') => {
|
|
||||||
let _ = sequencer.audio_tx.send(AudioCommand::Evaluate("sin 440 * 0.3".into()));
|
|
||||||
}
|
|
||||||
KeyCode::Char(' ') => {
|
|
||||||
app.toggle_playing();
|
|
||||||
playing.store(app.playing, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Page::Doc => {
|
|
||||||
let topic_count = views::doc_view::topic_count();
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('j') | KeyCode::Down => {
|
|
||||||
app.doc_topic = (app.doc_topic + 1) % topic_count;
|
|
||||||
app.doc_scroll = 0;
|
|
||||||
}
|
|
||||||
KeyCode::Char('k') | KeyCode::Up => {
|
|
||||||
app.doc_topic = (app.doc_topic + topic_count - 1) % topic_count;
|
|
||||||
app.doc_scroll = 0;
|
|
||||||
}
|
|
||||||
KeyCode::PageDown => {
|
|
||||||
app.doc_scroll = app.doc_scroll.saturating_add(10);
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => {
|
|
||||||
app.doc_scroll = app.doc_scroll.saturating_sub(10);
|
|
||||||
}
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
app.modal = Modal::ConfirmQuit { selected: false };
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,19 @@ use std::time::Duration;
|
|||||||
use crate::audio::PatternSlot;
|
use crate::audio::PatternSlot;
|
||||||
use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS};
|
use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS};
|
||||||
use crate::link::LinkState;
|
use crate::link::LinkState;
|
||||||
use crate::script::{ScriptEngine, StepContext, Variables, Rng};
|
use crate::script::{Rng, ScriptEngine, StepContext, Variables};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SlotChange {
|
||||||
|
Add {
|
||||||
|
slot: usize,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
slot: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
pub enum AudioCommand {
|
pub enum AudioCommand {
|
||||||
Evaluate(String),
|
Evaluate(String),
|
||||||
@@ -19,9 +31,19 @@ pub enum AudioCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub enum SeqCommand {
|
pub enum SeqCommand {
|
||||||
PatternUpdate { bank: usize, pattern: usize, data: PatternSnapshot },
|
PatternUpdate {
|
||||||
SlotAdd { slot: usize, bank: usize, pattern: usize },
|
bank: usize,
|
||||||
SlotRemove { slot: usize },
|
pattern: usize,
|
||||||
|
data: PatternSnapshot,
|
||||||
|
},
|
||||||
|
SlotAdd {
|
||||||
|
slot: usize,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
},
|
||||||
|
SlotRemove {
|
||||||
|
slot: usize,
|
||||||
|
},
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +135,14 @@ struct AudioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum PendingChange {
|
enum PendingChange {
|
||||||
Add { slot: usize, bank: usize, pattern: usize },
|
Add {
|
||||||
Remove { slot: usize },
|
slot: usize,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
slot: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioState {
|
impl AudioState {
|
||||||
@@ -189,7 +217,10 @@ impl PatternCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get(&self, bank: usize, pattern: usize) -> Option<&PatternSnapshot> {
|
fn get(&self, bank: usize, pattern: usize) -> Option<&PatternSnapshot> {
|
||||||
self.patterns.get(bank).and_then(|b| b.get(pattern)).and_then(|p| p.as_ref())
|
self.patterns
|
||||||
|
.get(bank)
|
||||||
|
.and_then(|b| b.get(pattern))
|
||||||
|
.and_then(|p| p.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set(&mut self, bank: usize, pattern: usize, data: PatternSnapshot) {
|
fn set(&mut self, bank: usize, pattern: usize, data: PatternSnapshot) {
|
||||||
@@ -218,14 +249,28 @@ fn sequencer_loop(
|
|||||||
loop {
|
loop {
|
||||||
while let Ok(cmd) = cmd_rx.try_recv() {
|
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||||
match cmd {
|
match cmd {
|
||||||
SeqCommand::PatternUpdate { bank, pattern, data } => {
|
SeqCommand::PatternUpdate {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
data,
|
||||||
|
} => {
|
||||||
pattern_cache.set(bank, pattern, data);
|
pattern_cache.set(bank, pattern, data);
|
||||||
}
|
}
|
||||||
SeqCommand::SlotAdd { slot, bank, pattern } => {
|
SeqCommand::SlotAdd {
|
||||||
audio_state.pending_changes.push(PendingChange::Add { slot, bank, pattern });
|
slot,
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
} => {
|
||||||
|
audio_state.pending_changes.push(PendingChange::Add {
|
||||||
|
slot,
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
SeqCommand::SlotRemove { slot } => {
|
SeqCommand::SlotRemove { slot } => {
|
||||||
audio_state.pending_changes.push(PendingChange::Remove { slot });
|
audio_state
|
||||||
|
.pending_changes
|
||||||
|
.push(PendingChange::Remove { slot });
|
||||||
}
|
}
|
||||||
SeqCommand::Shutdown => {
|
SeqCommand::Shutdown => {
|
||||||
return;
|
return;
|
||||||
@@ -248,7 +293,11 @@ fn sequencer_loop(
|
|||||||
if bar != prev_bar && audio_state.prev_beat >= 0.0 {
|
if bar != prev_bar && audio_state.prev_beat >= 0.0 {
|
||||||
for change in audio_state.pending_changes.drain(..) {
|
for change in audio_state.pending_changes.drain(..) {
|
||||||
match change {
|
match change {
|
||||||
PendingChange::Add { slot, bank, pattern } => {
|
PendingChange::Add {
|
||||||
|
slot,
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
} => {
|
||||||
audio_state.slots[slot] = PatternSlot {
|
audio_state.slots[slot] = PatternSlot {
|
||||||
bank,
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
@@ -294,8 +343,7 @@ fn sequencer_loop(
|
|||||||
phase: beat % quantum,
|
phase: beat % quantum,
|
||||||
slot: slot_idx,
|
slot: slot_idx,
|
||||||
};
|
};
|
||||||
if let Ok(cmd) = script_engine.evaluate(&step.script, &ctx)
|
if let Ok(cmd) = script_engine.evaluate(&step.script, &ctx) {
|
||||||
{
|
|
||||||
match audio_tx.try_send(AudioCommand::Evaluate(cmd)) {
|
match audio_tx.try_send(AudioCommand::Evaluate(cmd)) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
event_count.fetch_add(1, Ordering::Relaxed);
|
event_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|||||||
1
seq/src/services/mod.rs
Normal file
1
seq/src/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod pattern_editor;
|
||||||
59
seq/src/services/pattern_editor.rs
Normal file
59
seq/src/services/pattern_editor.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::model::Project;
|
||||||
|
|
||||||
|
pub fn toggle_step(project: &mut Project, bank: usize, pattern: usize, step: usize) {
|
||||||
|
if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) {
|
||||||
|
s.active = !s.active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_length(project: &mut Project, bank: usize, pattern: usize, length: usize) {
|
||||||
|
project.pattern_at_mut(bank, pattern).set_length(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_length(project: &Project, bank: usize, pattern: usize) -> usize {
|
||||||
|
project.pattern_at(bank, pattern).length
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increase_length(project: &mut Project, bank: usize, pattern: usize) {
|
||||||
|
let current = get_length(project, bank, pattern);
|
||||||
|
set_length(project, bank, pattern, current + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrease_length(project: &mut Project, bank: usize, pattern: usize) {
|
||||||
|
let current = get_length(project, bank, pattern);
|
||||||
|
set_length(project, bank, pattern, current.saturating_sub(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increase_speed(project: &mut Project, bank: usize, pattern: usize) {
|
||||||
|
let pat = project.pattern_at_mut(bank, pattern);
|
||||||
|
pat.speed = pat.speed.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrease_speed(project: &mut Project, bank: usize, pattern: usize) {
|
||||||
|
let pat = project.pattern_at_mut(bank, pattern);
|
||||||
|
pat.speed = pat.speed.prev();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_step_script(
|
||||||
|
project: &mut Project,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
step: usize,
|
||||||
|
script: String,
|
||||||
|
) {
|
||||||
|
if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) {
|
||||||
|
s.script = script;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_step_script(
|
||||||
|
project: &Project,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
step: usize,
|
||||||
|
) -> Option<String> {
|
||||||
|
project
|
||||||
|
.pattern_at(bank, pattern)
|
||||||
|
.step(step)
|
||||||
|
.map(|s| s.script.clone())
|
||||||
|
}
|
||||||
220
seq/src/state/audio.rs
Normal file
220
seq/src/state/audio.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use doux::audio::AudioDeviceInfo;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AudioConfig {
|
||||||
|
pub output_device: Option<String>,
|
||||||
|
pub input_device: Option<String>,
|
||||||
|
pub channels: u16,
|
||||||
|
pub buffer_size: u32,
|
||||||
|
pub sample_rate: f32,
|
||||||
|
pub sample_paths: Vec<PathBuf>,
|
||||||
|
pub sample_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
output_device: None,
|
||||||
|
input_device: None,
|
||||||
|
channels: 2,
|
||||||
|
buffer_size: 512,
|
||||||
|
sample_rate: 44100.0,
|
||||||
|
sample_paths: Vec::new(),
|
||||||
|
sample_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum AudioFocus {
|
||||||
|
#[default]
|
||||||
|
OutputDevice,
|
||||||
|
InputDevice,
|
||||||
|
Channels,
|
||||||
|
BufferSize,
|
||||||
|
SamplePaths,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Metrics {
|
||||||
|
pub event_count: usize,
|
||||||
|
pub active_voices: usize,
|
||||||
|
pub peak_voices: usize,
|
||||||
|
pub cpu_load: f32,
|
||||||
|
pub schedule_depth: usize,
|
||||||
|
pub scope: [f32; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Metrics {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
event_count: 0,
|
||||||
|
active_voices: 0,
|
||||||
|
peak_voices: 0,
|
||||||
|
cpu_load: 0.0,
|
||||||
|
schedule_depth: 0,
|
||||||
|
scope: [0.0; 64],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioSettings {
|
||||||
|
pub config: AudioConfig,
|
||||||
|
pub focus: AudioFocus,
|
||||||
|
pub output_devices: Vec<AudioDeviceInfo>,
|
||||||
|
pub input_devices: Vec<AudioDeviceInfo>,
|
||||||
|
pub restart_pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
config: AudioConfig::default(),
|
||||||
|
focus: AudioFocus::default(),
|
||||||
|
output_devices: doux::audio::list_output_devices(),
|
||||||
|
input_devices: doux::audio::list_input_devices(),
|
||||||
|
restart_pending: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioSettings {
|
||||||
|
pub fn refresh_devices(&mut self) {
|
||||||
|
self.output_devices = doux::audio::list_output_devices();
|
||||||
|
self.input_devices = doux::audio::list_input_devices();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_focus(&mut self) {
|
||||||
|
self.focus = match self.focus {
|
||||||
|
AudioFocus::OutputDevice => AudioFocus::InputDevice,
|
||||||
|
AudioFocus::InputDevice => AudioFocus::Channels,
|
||||||
|
AudioFocus::Channels => AudioFocus::BufferSize,
|
||||||
|
AudioFocus::BufferSize => AudioFocus::SamplePaths,
|
||||||
|
AudioFocus::SamplePaths => AudioFocus::OutputDevice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_focus(&mut self) {
|
||||||
|
self.focus = match self.focus {
|
||||||
|
AudioFocus::OutputDevice => AudioFocus::SamplePaths,
|
||||||
|
AudioFocus::InputDevice => AudioFocus::OutputDevice,
|
||||||
|
AudioFocus::Channels => AudioFocus::InputDevice,
|
||||||
|
AudioFocus::BufferSize => AudioFocus::Channels,
|
||||||
|
AudioFocus::SamplePaths => AudioFocus::BufferSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_output_device(&mut self) {
|
||||||
|
if self.output_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_output_device_index();
|
||||||
|
let next_idx = (current_idx + 1) % self.output_devices.len();
|
||||||
|
self.config.output_device = Some(self.output_devices[next_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_output_device(&mut self) {
|
||||||
|
if self.output_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_output_device_index();
|
||||||
|
let prev_idx = (current_idx + self.output_devices.len() - 1) % self.output_devices.len();
|
||||||
|
self.config.output_device = Some(self.output_devices[prev_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_output_device_index(&self) -> usize {
|
||||||
|
match &self.config.output_device {
|
||||||
|
Some(name) => self
|
||||||
|
.output_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| &d.name == name)
|
||||||
|
.unwrap_or(0),
|
||||||
|
None => self
|
||||||
|
.output_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| d.is_default)
|
||||||
|
.unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_input_device(&mut self) {
|
||||||
|
if self.input_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_input_device_index();
|
||||||
|
let next_idx = (current_idx + 1) % self.input_devices.len();
|
||||||
|
self.config.input_device = Some(self.input_devices[next_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_input_device(&mut self) {
|
||||||
|
if self.input_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_input_device_index();
|
||||||
|
let prev_idx = (current_idx + self.input_devices.len() - 1) % self.input_devices.len();
|
||||||
|
self.config.input_device = Some(self.input_devices[prev_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_input_device_index(&self) -> usize {
|
||||||
|
match &self.config.input_device {
|
||||||
|
Some(name) => self
|
||||||
|
.input_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| &d.name == name)
|
||||||
|
.unwrap_or(0),
|
||||||
|
None => self
|
||||||
|
.input_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| d.is_default)
|
||||||
|
.unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjust_channels(&mut self, delta: i16) {
|
||||||
|
let new_val = (self.config.channels as i16 + delta).clamp(1, 64) as u16;
|
||||||
|
self.config.channels = new_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjust_buffer_size(&mut self, delta: i32) {
|
||||||
|
let new_val = (self.config.buffer_size as i32 + delta).clamp(64, 4096) as u32;
|
||||||
|
self.config.buffer_size = new_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_output_device_name(&self) -> &str {
|
||||||
|
match &self.config.output_device {
|
||||||
|
Some(name) => name,
|
||||||
|
None => self
|
||||||
|
.output_devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.is_default)
|
||||||
|
.map(|d| d.name.as_str())
|
||||||
|
.unwrap_or("Default"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_input_device_name(&self) -> &str {
|
||||||
|
match &self.config.input_device {
|
||||||
|
Some(name) => name,
|
||||||
|
None => self
|
||||||
|
.input_devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.is_default)
|
||||||
|
.map(|d| d.name.as_str())
|
||||||
|
.unwrap_or("None"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_sample_path(&mut self, path: PathBuf) {
|
||||||
|
if !self.config.sample_paths.contains(&path) {
|
||||||
|
self.config.sample_paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_last_sample_path(&mut self) {
|
||||||
|
self.config.sample_paths.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trigger_restart(&mut self) {
|
||||||
|
self.restart_pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
seq/src/state/editor.rs
Normal file
33
seq/src/state/editor.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use tui_textarea::TextArea;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Focus {
|
||||||
|
Sequencer,
|
||||||
|
Editor,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PatternField {
|
||||||
|
Length,
|
||||||
|
Speed,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EditorContext {
|
||||||
|
pub bank: usize,
|
||||||
|
pub pattern: usize,
|
||||||
|
pub step: usize,
|
||||||
|
pub focus: Focus,
|
||||||
|
pub text: TextArea<'static>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EditorContext {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bank: 0,
|
||||||
|
pattern: 0,
|
||||||
|
step: 0,
|
||||||
|
focus: Focus::Sequencer,
|
||||||
|
text: TextArea::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
seq/src/state/mod.rs
Normal file
9
seq/src/state/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod audio;
|
||||||
|
pub mod editor;
|
||||||
|
pub mod modal;
|
||||||
|
pub mod patterns_nav;
|
||||||
|
|
||||||
|
pub use audio::{AudioFocus, AudioSettings, Metrics};
|
||||||
|
pub use editor::{EditorContext, Focus, PatternField};
|
||||||
|
pub use modal::Modal;
|
||||||
|
pub use patterns_nav::PatternsViewLevel;
|
||||||
25
seq/src/state/modal.rs
Normal file
25
seq/src/state/modal.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use crate::state::editor::PatternField;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub enum Modal {
|
||||||
|
None,
|
||||||
|
ConfirmQuit {
|
||||||
|
selected: bool,
|
||||||
|
},
|
||||||
|
SaveAs(String),
|
||||||
|
LoadFrom(String),
|
||||||
|
RenameBank {
|
||||||
|
bank: usize,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
RenamePattern {
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
SetPattern {
|
||||||
|
field: PatternField,
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
AddSamplePath(String),
|
||||||
|
}
|
||||||
8
seq/src/state/patterns_nav.rs
Normal file
8
seq/src/state/patterns_nav.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum PatternsViewLevel {
|
||||||
|
#[default]
|
||||||
|
Banks,
|
||||||
|
Patterns {
|
||||||
|
bank: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, Modal, PatternField};
|
use crate::app::App;
|
||||||
|
use crate::link::LinkState;
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
|
use crate::sequencer::SequencerSnapshot;
|
||||||
|
use crate::state::{Modal, PatternField};
|
||||||
use crate::views::{audio_view, doc_view, main_view, patterns_view};
|
use crate::views::{audio_view, doc_view, main_view, patterns_view};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &mut App) {
|
pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||||
let [header_area, body_area, footer_area] = Layout::vertical([
|
let [header_area, body_area, footer_area] = Layout::vertical([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
@@ -16,11 +19,11 @@ pub fn render(frame: &mut Frame, app: &mut App) {
|
|||||||
])
|
])
|
||||||
.areas(frame.area());
|
.areas(frame.area());
|
||||||
|
|
||||||
render_header(frame, app, header_area);
|
render_header(frame, app, link, header_area);
|
||||||
|
|
||||||
match app.page {
|
match app.page {
|
||||||
Page::Main => main_view::render(frame, app, body_area),
|
Page::Main => main_view::render(frame, app, snapshot, body_area),
|
||||||
Page::Patterns => patterns_view::render(frame, app, body_area),
|
Page::Patterns => patterns_view::render(frame, app, snapshot, body_area),
|
||||||
Page::Audio => audio_view::render(frame, app, body_area),
|
Page::Audio => audio_view::render(frame, app, body_area),
|
||||||
Page::Doc => doc_view::render(frame, app, body_area),
|
Page::Doc => doc_view::render(frame, app, body_area),
|
||||||
}
|
}
|
||||||
@@ -29,7 +32,7 @@ pub fn render(frame: &mut Frame, app: &mut App) {
|
|||||||
render_modal(frame, app);
|
render_modal(frame, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||||
let [left_area, right_area] =
|
let [left_area, right_area] =
|
||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
||||||
|
|
||||||
@@ -52,7 +55,11 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
let left_spans = vec![
|
let left_spans = vec![
|
||||||
Span::styled("EDIT ", Style::new().fg(Color::Cyan)),
|
Span::styled("EDIT ", Style::new().fg(Color::Cyan)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("B{:02}:P{:02}", app.editor_ctx.bank + 1, app.editor_ctx.pattern + 1),
|
format!(
|
||||||
|
"B{:02}:P{:02}",
|
||||||
|
app.editor_ctx.bank + 1,
|
||||||
|
app.editor_ctx.pattern + 1
|
||||||
|
),
|
||||||
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
@@ -61,17 +68,31 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
|
|
||||||
frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
|
frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
|
||||||
|
|
||||||
let pattern = app.project.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
|
let pattern = app
|
||||||
|
.project
|
||||||
|
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
|
||||||
let right_spans = vec![
|
let right_spans = vec![
|
||||||
Span::styled(format!("L:{:02}", pattern.length), Style::new().fg(Color::Rgb(180, 140, 90))),
|
Span::styled(
|
||||||
|
format!("L:{:02}", pattern.length),
|
||||||
|
Style::new().fg(Color::Rgb(180, 140, 90)),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("S:{}", pattern.speed.label()), Style::new().fg(Color::Rgb(180, 140, 90))),
|
Span::styled(
|
||||||
|
format!("S:{}", pattern.speed.label()),
|
||||||
|
Style::new().fg(Color::Rgb(180, 140, 90)),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("{:.1} BPM", app.tempo), Style::new().fg(Color::Magenta)),
|
Span::styled(
|
||||||
|
format!("{:.1} BPM", link.tempo()),
|
||||||
|
Style::new().fg(Color::Magenta),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)),
|
Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("V:{}", app.metrics.active_voices), Style::new().fg(Color::Cyan)),
|
Span::styled(
|
||||||
|
format!("V:{}", app.metrics.active_voices),
|
||||||
|
Style::new().fg(Color::Cyan),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -90,13 +111,19 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
|
|
||||||
let content = if let Some(ref msg) = app.status_message {
|
let content = if let Some(ref msg) = app.status_message {
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
|
Span::styled(
|
||||||
|
page_indicator,
|
||||||
|
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
Span::styled(msg.clone(), Style::new().fg(Color::Yellow)),
|
Span::styled(msg.clone(), Style::new().fg(Color::Yellow)),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
match app.page {
|
match app.page {
|
||||||
Page::Main => Line::from(vec![
|
Page::Main => Line::from(vec![
|
||||||
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
|
Span::styled(
|
||||||
|
page_indicator,
|
||||||
|
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
|
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
|
||||||
Span::raw(":nav "),
|
Span::raw(":nav "),
|
||||||
Span::styled("<>", Style::new().fg(Color::Yellow)),
|
Span::styled("<>", Style::new().fg(Color::Yellow)),
|
||||||
@@ -111,7 +138,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
Span::raw(":patterns"),
|
Span::raw(":patterns"),
|
||||||
]),
|
]),
|
||||||
Page::Patterns => Line::from(vec![
|
Page::Patterns => Line::from(vec![
|
||||||
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
|
Span::styled(
|
||||||
|
page_indicator,
|
||||||
|
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
|
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
|
||||||
Span::raw(":nav "),
|
Span::raw(":nav "),
|
||||||
Span::styled("Enter", Style::new().fg(Color::Yellow)),
|
Span::styled("Enter", Style::new().fg(Color::Yellow)),
|
||||||
@@ -122,7 +152,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
Span::raw(":back"),
|
Span::raw(":back"),
|
||||||
]),
|
]),
|
||||||
Page::Audio => Line::from(vec![
|
Page::Audio => Line::from(vec![
|
||||||
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
|
Span::styled(
|
||||||
|
page_indicator,
|
||||||
|
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
Span::styled("q", Style::new().fg(Color::Yellow)),
|
Span::styled("q", Style::new().fg(Color::Yellow)),
|
||||||
Span::raw(":quit "),
|
Span::raw(":quit "),
|
||||||
Span::styled("h", Style::new().fg(Color::Yellow)),
|
Span::styled("h", Style::new().fg(Color::Yellow)),
|
||||||
@@ -137,7 +170,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
Span::raw(":page"),
|
Span::raw(":page"),
|
||||||
]),
|
]),
|
||||||
Page::Doc => Line::from(vec![
|
Page::Doc => Line::from(vec![
|
||||||
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
|
Span::styled(
|
||||||
|
page_indicator,
|
||||||
|
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
Span::styled("j/k", Style::new().fg(Color::Yellow)),
|
Span::styled("j/k", Style::new().fg(Color::Yellow)),
|
||||||
Span::raw(":topic "),
|
Span::raw(":topic "),
|
||||||
Span::styled("PgUp/Dn", Style::new().fg(Color::Yellow)),
|
Span::styled("PgUp/Dn", Style::new().fg(Color::Yellow)),
|
||||||
@@ -205,7 +241,9 @@ fn render_modal(frame: &mut Frame, app: &App) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Modal::SaveAs(path) => {
|
Modal::SaveAs(path) => {
|
||||||
let width = (term.width * 60 / 100).clamp(40, 70).min(term.width.saturating_sub(4));
|
let width = (term.width * 60 / 100)
|
||||||
|
.clamp(40, 70)
|
||||||
|
.min(term.width.saturating_sub(4));
|
||||||
let height = 5.min(term.height.saturating_sub(4));
|
let height = 5.min(term.height.saturating_sub(4));
|
||||||
let area = centered_rect(width, height, term);
|
let area = centered_rect(width, height, term);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
@@ -223,7 +261,9 @@ fn render_modal(frame: &mut Frame, app: &App) {
|
|||||||
frame.render_widget(modal, area);
|
frame.render_widget(modal, area);
|
||||||
}
|
}
|
||||||
Modal::LoadFrom(path) => {
|
Modal::LoadFrom(path) => {
|
||||||
let width = (term.width * 60 / 100).clamp(40, 70).min(term.width.saturating_sub(4));
|
let width = (term.width * 60 / 100)
|
||||||
|
.clamp(40, 70)
|
||||||
|
.min(term.width.saturating_sub(4));
|
||||||
let height = 5.min(term.height.saturating_sub(4));
|
let height = 5.min(term.height.saturating_sub(4));
|
||||||
let area = centered_rect(width, height, term);
|
let area = centered_rect(width, height, term);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
@@ -258,7 +298,11 @@ fn render_modal(frame: &mut Frame, app: &App) {
|
|||||||
);
|
);
|
||||||
frame.render_widget(modal, area);
|
frame.render_widget(modal, area);
|
||||||
}
|
}
|
||||||
Modal::RenamePattern { bank, pattern, name } => {
|
Modal::RenamePattern {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
name,
|
||||||
|
} => {
|
||||||
let width = 40.min(term.width.saturating_sub(4));
|
let width = 40.min(term.width.saturating_sub(4));
|
||||||
let height = 5.min(term.height.saturating_sub(4));
|
let height = 5.min(term.height.saturating_sub(4));
|
||||||
let area = centered_rect(width, height, term);
|
let area = centered_rect(width, height, term);
|
||||||
@@ -293,7 +337,8 @@ fn render_modal(frame: &mut Frame, app: &App) {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
|
let rows =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
|
||||||
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
@@ -321,7 +366,8 @@ fn render_modal(frame: &mut Frame, app: &App) {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
|
let rows =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
|
||||||
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, AudioFocus};
|
use crate::app::App;
|
||||||
|
use crate::state::AudioFocus;
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let [config_area, stats_area] =
|
let [config_area, stats_area] =
|
||||||
@@ -117,7 +118,11 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
let mut sample_lines = vec![Line::from(vec![
|
let mut sample_lines = vec![Line::from(vec![
|
||||||
Span::styled("Samples ", dim),
|
Span::styled("Samples ", dim),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{} paths, {} indexed", app.audio.config.sample_paths.len(), app.audio.config.sample_count),
|
format!(
|
||||||
|
"{} paths, {} indexed",
|
||||||
|
app.audio.config.sample_paths.len(),
|
||||||
|
app.audio.config.sample_count
|
||||||
|
),
|
||||||
samples_style,
|
samples_style,
|
||||||
),
|
),
|
||||||
])];
|
])];
|
||||||
@@ -127,7 +132,10 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
let display = truncate_name(&path_str, 35);
|
let display = truncate_name(&path_str, 35);
|
||||||
sample_lines.push(Line::from(vec![
|
sample_lines.push(Line::from(vec![
|
||||||
Span::styled(" ", dim),
|
Span::styled(" ", dim),
|
||||||
Span::styled(format!("{}: {}", i + 1, display), Style::new().fg(Color::DarkGray)),
|
Span::styled(
|
||||||
|
format!("{}: {}", i + 1, display),
|
||||||
|
Style::new().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +208,17 @@ fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
let extra = Paragraph::new(vec![
|
let extra = Paragraph::new(vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw("Schedule: "),
|
Span::raw("Schedule: "),
|
||||||
Span::styled(format!("{}", app.metrics.schedule_depth), Style::new().fg(Color::White)),
|
Span::styled(
|
||||||
|
format!("{}", app.metrics.schedule_depth),
|
||||||
|
Style::new().fg(Color::White),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw("Pool: "),
|
Span::raw("Pool: "),
|
||||||
Span::styled(format!("{:.1} MB", app.sample_pool_mb), Style::new().fg(Color::White)),
|
Span::styled(
|
||||||
|
format!("{:.1} MB", app.sample_pool_mb),
|
||||||
|
Style::new().fg(Color::White),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,25 +3,24 @@ use ratatui::style::{Color, Modifier, Style};
|
|||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, Focus};
|
use crate::app::App;
|
||||||
|
use crate::sequencer::SequencerSnapshot;
|
||||||
|
use crate::state::Focus;
|
||||||
use crate::widgets::{Orientation, Scope};
|
use crate::widgets::{Orientation, Scope};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let [main_area, scope_area] = Layout::horizontal([
|
let [main_area, scope_area] =
|
||||||
Constraint::Fill(1),
|
Layout::horizontal([Constraint::Fill(1), Constraint::Length(10)]).areas(area);
|
||||||
Constraint::Length(10),
|
|
||||||
])
|
|
||||||
.areas(area);
|
|
||||||
|
|
||||||
let [seq_area, editor_area] =
|
let [seq_area, editor_area] =
|
||||||
Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area);
|
Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area);
|
||||||
|
|
||||||
render_sequencer(frame, app, seq_area);
|
render_sequencer(frame, app, snapshot, seq_area);
|
||||||
render_editor(frame, app, editor_area);
|
render_editor(frame, app, editor_area);
|
||||||
render_scope(frame, app, scope_area);
|
render_scope(frame, app, scope_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let focus_indicator = if app.editor_ctx.focus == Focus::Sequencer {
|
let focus_indicator = if app.editor_ctx.focus == Focus::Sequencer {
|
||||||
"*"
|
"*"
|
||||||
} else {
|
} else {
|
||||||
@@ -96,13 +95,19 @@ fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
for col_idx in 0..cols_in_row {
|
for col_idx in 0..cols_in_row {
|
||||||
let step_idx = start_step + col_idx;
|
let step_idx = start_step + col_idx;
|
||||||
if step_idx < length {
|
if step_idx < length {
|
||||||
render_tile(frame, cols[col_idx * 2], app, step_idx);
|
render_tile(frame, cols[col_idx * 2], app, snapshot, step_idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_tile(frame: &mut Frame, area: Rect, app: &App, step_idx: usize) {
|
fn render_tile(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
app: &App,
|
||||||
|
snapshot: &SequencerSnapshot,
|
||||||
|
step_idx: usize,
|
||||||
|
) {
|
||||||
let pattern = app.current_edit_pattern();
|
let pattern = app.current_edit_pattern();
|
||||||
let step = pattern.step(step_idx);
|
let step = pattern.step(step_idx);
|
||||||
let is_active = step.map(|s| s.active).unwrap_or(false);
|
let is_active = step.map(|s| s.active).unwrap_or(false);
|
||||||
@@ -110,11 +115,11 @@ fn render_tile(frame: &mut Frame, area: Rect, app: &App, step_idx: usize) {
|
|||||||
|
|
||||||
let playing_slot = if app.playing {
|
let playing_slot = if app.playing {
|
||||||
(0..8).find(|&i| {
|
(0..8).find(|&i| {
|
||||||
let s = app.slot_data[i];
|
let s = snapshot.slot_data[i];
|
||||||
s.active
|
s.active
|
||||||
&& s.bank == app.editor_ctx.bank
|
&& s.bank == app.editor_ctx.bank
|
||||||
&& s.pattern == app.editor_ctx.pattern
|
&& s.pattern == app.editor_ctx.pattern
|
||||||
&& app.slot_steps[i] == step_idx
|
&& snapshot.slot_steps[i] == step_idx
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, PatternsViewLevel};
|
use crate::app::App;
|
||||||
|
use crate::sequencer::SequencerSnapshot;
|
||||||
|
use crate::state::PatternsViewLevel;
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
match app.patterns_view_level {
|
match app.patterns_view_level {
|
||||||
PatternsViewLevel::Banks => render_banks(frame, app, area),
|
PatternsViewLevel::Banks => render_banks(frame, app, snapshot, area),
|
||||||
PatternsViewLevel::Patterns { bank } => render_patterns(frame, app, area, bank),
|
PatternsViewLevel::Patterns { bank } => render_patterns(frame, app, snapshot, area, bank),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_banks(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::new().fg(Color::Rgb(100, 160, 180)))
|
.border_style(Style::new().fg(Color::Rgb(100, 160, 180)))
|
||||||
@@ -30,7 +32,7 @@ fn render_banks(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let banks_with_playback: Vec<usize> = app
|
let banks_with_playback: Vec<usize> = snapshot
|
||||||
.slot_data
|
.slot_data
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| s.active)
|
.filter(|s| s.active)
|
||||||
@@ -44,10 +46,23 @@ fn render_banks(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
.map(|b| b.name.as_deref())
|
.map(|b| b.name.as_deref())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
render_grid(frame, inner, app.patterns_cursor, app.editor_ctx.bank, &banks_with_playback, &bank_names);
|
render_grid(
|
||||||
|
frame,
|
||||||
|
inner,
|
||||||
|
app.patterns_cursor,
|
||||||
|
app.editor_ctx.bank,
|
||||||
|
&banks_with_playback,
|
||||||
|
&bank_names,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_patterns(frame: &mut Frame, app: &App, area: Rect, bank: usize) {
|
fn render_patterns(
|
||||||
|
frame: &mut Frame,
|
||||||
|
app: &App,
|
||||||
|
snapshot: &SequencerSnapshot,
|
||||||
|
area: Rect,
|
||||||
|
bank: usize,
|
||||||
|
) {
|
||||||
let bank_name = app.project.banks[bank].name.as_deref();
|
let bank_name = app.project.banks[bank].name.as_deref();
|
||||||
let title_text = match bank_name {
|
let title_text = match bank_name {
|
||||||
Some(name) => format!("{name} › Patterns"),
|
Some(name) => format!("{name} › Patterns"),
|
||||||
@@ -74,7 +89,7 @@ fn render_patterns(frame: &mut Frame, app: &App, area: Rect, bank: usize) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let playing_patterns: Vec<usize> = app
|
let playing_patterns: Vec<usize> = snapshot
|
||||||
.slot_data
|
.slot_data
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| s.active && s.bank == bank)
|
.filter(|s| s.active && s.bank == bank)
|
||||||
@@ -93,7 +108,17 @@ fn render_patterns(frame: &mut Frame, app: &App, area: Rect, bank: usize) {
|
|||||||
.map(|p| p.name.as_deref())
|
.map(|p| p.name.as_deref())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
render_pattern_grid(frame, app, inner, bank, app.patterns_cursor, edit_pattern, &playing_patterns, &pattern_names);
|
render_pattern_grid(
|
||||||
|
frame,
|
||||||
|
app,
|
||||||
|
snapshot,
|
||||||
|
inner,
|
||||||
|
bank,
|
||||||
|
app.patterns_cursor,
|
||||||
|
edit_pattern,
|
||||||
|
&playing_patterns,
|
||||||
|
&pattern_names,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_grid(
|
fn render_grid(
|
||||||
@@ -169,6 +194,7 @@ fn render_grid(
|
|||||||
fn render_pattern_grid(
|
fn render_pattern_grid(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
app: &App,
|
app: &App,
|
||||||
|
snapshot: &SequencerSnapshot,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
bank: usize,
|
bank: usize,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
@@ -191,7 +217,7 @@ fn render_pattern_grid(
|
|||||||
let is_cursor = idx == cursor;
|
let is_cursor = idx == cursor;
|
||||||
let is_edit = idx == edit_pos;
|
let is_edit = idx == edit_pos;
|
||||||
let is_playing = playing_positions.contains(&idx);
|
let is_playing = playing_positions.contains(&idx);
|
||||||
let queued = app.is_pattern_queued(bank, idx);
|
let queued = app.is_pattern_queued(bank, idx, snapshot);
|
||||||
|
|
||||||
let (bg, fg, prefix) = match (is_cursor, is_playing, queued) {
|
let (bg, fg, prefix) = match (is_cursor, is_playing, queued) {
|
||||||
(true, _, _) => (Color::Cyan, Color::Black, ""),
|
(true, _, _) => (Color::Cyan, Color::Black, ""),
|
||||||
|
|||||||
17733
test.buboseq
17733
test.buboseq
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user