flesh out sequencer

This commit is contained in:
2026-01-20 14:37:03 +01:00
parent 276107433a
commit ebb82b6f7d
25 changed files with 2069 additions and 771 deletions

19
Cargo.lock generated
View File

@@ -1259,7 +1259,7 @@ source = "git+https://github.com/sourcebox/mi-plaits-dsp-rs?rev=dc55bd55e73bd6f8
dependencies = [ dependencies = [
"dyn-clone", "dyn-clone",
"num-traits", "num-traits",
"spin 0.10.0", "spin",
] ]
[[package]] [[package]]
@@ -1410,15 +1410,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
dependencies = [
"spin 0.5.2",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1943,7 +1934,6 @@ checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1"
dependencies = [ dependencies = [
"ahash", "ahash",
"bitflags 2.10.0", "bitflags 2.10.0",
"no-std-compat",
"num-traits", "num-traits",
"once_cell", "once_cell",
"rhai_codegen", "rhai_codegen",
@@ -2097,7 +2087,6 @@ dependencies = [
"minimad", "minimad",
"rand 0.8.5", "rand 0.8.5",
"ratatui", "ratatui",
"rhai",
"rusty_link", "rusty_link",
"serde", "serde",
"serde_json", "serde_json",
@@ -2259,12 +2248,6 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.10.0" version = "0.10.0"

View File

@@ -14,7 +14,7 @@ ratatui = "0.29"
crossterm = "0.28" crossterm = "0.28"
cpal = "0.15" cpal = "0.15"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
rhai = { version = "1.24", features = ["sync"] }
rand = "0.8" rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -15,8 +15,8 @@ use crate::model::{self, Pattern, Rng, ScriptEngine, StepContext, Variables};
use crate::page::Page; use crate::page::Page;
use crate::services::pattern_editor; use crate::services::pattern_editor;
use crate::state::{ use crate::state::{
AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsViewLevel, AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsNav, PlaybackState,
PlaybackState, ProjectState, UiState, ProjectState, UiState,
}; };
use crate::views::doc_view; use crate::views::doc_view;
@@ -28,8 +28,7 @@ pub struct App {
pub page: Page, pub page: Page,
pub editor_ctx: EditorContext, pub editor_ctx: EditorContext,
pub patterns_view_level: PatternsViewLevel, pub patterns_nav: PatternsNav,
pub patterns_cursor: usize,
pub metrics: Metrics, pub metrics: Metrics,
pub sample_pool_mb: f32, pub sample_pool_mb: f32,
@@ -55,8 +54,7 @@ impl App {
page: Page::default(), page: Page::default(),
editor_ctx: EditorContext::default(), editor_ctx: EditorContext::default(),
patterns_view_level: PatternsViewLevel::default(), patterns_nav: PatternsNav::default(),
patterns_cursor: 0,
metrics: Metrics::default(), metrics: Metrics::default(),
sample_pool_mb: 0.0, sample_pool_mb: 0.0,
@@ -253,17 +251,22 @@ impl App {
tempo: link.tempo(), tempo: link.tempo(),
phase: link.phase(), phase: link.phase(),
slot: 0, slot: 0,
runs: 0,
}; };
match self.script_engine.evaluate(&script, &ctx) { match self.script_engine.evaluate(&script, &ctx) {
Ok(cmd) => { Ok(cmds) => {
if let Some(step) = self if let Some(step) = self
.project_state .project_state
.project .project
.pattern_at_mut(bank, pattern) .pattern_at_mut(bank, pattern)
.step_mut(step_idx) .step_mut(step_idx)
{ {
step.command = Some(cmd); step.command = if cmds.is_empty() {
None
} else {
Some(cmds.join("\n"))
};
} }
self.ui.flash("Script compiled", 150); self.ui.flash("Script compiled", 150);
} }
@@ -314,16 +317,21 @@ impl App {
tempo: link.tempo(), tempo: link.tempo(),
phase: 0.0, phase: 0.0,
slot: 0, slot: 0,
runs: 0,
}; };
if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx) { if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
if let Some(step) = self if let Some(step) = self
.project_state .project_state
.project .project
.pattern_at_mut(bank, pattern) .pattern_at_mut(bank, pattern)
.step_mut(step_idx) .step_mut(step_idx)
{ {
step.command = Some(cmd); step.command = if cmds.is_empty() {
None
} else {
Some(cmds.join("\n"))
};
} }
} }
} }
@@ -429,6 +437,7 @@ impl App {
pub fn save(&mut self, path: PathBuf) { pub fn save(&mut self, path: PathBuf) {
self.save_editor_to_step(); self.save_editor_to_step();
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
match model::save(&self.project_state.project, &path) { match model::save(&self.project_state.project, &path) {
Ok(()) => { Ok(()) => {
self.ui.set_status(format!("Saved: {}", path.display())); self.ui.set_status(format!("Saved: {}", path.display()));
@@ -459,22 +468,60 @@ impl App {
pub fn copy_step(&mut self) { pub fn copy_step(&mut self) {
let (bank, pattern) = self.current_bank_pattern(); let (bank, pattern) = self.current_bank_pattern();
let script = pattern_editor::get_step_script( let step = self.editor_ctx.step;
&self.project_state.project, let script =
bank, pattern_editor::get_step_script(&self.project_state.project, bank, pattern, step);
pattern,
self.editor_ctx.step,
);
if let Some(script) = script { if let Some(script) = script {
if let Some(clip) = &mut self.clipboard { if let Some(clip) = &mut self.clipboard {
if clip.set_text(&script).is_ok() { if clip.set_text(&script).is_ok() {
self.editor_ctx.copied_step = Some(crate::state::CopiedStep {
bank,
pattern,
step,
});
self.ui.set_status("Copied".to_string()); self.ui.set_status("Copied".to_string());
} }
} }
} }
} }
pub fn delete_step(&mut self, bank: usize, pattern: usize, step: usize) {
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
for s in &mut pat.steps {
if s.source == Some(step) {
s.source = None;
s.script.clear();
s.command = None;
}
}
let change = pattern_editor::set_step_script(
&mut self.project_state.project,
bank,
pattern,
step,
String::new(),
);
if let Some(s) = self
.project_state
.project
.pattern_at_mut(bank, pattern)
.step_mut(step)
{
s.command = None;
s.source = None;
}
self.project_state.mark_dirty(change.bank, change.pattern);
if self.editor_ctx.bank == bank
&& self.editor_ctx.pattern == pattern
&& self.editor_ctx.step == step
{
self.load_step_to_editor();
}
self.ui.flash("Step deleted", 150);
}
pub fn paste_step(&mut self, link: &LinkState) { pub fn paste_step(&mut self, link: &LinkState) {
let text = self let text = self
.clipboard .clipboard
@@ -496,6 +543,86 @@ impl App {
} }
} }
pub fn link_paste_step(&mut self) {
let Some(copied) = self.editor_ctx.copied_step else {
self.ui.set_status("Nothing copied".to_string());
return;
};
let (bank, pattern) = self.current_bank_pattern();
let step = self.editor_ctx.step;
if copied.bank != bank || copied.pattern != pattern {
self.ui
.set_status("Can only link within same pattern".to_string());
return;
}
if copied.step == step {
self.ui.set_status("Cannot link step to itself".to_string());
return;
}
let source_step = self
.project_state
.project
.pattern_at(bank, pattern)
.step(copied.step);
if source_step.map(|s| s.source.is_some()).unwrap_or(false) {
self.ui
.set_status("Cannot link to a linked step".to_string());
return;
}
if let Some(s) = self
.project_state
.project
.pattern_at_mut(bank, pattern)
.step_mut(step)
{
s.source = Some(copied.step);
s.script.clear();
s.command = None;
}
self.project_state.mark_dirty(bank, pattern);
self.load_step_to_editor();
self.ui
.flash(&format!("Linked to step {:02}", copied.step + 1), 150);
}
pub fn harden_step(&mut self) {
let (bank, pattern) = self.current_bank_pattern();
let step = self.editor_ctx.step;
let resolved_script = self
.project_state
.project
.pattern_at(bank, pattern)
.resolve_script(step)
.map(|s| s.to_string());
let Some(script) = resolved_script else {
return;
};
if let Some(s) = self
.project_state
.project
.pattern_at_mut(bank, pattern)
.step_mut(step)
{
if s.source.is_none() {
self.ui.set_status("Step is not linked".to_string());
return;
}
s.source = None;
s.script = script;
}
self.project_state.mark_dirty(bank, pattern);
self.load_step_to_editor();
self.ui.flash("Step hardened", 150);
}
pub fn open_pattern_modal(&mut self, field: PatternField) { pub fn open_pattern_modal(&mut self, field: PatternField) {
let current = match field { let current = match field {
PatternField::Length => self.current_edit_pattern().length.to_string(), PatternField::Length => self.current_edit_pattern().length.to_string(),
@@ -566,10 +693,19 @@ impl App {
AppCommand::SaveEditorToStep => self.save_editor_to_step(), AppCommand::SaveEditorToStep => self.save_editor_to_step(),
AppCommand::CompileCurrentStep => self.compile_current_step(link), AppCommand::CompileCurrentStep => self.compile_current_step(link),
AppCommand::CompileAllSteps => self.compile_all_steps(link), AppCommand::CompileAllSteps => self.compile_all_steps(link),
AppCommand::DeleteStep {
bank,
pattern,
step,
} => {
self.delete_step(bank, pattern, step);
}
// Clipboard // Clipboard
AppCommand::CopyStep => self.copy_step(), AppCommand::CopyStep => self.copy_step(),
AppCommand::PasteStep => self.paste_step(link), AppCommand::PasteStep => self.paste_step(link),
AppCommand::LinkPasteStep => self.link_paste_step(),
AppCommand::HardenStep => self.harden_step(),
// Pattern playback // Pattern playback
AppCommand::QueueSlotChange(change) => { AppCommand::QueueSlotChange(change) => {
@@ -600,7 +736,18 @@ impl App {
message, message,
duration_ms, duration_ms,
} => self.ui.flash(&message, duration_ms), } => self.ui.flash(&message, duration_ms),
AppCommand::OpenModal(modal) => self.ui.modal = modal, AppCommand::OpenModal(modal) => {
if matches!(modal, Modal::Editor) {
// If current step is a shallow copy, navigate to source step
let pattern = &self.project_state.project.banks[self.editor_ctx.bank].patterns
[self.editor_ctx.pattern];
if let Some(source) = pattern.steps[self.editor_ctx.step].source {
self.editor_ctx.step = source;
}
self.load_step_to_editor();
}
self.ui.modal = modal;
}
AppCommand::CloseModal => self.ui.modal = Modal::None, AppCommand::CloseModal => self.ui.modal = Modal::None,
AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field), AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field),
@@ -629,39 +776,32 @@ impl App {
// Patterns view // Patterns view
AppCommand::PatternsCursorLeft => { AppCommand::PatternsCursorLeft => {
self.patterns_cursor = (self.patterns_cursor + 15) % 16; self.patterns_nav.move_left();
} }
AppCommand::PatternsCursorRight => { AppCommand::PatternsCursorRight => {
self.patterns_cursor = (self.patterns_cursor + 1) % 16; self.patterns_nav.move_right();
} }
AppCommand::PatternsCursorUp => { AppCommand::PatternsCursorUp => {
self.patterns_cursor = (self.patterns_cursor + 12) % 16; self.patterns_nav.move_up();
} }
AppCommand::PatternsCursorDown => { AppCommand::PatternsCursorDown => {
self.patterns_cursor = (self.patterns_cursor + 4) % 16; self.patterns_nav.move_down();
}
AppCommand::PatternsEnter => {
let bank = self.patterns_nav.selected_bank();
let pattern = self.patterns_nav.selected_pattern();
self.select_edit_bank(bank);
self.select_edit_pattern(pattern);
self.page.down();
}
AppCommand::PatternsBack => {
self.page.down();
}
AppCommand::PatternsTogglePlay => {
let bank = self.patterns_nav.selected_bank();
let pattern = self.patterns_nav.selected_pattern();
self.toggle_pattern_playback(bank, pattern, snapshot);
} }
AppCommand::PatternsEnter => match self.patterns_view_level {
PatternsViewLevel::Banks => {
let bank = self.patterns_cursor;
self.patterns_view_level = PatternsViewLevel::Patterns { bank };
self.patterns_cursor = 0;
}
PatternsViewLevel::Patterns { bank } => {
let pattern = self.patterns_cursor;
self.select_edit_bank(bank);
self.select_edit_pattern(pattern);
self.patterns_view_level = PatternsViewLevel::Banks;
self.patterns_cursor = 0;
self.page.down();
}
},
AppCommand::PatternsBack => match self.patterns_view_level {
PatternsViewLevel::Banks => self.page.down(),
PatternsViewLevel::Patterns { .. } => {
self.patterns_view_level = PatternsViewLevel::Banks;
self.patterns_cursor = 0;
}
},
} }
} }
@@ -699,6 +839,7 @@ impl App {
.map(|s| StepSnapshot { .map(|s| StepSnapshot {
active: s.active, active: s.active,
script: s.script.clone(), script: s.script.clone(),
source: s.source,
}) })
.collect(), .collect(),
}; };

View File

@@ -40,10 +40,17 @@ pub enum AppCommand {
SaveEditorToStep, SaveEditorToStep,
CompileCurrentStep, CompileCurrentStep,
CompileAllSteps, CompileAllSteps,
DeleteStep {
bank: usize,
pattern: usize,
step: usize,
},
// Clipboard // Clipboard
CopyStep, CopyStep,
PasteStep, PasteStep,
LinkPasteStep,
HardenStep,
// Pattern playback // Pattern playback
QueueSlotChange(SlotChange), QueueSlotChange(SlotChange),
@@ -95,4 +102,5 @@ pub enum AppCommand {
PatternsCursorDown, PatternsCursorDown,
PatternsEnter, PatternsEnter,
PatternsBack, PatternsBack,
PatternsTogglePlay,
} }

View File

@@ -1,4 +1,5 @@
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
@@ -57,6 +58,7 @@ pub struct PatternSnapshot {
pub struct StepSnapshot { pub struct StepSnapshot {
pub active: bool, pub active: bool,
pub script: String, pub script: String,
pub source: Option<usize>,
} }
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
@@ -229,6 +231,51 @@ impl PatternCache {
} }
} }
impl PatternSnapshot {
fn resolve_source(&self, index: usize) -> usize {
let mut current = index;
for _ in 0..self.steps.len() {
if let Some(step) = self.steps.get(current) {
if let Some(source) = step.source {
current = source;
} else {
return current;
}
} else {
return index;
}
}
index
}
fn resolve_script(&self, index: usize) -> Option<&str> {
let source_idx = self.resolve_source(index);
self.steps.get(source_idx).map(|s| s.script.as_str())
}
}
type StepKey = (usize, usize, usize);
struct RunsCounter {
counts: HashMap<StepKey, usize>,
}
impl RunsCounter {
fn new() -> Self {
Self {
counts: HashMap::new(),
}
}
fn get_and_increment(&mut self, bank: usize, pattern: usize, step: usize) -> usize {
let key = (bank, pattern, step);
let count = self.counts.entry(key).or_insert(0);
let current = *count;
*count += 1;
current
}
}
fn sequencer_loop( fn sequencer_loop(
cmd_rx: Receiver<SeqCommand>, cmd_rx: Receiver<SeqCommand>,
audio_tx: Sender<AudioCommand>, audio_tx: Sender<AudioCommand>,
@@ -244,6 +291,7 @@ fn sequencer_loop(
let script_engine = ScriptEngine::new(variables, rng); let script_engine = ScriptEngine::new(variables, rng);
let mut audio_state = AudioState::new(); let mut audio_state = AudioState::new();
let mut pattern_cache = PatternCache::new(); let mut pattern_cache = PatternCache::new();
let mut runs_counter = RunsCounter::new();
loop { loop {
while let Ok(cmd) = cmd_rx.try_recv() { while let Ok(cmd) = cmd_rx.try_recv() {
@@ -332,7 +380,15 @@ fn sequencer_loop(
slot_steps[slot_idx].store(step_idx, Ordering::Relaxed); slot_steps[slot_idx].store(step_idx, Ordering::Relaxed);
if let Some(step) = pattern.steps.get(step_idx) { if let Some(step) = pattern.steps.get(step_idx) {
if step.active && !step.script.trim().is_empty() { let resolved_script = pattern.resolve_script(step_idx);
let has_script = resolved_script
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if step.active && has_script {
let source_idx = pattern.resolve_source(step_idx);
let runs =
runs_counter.get_and_increment(slot.bank, slot.pattern, source_idx);
let ctx = StepContext { let ctx = StepContext {
step: step_idx, step: step_idx,
beat, beat,
@@ -341,15 +397,20 @@ fn sequencer_loop(
tempo, tempo,
phase: beat % quantum, phase: beat % quantum,
slot: slot_idx, slot: slot_idx,
runs,
}; };
if let Ok(cmd) = script_engine.evaluate(&step.script, &ctx) { if let Some(script) = resolved_script {
match audio_tx.try_send(AudioCommand::Evaluate(cmd)) { if let Ok(cmds) = script_engine.evaluate(script, &ctx) {
Ok(()) => { for cmd in cmds {
event_count.fetch_add(1, Ordering::Relaxed); match audio_tx.try_send(AudioCommand::Evaluate(cmd)) {
} Ok(()) => {
Err(TrySendError::Full(_)) => {} event_count.fetch_add(1, Ordering::Relaxed);
Err(TrySendError::Disconnected(_)) => { }
return; Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => {
return;
}
}
} }
} }
} }

View File

@@ -9,7 +9,7 @@ use crate::commands::AppCommand;
use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; use crate::engine::{AudioCommand, LinkState, SequencerSnapshot};
use crate::model::PatternSpeed; use crate::model::PatternSpeed;
use crate::page::Page; use crate::page::Page;
use crate::state::{AudioFocus, Focus, Modal, PatternField, PatternsViewLevel}; use crate::state::{AudioFocus, Modal, PatternField};
pub enum InputResult { pub enum InputResult {
Continue, Continue,
@@ -31,6 +31,11 @@ impl<'a> InputContext<'a> {
} }
pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult { pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
if ctx.app.ui.show_title {
ctx.app.ui.show_title = false;
return InputResult::Continue;
}
ctx.dispatch(AppCommand::ClearStatus); ctx.dispatch(AppCommand::ClearStatus);
if matches!(ctx.app.ui.modal, Modal::None) { if matches!(ctx.app.ui.modal, Modal::None) {
@@ -59,6 +64,49 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} }
_ => {} _ => {}
}, },
Modal::ConfirmDeleteStep {
bank,
pattern,
step,
selected: _,
} => {
let (bank, pattern, step) = (*bank, *pattern, *step);
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.dispatch(AppCommand::DeleteStep {
bank,
pattern,
step,
});
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::ConfirmDeleteStep { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
let do_delete =
if let Modal::ConfirmDeleteStep { selected, .. } = &ctx.app.ui.modal {
*selected
} else {
false
};
if do_delete {
ctx.dispatch(AppCommand::DeleteStep {
bank,
pattern,
step,
});
}
ctx.dispatch(AppCommand::CloseModal);
}
_ => {}
}
}
Modal::SaveAs(path) => match key.code { Modal::SaveAs(path) => match key.code {
KeyCode::Enter => { KeyCode::Enter => {
let save_path = PathBuf::from(path.as_str()); let save_path = PathBuf::from(path.as_str());
@@ -77,6 +125,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let load_path = PathBuf::from(path.as_str()); let load_path = PathBuf::from(path.as_str());
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
ctx.dispatch(AppCommand::Load(load_path)); ctx.dispatch(AppCommand::Load(load_path));
load_project_samples(ctx);
} }
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => { KeyCode::Backspace => {
@@ -204,6 +253,23 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char(c) => path.push(c), KeyCode::Char(c) => path.push(c),
_ => {} _ => {}
}, },
Modal::Editor => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('e') if ctrl => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
}
_ => {
ctx.app.editor_ctx.text.input(Event::Key(key));
}
}
}
Modal::None => unreachable!(), Modal::None => unreachable!(),
} }
InputResult::Continue InputResult::Continue
@@ -243,64 +309,73 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} }
fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult {
match ctx.app.editor_ctx.focus { match key.code {
Focus::Sequencer => match key.code { KeyCode::Char('q') => {
KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false,
selected: false, }));
})); }
} KeyCode::Char(' ') => {
KeyCode::Char(' ') => { ctx.dispatch(AppCommand::TogglePlaying);
ctx.dispatch(AppCommand::TogglePlaying); ctx.playing
ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed);
.store(ctx.app.playback.playing, Ordering::Relaxed); }
} KeyCode::Left => ctx.dispatch(AppCommand::PrevStep),
KeyCode::Tab => ctx.dispatch(AppCommand::ToggleFocus), KeyCode::Right => ctx.dispatch(AppCommand::NextStep),
KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), KeyCode::Up => ctx.dispatch(AppCommand::StepUp),
KeyCode::Right => ctx.dispatch(AppCommand::NextStep), KeyCode::Down => ctx.dispatch(AppCommand::StepDown),
KeyCode::Up => ctx.dispatch(AppCommand::StepUp), KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)),
KeyCode::Down => ctx.dispatch(AppCommand::StepDown), KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep),
KeyCode::Enter => ctx.dispatch(AppCommand::ToggleStep), KeyCode::Char('s') => {
KeyCode::Char('s') => { ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(String::new())));
let default = ctx }
.app KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep),
.project_state KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep),
.file_path KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep),
.as_ref() KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep),
.map(|p| p.display().to_string()) KeyCode::Char('l') => {
.unwrap_or_else(|| "project.buboseq".to_string()); let default_dir = ctx
ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(default))); .app
} .project_state
KeyCode::Char('l') => { .file_path
ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(String::new()))); .as_ref()
} .and_then(|p| p.parent())
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), .map(|p| {
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), let mut s = p.display().to_string();
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), if !s.ends_with('/') {
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), s.push('/');
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), }
KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), s
KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), })
KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), .unwrap_or_default();
KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep), ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(default_dir)));
KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep), }
_ => {} KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
}, KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
Focus::Editor => match key.code { KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Tab | KeyCode::Esc => ctx.dispatch(AppCommand::ToggleFocus), KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
KeyCode::Char('e') if ctrl => { KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
ctx.dispatch(AppCommand::SaveEditorToStep); KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease),
ctx.dispatch(AppCommand::CompileCurrentStep); KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)),
} KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)),
_ => { KeyCode::Delete | KeyCode::Backspace => {
ctx.app.editor_ctx.text.input(Event::Key(key)); let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
} let step = ctx.app.editor_ctx.step;
}, ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep {
bank,
pattern,
step,
selected: false,
}));
}
_ => {}
} }
InputResult::Continue InputResult::Continue
} }
fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
use crate::state::PatternsColumn;
match key.code { match key.code {
KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft), KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft),
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight), KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
@@ -308,42 +383,39 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown), KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
KeyCode::Esc | KeyCode::Backspace => ctx.dispatch(AppCommand::PatternsBack), KeyCode::Esc | KeyCode::Backspace => ctx.dispatch(AppCommand::PatternsBack),
KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter),
KeyCode::Char(' ') => { KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay),
if let PatternsViewLevel::Patterns { bank } = ctx.app.patterns_view_level {
let pattern = ctx.app.patterns_cursor;
ctx.dispatch(AppCommand::TogglePatternPlayback { bank, pattern });
}
}
KeyCode::Char('q') => { KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
selected: false, selected: false,
})); }));
} }
KeyCode::Char('r') => match ctx.app.patterns_view_level { KeyCode::Char('r') => {
PatternsViewLevel::Banks => { let bank = ctx.app.patterns_nav.bank_cursor;
let bank = ctx.app.patterns_cursor; match ctx.app.patterns_nav.column {
let current_name = ctx.app.project_state.project.banks[bank] PatternsColumn::Banks => {
.name let current_name = ctx.app.project_state.project.banks[bank]
.clone() .name
.unwrap_or_default(); .clone()
ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { .unwrap_or_default();
bank, ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank {
name: current_name, bank,
})); name: current_name,
}));
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern {
bank,
pattern,
name: current_name,
}));
}
} }
PatternsViewLevel::Patterns { bank } => { }
let pattern = ctx.app.patterns_cursor;
let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern {
bank,
pattern,
name: current_name,
}));
}
},
_ => {} _ => {}
} }
InputResult::Continue InputResult::Continue
@@ -421,3 +493,29 @@ fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} }
InputResult::Continue InputResult::Continue
} }
fn load_project_samples(ctx: &mut InputContext) {
let paths = ctx.app.project_state.project.sample_paths.clone();
if paths.is_empty() {
return;
}
let mut total_count = 0;
for path in &paths {
if path.is_dir() {
let index = doux::loader::scan_samples_dir(path);
let count = index.len();
total_count += count;
let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index));
}
}
ctx.app.audio.config.sample_paths = paths;
ctx.app.audio.config.sample_count = total_count;
if total_count > 0 {
ctx.dispatch(AppCommand::SetStatus(format!(
"Loaded {total_count} samples from project"
)));
}
}

View File

@@ -1,6 +1,6 @@
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::Path; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -12,6 +12,8 @@ const VERSION: u8 = 1;
struct ProjectFile { struct ProjectFile {
version: u8, version: u8,
banks: Vec<Bank>, banks: Vec<Bank>,
#[serde(default)]
sample_paths: Vec<PathBuf>,
} }
impl From<&Project> for ProjectFile { impl From<&Project> for ProjectFile {
@@ -19,13 +21,17 @@ impl From<&Project> for ProjectFile {
Self { Self {
version: VERSION, version: VERSION,
banks: project.banks.clone(), banks: project.banks.clone(),
sample_paths: project.sample_paths.clone(),
} }
} }
} }
impl From<ProjectFile> for Project { impl From<ProjectFile> for Project {
fn from(file: ProjectFile) -> Self { fn from(file: ProjectFile) -> Self {
Self { banks: file.banks } Self {
banks: file.banks,
sample_paths: file.sample_paths,
}
} }
} }

788
seq/src/model/forth.rs Normal file
View File

@@ -0,0 +1,788 @@
use rand::rngs::StdRng;
use rand::{Rng as RngTrait, SeedableRng};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub struct StepContext {
pub step: usize,
pub beat: f64,
pub bank: usize,
pub pattern: usize,
pub tempo: f64,
pub phase: f64,
pub slot: usize,
pub runs: usize,
}
pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type Rng = Arc<Mutex<StdRng>>;
#[derive(Clone, Debug)]
pub(crate) enum Value {
Int(i64),
Float(f64),
Str(String),
Cmd(Vec<(String, String)>),
Param(String, String),
Marker,
}
impl Value {
fn as_float(&self) -> Result<f64, String> {
match self {
Value::Float(f) => Ok(*f),
Value::Int(i) => Ok(*i as f64),
_ => Err("expected number".into()),
}
}
fn as_int(&self) -> Result<i64, String> {
match self {
Value::Int(i) => Ok(*i),
Value::Float(f) => Ok(*f as i64),
_ => Err("expected number".into()),
}
}
fn as_str(&self) -> Result<&str, String> {
match self {
Value::Str(s) => Ok(s),
_ => Err("expected string".into()),
}
}
fn is_truthy(&self) -> bool {
match self {
Value::Int(i) => *i != 0,
Value::Float(f) => *f != 0.0,
Value::Str(s) => !s.is_empty(),
Value::Cmd(_) => true,
Value::Param(_, _) => true,
Value::Marker => false,
}
}
fn is_marker(&self) -> bool {
matches!(self, Value::Marker)
}
fn is_param(&self) -> bool {
matches!(self, Value::Param(_, _))
}
fn to_param_string(&self) -> String {
match self {
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Str(s) => s.clone(),
Value::Cmd(_) | Value::Param(_, _) | Value::Marker => String::new(),
}
}
}
#[derive(Clone, Debug)]
enum Op {
PushInt(i64),
PushFloat(f64),
PushStr(String),
Dup,
Drop,
Swap,
Over,
Rot,
Nip,
Tuck,
Add,
Sub,
Mul,
Div,
Mod,
Neg,
Abs,
Min,
Max,
Eq,
Ne,
Lt,
Gt,
Le,
Ge,
And,
Or,
Not,
BranchIfZero(usize),
Branch(usize),
NewCmd,
SetParam(String),
Emit,
Get,
Set,
GetContext(String),
Rand,
Rrand,
Seed,
Cycle,
Choose,
Chance,
Maybe,
Wait,
ListStart,
ListEnd,
}
#[derive(Clone, Debug)]
enum Token {
Int(i64),
Float(f64),
Str(String),
Word(String),
}
fn tokenize(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c == '"' {
chars.next();
let mut s = String::new();
while let Some(&ch) = chars.peek() {
if ch == '"' {
chars.next();
break;
}
s.push(ch);
chars.next();
}
tokens.push(Token::Str(s));
continue;
}
if c == '(' {
while let Some(&ch) = chars.peek() {
chars.next();
if ch == ')' {
break;
}
}
continue;
}
let mut word = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
break;
}
word.push(ch);
chars.next();
}
if let Ok(i) = word.parse::<i64>() {
tokens.push(Token::Int(i));
} else if let Ok(f) = word.parse::<f64>() {
tokens.push(Token::Float(f));
} else {
tokens.push(Token::Word(word));
}
}
tokens
}
const PARAMS: &[&str] = &[
"time",
"repeat",
"dur",
"gate",
"freq",
"detune",
"speed",
"glide",
"pw",
"spread",
"mult",
"warp",
"mirror",
"harmonics",
"timbre",
"morph",
"begin",
"end",
"gain",
"postgain",
"velocity",
"pan",
"attack",
"decay",
"sustain",
"release",
"lpf",
"lpq",
"lpe",
"lpa",
"lpd",
"lps",
"lpr",
"hpf",
"hpq",
"hpe",
"hpa",
"hpd",
"hps",
"hpr",
"bpf",
"bpq",
"bpe",
"bpa",
"bpd",
"bps",
"bpr",
"ftype",
"penv",
"patt",
"pdec",
"psus",
"prel",
"vib",
"vibmod",
"vibshape",
"fm",
"fmh",
"fmshape",
"fme",
"fma",
"fmd",
"fms",
"fmr",
"am",
"amdepth",
"amshape",
"rm",
"rmdepth",
"rmshape",
"phaser",
"phaserdepth",
"phasersweep",
"phasercenter",
"flanger",
"flangerdepth",
"flangerfeedback",
"chorus",
"chorusdepth",
"chorusdelay",
"comb",
"combfreq",
"combfeedback",
"combdamp",
"coarse",
"crush",
"fold",
"wrap",
"distort",
"distortvol",
"delay",
"delaytime",
"delayfeedback",
"delaytype",
"verb",
"verbdecay",
"verbdamp",
"verbpredelay",
"verbdiff",
"voice",
"orbit",
"note",
"size",
"n",
"cut",
"reset",
];
fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
let mut ops = Vec::new();
let mut i = 0;
while i < tokens.len() {
match &tokens[i] {
Token::Int(n) => ops.push(Op::PushInt(*n)),
Token::Float(f) => ops.push(Op::PushFloat(*f)),
Token::Str(s) => ops.push(Op::PushStr(s.clone())),
Token::Word(w) => {
let word = w.as_str();
match word {
"dup" => ops.push(Op::Dup),
"drop" => ops.push(Op::Drop),
"swap" => ops.push(Op::Swap),
"over" => ops.push(Op::Over),
"rot" => ops.push(Op::Rot),
"nip" => ops.push(Op::Nip),
"tuck" => ops.push(Op::Tuck),
"+" => ops.push(Op::Add),
"-" => ops.push(Op::Sub),
"*" => ops.push(Op::Mul),
"/" => ops.push(Op::Div),
"mod" => ops.push(Op::Mod),
"neg" => ops.push(Op::Neg),
"abs" => ops.push(Op::Abs),
"min" => ops.push(Op::Min),
"max" => ops.push(Op::Max),
"=" => ops.push(Op::Eq),
"<>" => ops.push(Op::Ne),
"<" => ops.push(Op::Lt),
">" => ops.push(Op::Gt),
"<=" => ops.push(Op::Le),
">=" => ops.push(Op::Ge),
"and" => ops.push(Op::And),
"or" => ops.push(Op::Or),
"not" => ops.push(Op::Not),
"sound" | "s" => ops.push(Op::NewCmd),
"emit" => ops.push(Op::Emit),
"get" => ops.push(Op::Get),
"set" => ops.push(Op::Set),
"rand" => ops.push(Op::Rand),
"rrand" => ops.push(Op::Rrand),
"seed" => ops.push(Op::Seed),
"cycle" => ops.push(Op::Cycle),
"choose" => ops.push(Op::Choose),
"chance" => ops.push(Op::Chance),
"?" => ops.push(Op::Maybe),
"always" => {
ops.push(Op::PushFloat(1.0));
ops.push(Op::Maybe);
}
"never" => {
ops.push(Op::PushFloat(0.0));
ops.push(Op::Maybe);
}
"often" => {
ops.push(Op::PushFloat(0.75));
ops.push(Op::Maybe);
}
"sometimes" => {
ops.push(Op::PushFloat(0.5));
ops.push(Op::Maybe);
}
"rarely" => {
ops.push(Op::PushFloat(0.25));
ops.push(Op::Maybe);
}
"almostNever" => {
ops.push(Op::PushFloat(0.1));
ops.push(Op::Maybe);
}
"almostAlways" => {
ops.push(Op::PushFloat(0.9));
ops.push(Op::Maybe);
}
"wait" => ops.push(Op::Wait),
"[" => ops.push(Op::ListStart),
"]" => ops.push(Op::ListEnd),
"step" => ops.push(Op::GetContext("step".into())),
"beat" => ops.push(Op::GetContext("beat".into())),
"bank" => ops.push(Op::GetContext("bank".into())),
"pattern" => ops.push(Op::GetContext("pattern".into())),
"tempo" => ops.push(Op::GetContext("tempo".into())),
"phase" => ops.push(Op::GetContext("phase".into())),
"slot" => ops.push(Op::GetContext("slot".into())),
"runs" => ops.push(Op::GetContext("runs".into())),
"if" => {
let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?;
i += consumed;
if else_ops.is_empty() {
ops.push(Op::BranchIfZero(then_ops.len()));
ops.extend(then_ops);
} else {
ops.push(Op::BranchIfZero(then_ops.len() + 1));
ops.extend(then_ops);
ops.push(Op::Branch(else_ops.len()));
ops.extend(else_ops);
}
}
_ => {
if PARAMS.contains(&word) {
ops.push(Op::SetParam(word.into()));
} else {
return Err(format!("unknown word: {word}"));
}
}
}
}
}
i += 1;
}
Ok(ops)
}
fn compile_if(tokens: &[Token]) -> Result<(Vec<Op>, Vec<Op>, usize), String> {
let mut depth = 1;
let mut else_pos = None;
let mut then_pos = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w) = tok {
match w.as_str() {
"if" => depth += 1,
"else" if depth == 1 => else_pos = Some(i),
"then" => {
depth -= 1;
if depth == 0 {
then_pos = Some(i);
break;
}
}
_ => {}
}
}
}
let then_pos = then_pos.ok_or("missing 'then'")?;
let (then_ops, else_ops) = if let Some(ep) = else_pos {
let then_ops = compile(&tokens[..ep])?;
let else_ops = compile(&tokens[ep + 1..then_pos])?;
(then_ops, else_ops)
} else {
let then_ops = compile(&tokens[..then_pos])?;
(then_ops, Vec::new())
};
Ok((then_ops, else_ops, then_pos + 1))
}
pub struct Forth {
vars: Variables,
rng: Rng,
}
impl Forth {
pub fn new(vars: Variables, rng: Rng) -> Self {
Self { vars, rng }
}
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<Vec<String>, String> {
if script.trim().is_empty() {
return Err("empty script".into());
}
let tokens = tokenize(script);
let ops = compile(&tokens)?;
self.execute(&ops, ctx)
}
fn execute(&self, ops: &[Op], ctx: &StepContext) -> Result<Vec<String>, String> {
let mut stack: Vec<Value> = Vec::new();
let mut outputs: Vec<String> = Vec::new();
let mut time_offset: f64 = 0.0;
let mut pc = 0;
while pc < ops.len() {
match &ops[pc] {
Op::PushInt(n) => stack.push(Value::Int(*n)),
Op::PushFloat(f) => stack.push(Value::Float(*f)),
Op::PushStr(s) => stack.push(Value::Str(s.clone())),
Op::Dup => {
let v = stack.last().ok_or("stack underflow")?.clone();
stack.push(v);
}
Op::Drop => {
stack.pop().ok_or("stack underflow")?;
}
Op::Swap => {
let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
stack.swap(len - 1, len - 2);
}
Op::Over => {
let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
let v = stack[len - 2].clone();
stack.push(v);
}
Op::Rot => {
let len = stack.len();
if len < 3 {
return Err("stack underflow".into());
}
let v = stack.remove(len - 3);
stack.push(v);
}
Op::Nip => {
let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
stack.remove(len - 2);
}
Op::Tuck => {
let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
let v = stack[len - 1].clone();
stack.insert(len - 2, v);
}
Op::Add => binary_op(&mut stack, |a, b| a + b)?,
Op::Sub => binary_op(&mut stack, |a, b| a - b)?,
Op::Mul => binary_op(&mut stack, |a, b| a * b)?,
Op::Div => binary_op(&mut stack, |a, b| a / b)?,
Op::Mod => {
let b = stack.pop().ok_or("stack underflow")?.as_int()?;
let a = stack.pop().ok_or("stack underflow")?.as_int()?;
stack.push(Value::Int(a % b));
}
Op::Neg => {
let v = stack.pop().ok_or("stack underflow")?;
match v {
Value::Int(i) => stack.push(Value::Int(-i)),
Value::Float(f) => stack.push(Value::Float(-f)),
_ => return Err("expected number".into()),
}
}
Op::Abs => {
let v = stack.pop().ok_or("stack underflow")?;
match v {
Value::Int(i) => stack.push(Value::Int(i.abs())),
Value::Float(f) => stack.push(Value::Float(f.abs())),
_ => return Err("expected number".into()),
}
}
Op::Min => binary_op(&mut stack, |a, b| a.min(b))?,
Op::Max => binary_op(&mut stack, |a, b| a.max(b))?,
Op::Eq => cmp_op(&mut stack, |a, b| (a - b).abs() < f64::EPSILON)?,
Op::Ne => cmp_op(&mut stack, |a, b| (a - b).abs() >= f64::EPSILON)?,
Op::Lt => cmp_op(&mut stack, |a, b| a < b)?,
Op::Gt => cmp_op(&mut stack, |a, b| a > b)?,
Op::Le => cmp_op(&mut stack, |a, b| a <= b)?,
Op::Ge => cmp_op(&mut stack, |a, b| a >= b)?,
Op::And => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
stack.push(Value::Int(if a && b { 1 } else { 0 }));
}
Op::Or => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
stack.push(Value::Int(if a || b { 1 } else { 0 }));
}
Op::Not => {
let v = stack.pop().ok_or("stack underflow")?.is_truthy();
stack.push(Value::Int(if v { 0 } else { 1 }));
}
Op::BranchIfZero(offset) => {
let v = stack.pop().ok_or("stack underflow")?;
if !v.is_truthy() {
pc += offset;
}
}
Op::Branch(offset) => {
pc += offset;
}
Op::NewCmd => {
let name = stack.pop().ok_or("stack underflow")?;
let name = name.as_str()?;
stack.push(Value::Cmd(vec![("sound".into(), name.into())]));
}
Op::SetParam(param) => {
let val = stack.pop().ok_or("stack underflow")?;
stack.push(Value::Param(param.clone(), val.to_param_string()));
}
Op::Emit => {
let mut params = Vec::new();
while let Some(v) = stack.last() {
if v.is_param() {
if let Value::Param(k, v) = stack.pop().unwrap() {
params.push((k, v));
}
} else {
break;
}
}
params.reverse();
let cmd = stack.pop().ok_or("stack underflow")?;
if let Value::Cmd(mut pairs) = cmd {
pairs.extend(params);
if time_offset > 0.0 {
pairs.push(("delta".into(), time_offset.to_string()));
}
outputs.push(format_cmd(&pairs));
} else {
return Err("expected command".into());
}
}
Op::Get => {
let name = stack.pop().ok_or("stack underflow")?;
let name = name.as_str()?;
let vars = self.vars.lock().unwrap();
let val = vars.get(name).cloned().unwrap_or(Value::Int(0));
stack.push(val);
}
Op::Set => {
let name = stack.pop().ok_or("stack underflow")?;
let name = name.as_str()?.to_string();
let val = stack.pop().ok_or("stack underflow")?;
self.vars.lock().unwrap().insert(name, val);
}
Op::GetContext(name) => {
let val = match name.as_str() {
"step" => Value::Int(ctx.step as i64),
"beat" => Value::Float(ctx.beat),
"bank" => Value::Int(ctx.bank as i64),
"pattern" => Value::Int(ctx.pattern as i64),
"tempo" => Value::Float(ctx.tempo),
"phase" => Value::Float(ctx.phase),
"slot" => Value::Int(ctx.slot as i64),
"runs" => Value::Int(ctx.runs as i64),
_ => Value::Int(0),
};
stack.push(val);
}
Op::Rand => {
let max = stack.pop().ok_or("stack underflow")?.as_float()?;
let min = stack.pop().ok_or("stack underflow")?.as_float()?;
let val = self.rng.lock().unwrap().gen_range(min..max);
stack.push(Value::Float(val));
}
Op::Rrand => {
let max = stack.pop().ok_or("stack underflow")?.as_int()?;
let min = stack.pop().ok_or("stack underflow")?.as_int()?;
let val = self.rng.lock().unwrap().gen_range(min..=max);
stack.push(Value::Int(val));
}
Op::Seed => {
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
}
Op::Cycle => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if count == 0 {
return Err("cycle count must be > 0".into());
}
if stack.len() < count {
return Err("stack underflow".into());
}
let start = stack.len() - count;
let values: Vec<Value> = stack.drain(start..).collect();
let idx = ctx.runs % count;
stack.push(values[idx].clone());
}
Op::Choose => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if count == 0 {
return Err("choose count must be > 0".into());
}
if stack.len() < count {
return Err("stack underflow".into());
}
let start = stack.len() - count;
let values: Vec<Value> = stack.drain(start..).collect();
let idx = self.rng.lock().unwrap().gen_range(0..count);
stack.push(values[idx].clone());
}
Op::Chance => {
let prob = stack.pop().ok_or("stack underflow")?.as_float()?;
let val: f64 = self.rng.lock().unwrap().gen();
stack.push(Value::Int(if val < prob { 1 } else { 0 }));
}
Op::Maybe => {
let prob = stack.pop().ok_or("stack underflow")?.as_float()?;
let param = stack.pop().ok_or("stack underflow")?;
if !param.is_param() {
return Err("? requires a param".into());
}
let val: f64 = self.rng.lock().unwrap().gen();
if val < prob {
stack.push(param);
}
}
Op::Wait => {
let duration = stack.pop().ok_or("stack underflow")?.as_float()?;
time_offset += duration;
}
Op::ListStart => {
stack.push(Value::Marker);
}
Op::ListEnd => {
let mut count = 0;
let mut values = Vec::new();
while let Some(v) = stack.pop() {
if v.is_marker() {
break;
}
values.push(v);
count += 1;
}
values.reverse();
for v in values {
stack.push(v);
}
stack.push(Value::Int(count));
}
}
pc += 1;
}
if outputs.is_empty() {
if let Some(Value::Cmd(pairs)) = stack.pop() {
outputs.push(format_cmd(&pairs));
}
}
Ok(outputs)
}
}
fn binary_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
where
F: Fn(f64, f64) -> f64,
{
let b = stack.pop().ok_or("stack underflow")?.as_float()?;
let a = stack.pop().ok_or("stack underflow")?.as_float()?;
let result = f(a, b);
if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
stack.push(Value::Int(result as i64));
} else {
stack.push(Value::Float(result));
}
Ok(())
}
fn cmp_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
where
F: Fn(f64, f64) -> bool,
{
let b = stack.pop().ok_or("stack underflow")?.as_float()?;
let a = stack.pop().ok_or("stack underflow")?.as_float()?;
stack.push(Value::Int(if f(a, b) { 1 } else { 0 }));
Ok(())
}
fn format_cmd(pairs: &[(String, String)]) -> String {
let parts: Vec<String> = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect();
format!("/{}", parts.join("/"))
}

View File

@@ -1,4 +1,5 @@
mod file; mod file;
mod forth;
mod project; mod project;
mod script; mod script;

View File

@@ -1,3 +1,5 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS}; use crate::config::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
@@ -83,6 +85,8 @@ pub struct Step {
pub script: String, pub script: String,
#[serde(skip)] #[serde(skip)]
pub command: Option<String>, pub command: Option<String>,
#[serde(default)]
pub source: Option<usize>,
} }
impl Default for Step { impl Default for Step {
@@ -91,6 +95,7 @@ impl Default for Step {
active: true, active: true,
script: String::new(), script: String::new(),
command: None, command: None,
source: None,
} }
} }
} }
@@ -132,6 +137,27 @@ impl Pattern {
} }
self.length = length; self.length = length;
} }
pub fn resolve_source(&self, index: usize) -> usize {
let mut current = index;
for _ in 0..self.steps.len() {
if let Some(step) = self.steps.get(current) {
if let Some(source) = step.source {
current = source;
} else {
return current;
}
} else {
return index;
}
}
index
}
pub fn resolve_script(&self, index: usize) -> Option<&str> {
let source_idx = self.resolve_source(index);
self.steps.get(source_idx).map(|s| s.script.as_str())
}
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -153,12 +179,15 @@ impl Default for Bank {
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Project { pub struct Project {
pub banks: Vec<Bank>, pub banks: Vec<Bank>,
#[serde(default)]
pub sample_paths: Vec<PathBuf>,
} }
impl Default for Project { impl Default for Project {
fn default() -> Self { fn default() -> Self {
Self { Self {
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(), banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
sample_paths: Vec::new(),
} }
} }
} }

View File

@@ -1,274 +1,19 @@
use rand::rngs::StdRng; use super::forth::Forth;
use rand::{Rng as RngTrait, SeedableRng};
use rhai::{Dynamic, Engine, Scope};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub type Variables = Arc<Mutex<HashMap<String, Dynamic>>>; pub use super::forth::{Rng, StepContext, Variables};
pub type Rng = Arc<Mutex<StdRng>>;
#[derive(Clone, Debug)]
pub struct Cmd {
pairs: Vec<(String, String)>,
}
impl Cmd {
fn new() -> Self {
Self { pairs: vec![] }
}
fn with(sound: &str) -> Self {
let mut cmd = Self::new();
cmd.pairs.push(("sound".into(), sound.into()));
cmd
}
fn with_dur_f(sound: &str, dur: f64) -> Self {
let mut cmd = Self::with(sound);
cmd.pairs.push(("dur".into(), dur.to_string()));
cmd
}
fn with_dur_i(sound: &str, dur: i64) -> Self {
let mut cmd = Self::with(sound);
cmd.pairs.push(("dur".into(), dur.to_string()));
cmd
}
fn set(&mut self, key: &str, val: &str) -> Self {
self.pairs.push((key.into(), val.into()));
self.clone()
}
}
impl std::fmt::Display for Cmd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<String> = self.pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect();
write!(f, "/{}", parts.join("/"))
}
}
pub struct StepContext {
pub step: usize,
pub beat: f64,
pub bank: usize,
pub pattern: usize,
pub tempo: f64,
pub phase: f64,
pub slot: usize,
}
pub struct ScriptEngine { pub struct ScriptEngine {
engine: Engine, forth: Forth,
} }
impl ScriptEngine { impl ScriptEngine {
pub fn new(vars: Variables, rng: Rng) -> Self { pub fn new(vars: Variables, rng: Rng) -> Self {
let mut engine = Engine::new(); Self {
engine.set_max_expr_depths(64, 32); forth: Forth::new(vars, rng),
}
register_cmd(&mut engine);
let vars_for_set = Arc::clone(&vars);
let vars_for_get = Arc::clone(&vars);
engine.register_fn("set", move |name: &str, value: Dynamic| {
vars_for_set.lock().unwrap().insert(name.to_string(), value);
});
engine.register_fn("get", move |name: &str| -> Dynamic {
vars_for_get
.lock()
.unwrap()
.get(name)
.cloned()
.unwrap_or(Dynamic::UNIT)
});
let rng_rand_ff = Arc::clone(&rng);
let rng_rand_ii = Arc::clone(&rng);
let rng_rrand_ff = Arc::clone(&rng);
let rng_rrand_ii = Arc::clone(&rng);
let rng_seed = Arc::clone(&rng);
engine.register_fn("rand", move |min: f64, max: f64| -> f64 {
rng_rand_ff.lock().unwrap().gen_range(min..max)
});
engine.register_fn("rand", move |min: i64, max: i64| -> f64 {
rng_rand_ii
.lock()
.unwrap()
.gen_range(min as f64..max as f64)
});
engine.register_fn("rrand", move |min: f64, max: f64| -> i64 {
rng_rrand_ff
.lock()
.unwrap()
.gen_range(min as i64..=max as i64)
});
engine.register_fn("rrand", move |min: i64, max: i64| -> i64 {
rng_rrand_ii.lock().unwrap().gen_range(min..=max)
});
engine.register_fn("seed", move |s: i64| {
*rng_seed.lock().unwrap() = StdRng::seed_from_u64(s as u64);
});
Self { engine }
} }
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<String, String> { pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<Vec<String>, String> {
if script.trim().is_empty() { self.forth.evaluate(script, ctx)
return Err("empty script".to_string());
}
let mut scope = Scope::new();
scope.push("step", ctx.step as i64);
scope.push("beat", ctx.beat);
scope.push("bank", ctx.bank as i64);
scope.push("pattern", ctx.pattern as i64);
scope.push("tempo", ctx.tempo);
scope.push("phase", ctx.phase);
scope.push("slot", ctx.slot as i64);
if let Ok(cmd) = self.engine.eval_with_scope::<Cmd>(&mut scope, script) {
return Ok(cmd.to_string());
}
self.engine
.eval_with_scope::<String>(&mut scope, script)
.map_err(|e| e.to_string())
} }
} }
fn register_cmd(engine: &mut Engine) {
engine.register_type_with_name::<Cmd>("Cmd");
engine.register_fn("sound", Cmd::with);
engine.register_fn("sound", Cmd::with_dur_f);
engine.register_fn("sound", Cmd::with_dur_i);
engine.register_fn("s", Cmd::with);
engine.register_fn("s", Cmd::with_dur_f);
engine.register_fn("s", Cmd::with_dur_i);
macro_rules! reg_both {
($($name:expr),*) => {
$(
engine.register_fn($name, |c: &mut Cmd, v: f64| c.set($name, &v.to_string()));
engine.register_fn($name, |c: &mut Cmd, v: i64| c.set($name, &v.to_string()));
)*
};
}
reg_both!(
"time",
"repeat",
"dur",
"gate",
"freq",
"detune",
"speed",
"glide",
"pw",
"spread",
"mult",
"warp",
"mirror",
"harmonics",
"timbre",
"morph",
"begin",
"end",
"gain",
"postgain",
"velocity",
"pan",
"attack",
"decay",
"sustain",
"release",
"lpf",
"lpq",
"lpe",
"lpa",
"lpd",
"lps",
"lpr",
"hpf",
"hpq",
"hpe",
"hpa",
"hpd",
"hps",
"hpr",
"bpf",
"bpq",
"bpe",
"bpa",
"bpd",
"bps",
"bpr",
"ftype",
"penv",
"patt",
"pdec",
"psus",
"prel",
"vib",
"vibmod",
"vibshape",
"fm",
"fmh",
"fmshape",
"fme",
"fma",
"fmd",
"fms",
"fmr",
"am",
"amdepth",
"amshape",
"rm",
"rmdepth",
"rmshape",
"phaser",
"phaserdepth",
"phasersweep",
"phasercenter",
"flanger",
"flangerdepth",
"flangerfeedback",
"chorus",
"chorusdepth",
"chorusdelay",
"comb",
"combfreq",
"combfeedback",
"combdamp",
"coarse",
"crush",
"fold",
"wrap",
"distort",
"distortvol",
"delay",
"delaytime",
"delayfeedback",
"delaytype",
"verb",
"verbdecay",
"verbdamp",
"verbpredelay",
"verbdiff",
"voice",
"orbit",
"note",
"size",
"n",
"cut"
);
engine.register_fn("reset", |c: &mut Cmd, v: bool| {
c.set("reset", if v { "1" } else { "0" })
});
}

View File

@@ -18,6 +18,14 @@ pub struct EditorContext {
pub step: usize, pub step: usize,
pub focus: Focus, pub focus: Focus,
pub text: TextArea<'static>, pub text: TextArea<'static>,
pub copied_step: Option<CopiedStep>,
}
#[derive(Clone, Copy)]
pub struct CopiedStep {
pub bank: usize,
pub pattern: usize,
pub step: usize,
} }
impl Default for EditorContext { impl Default for EditorContext {
@@ -28,6 +36,7 @@ impl Default for EditorContext {
step: 0, step: 0,
focus: Focus::Sequencer, focus: Focus::Sequencer,
text: TextArea::default(), text: TextArea::default(),
copied_step: None,
} }
} }
} }

View File

@@ -7,9 +7,9 @@ pub mod project;
pub mod ui; pub mod ui;
pub use audio::{AudioFocus, AudioSettings, Metrics}; pub use audio::{AudioFocus, AudioSettings, Metrics};
pub use editor::{EditorContext, Focus, PatternField}; pub use editor::{CopiedStep, EditorContext, Focus, PatternField};
pub use modal::Modal; pub use modal::Modal;
pub use patterns_nav::PatternsViewLevel; pub use patterns_nav::{PatternsColumn, PatternsNav};
pub use playback::PlaybackState; pub use playback::PlaybackState;
pub use project::ProjectState; pub use project::ProjectState;
pub use ui::UiState; pub use ui::UiState;

View File

@@ -6,6 +6,12 @@ pub enum Modal {
ConfirmQuit { ConfirmQuit {
selected: bool, selected: bool,
}, },
ConfirmDeleteStep {
bank: usize,
pattern: usize,
step: usize,
selected: bool,
},
SaveAs(String), SaveAs(String),
LoadFrom(String), LoadFrom(String),
RenameBank { RenameBank {
@@ -22,4 +28,5 @@ pub enum Modal {
input: String, input: String,
}, },
AddSamplePath(String), AddSamplePath(String),
Editor,
} }

View File

@@ -1,8 +1,53 @@
#[derive(Clone, Copy, PartialEq, Eq, Default)] #[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum PatternsViewLevel { pub enum PatternsColumn {
#[default] #[default]
Banks, Banks,
Patterns { Patterns,
bank: usize, }
},
#[derive(Clone, Copy, Default)]
pub struct PatternsNav {
pub column: PatternsColumn,
pub bank_cursor: usize,
pub pattern_cursor: usize,
}
impl PatternsNav {
pub fn move_left(&mut self) {
self.column = PatternsColumn::Banks;
}
pub fn move_right(&mut self) {
self.column = PatternsColumn::Patterns;
}
pub fn move_up(&mut self) {
match self.column {
PatternsColumn::Banks => {
self.bank_cursor = (self.bank_cursor + 15) % 16;
}
PatternsColumn::Patterns => {
self.pattern_cursor = (self.pattern_cursor + 15) % 16;
}
}
}
pub fn move_down(&mut self) {
match self.column {
PatternsColumn::Banks => {
self.bank_cursor = (self.bank_cursor + 1) % 16;
}
PatternsColumn::Patterns => {
self.pattern_cursor = (self.pattern_cursor + 1) % 16;
}
}
}
pub fn selected_bank(&self) -> usize {
self.bank_cursor
}
pub fn selected_pattern(&self) -> usize {
self.pattern_cursor
}
} }

View File

@@ -8,6 +8,7 @@ pub struct UiState {
pub modal: Modal, pub modal: Modal,
pub doc_topic: usize, pub doc_topic: usize,
pub doc_scroll: usize, pub doc_scroll: usize,
pub show_title: bool,
} }
impl Default for UiState { impl Default for UiState {
@@ -18,6 +19,7 @@ impl Default for UiState {
modal: Modal::None, modal: Modal::None,
doc_topic: 0, doc_topic: 0,
doc_scroll: 0, doc_scroll: 0,
show_title: true,
} }
} }
} }

277
seq/src/views/highlight.rs Normal file
View File

@@ -0,0 +1,277 @@
use ratatui::style::{Color, Style};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Number,
String,
Comment,
Keyword,
StackOp,
Operator,
Sound,
Param,
Context,
Default,
}
impl TokenKind {
pub fn style(self) -> Style {
match self {
TokenKind::Number => Style::default().fg(Color::Rgb(255, 180, 100)),
TokenKind::String => Style::default().fg(Color::Rgb(150, 220, 150)),
TokenKind::Comment => Style::default().fg(Color::Rgb(100, 100, 100)),
TokenKind::Keyword => Style::default().fg(Color::Rgb(220, 120, 220)),
TokenKind::StackOp => Style::default().fg(Color::Rgb(120, 180, 220)),
TokenKind::Operator => Style::default().fg(Color::Rgb(200, 200, 130)),
TokenKind::Sound => Style::default().fg(Color::Rgb(100, 220, 200)),
TokenKind::Param => Style::default().fg(Color::Rgb(180, 150, 220)),
TokenKind::Context => Style::default().fg(Color::Rgb(220, 180, 120)),
TokenKind::Default => Style::default().fg(Color::Rgb(200, 200, 200)),
}
}
}
pub struct Token {
pub start: usize,
pub end: usize,
pub kind: TokenKind,
}
const STACK_OPS: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
const OPERATORS: &[&str] = &[
"+", "-", "*", "/", "mod", "neg", "abs", "min", "max", "=", "<>", "<", ">", "<=", ">=", "and",
"or", "not",
];
const KEYWORDS: &[&str] = &[
"if", "else", "then", "emit", "get", "set", "rand", "rrand", "seed", "cycle", "choose",
"chance", "[", "]",
];
const SOUND: &[&str] = &["sound", "s"];
const CONTEXT: &[&str] = &[
"step", "beat", "bank", "pattern", "tempo", "phase", "slot", "runs",
];
const PARAMS: &[&str] = &[
"time",
"repeat",
"dur",
"gate",
"freq",
"detune",
"speed",
"glide",
"pw",
"spread",
"mult",
"warp",
"mirror",
"harmonics",
"timbre",
"morph",
"begin",
"end",
"gain",
"postgain",
"velocity",
"pan",
"attack",
"decay",
"sustain",
"release",
"lpf",
"lpq",
"lpe",
"lpa",
"lpd",
"lps",
"lpr",
"hpf",
"hpq",
"hpe",
"hpa",
"hpd",
"hps",
"hpr",
"bpf",
"bpq",
"bpe",
"bpa",
"bpd",
"bps",
"bpr",
"ftype",
"penv",
"patt",
"pdec",
"psus",
"prel",
"vib",
"vibmod",
"vibshape",
"fm",
"fmh",
"fmshape",
"fme",
"fma",
"fmd",
"fms",
"fmr",
"am",
"amdepth",
"amshape",
"rm",
"rmdepth",
"rmshape",
"phaser",
"phaserdepth",
"phasersweep",
"phasercenter",
"flanger",
"flangerdepth",
"flangerfeedback",
"chorus",
"chorusdepth",
"chorusdelay",
"comb",
"combfreq",
"combfeedback",
"combdamp",
"coarse",
"crush",
"fold",
"wrap",
"distort",
"distortvol",
"delay",
"delaytime",
"delayfeedback",
"delaytype",
"verb",
"verbdecay",
"verbdamp",
"verbpredelay",
"verbdiff",
"voice",
"orbit",
"note",
"size",
"n",
"cut",
"reset",
];
pub fn tokenize_line(line: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = line.char_indices().peekable();
while let Some((start, c)) = chars.next() {
if c.is_whitespace() {
continue;
}
if c == '(' {
let end = line.len();
let comment_end = line[start..]
.find(')')
.map(|i| start + i + 1)
.unwrap_or(end);
tokens.push(Token {
start,
end: comment_end,
kind: TokenKind::Comment,
});
while let Some((i, _)) = chars.peek() {
if *i >= comment_end {
break;
}
chars.next();
}
continue;
}
if c == '"' {
let mut end = start + 1;
while let Some((i, ch)) = chars.next() {
end = i + ch.len_utf8();
if ch == '"' {
break;
}
}
tokens.push(Token {
start,
end,
kind: TokenKind::String,
});
continue;
}
let mut end = start + c.len_utf8();
while let Some((i, ch)) = chars.peek() {
if ch.is_whitespace() {
break;
}
end = *i + ch.len_utf8();
chars.next();
}
let word = &line[start..end];
let kind = classify_word(word);
tokens.push(Token { start, end, kind });
}
tokens
}
fn classify_word(word: &str) -> TokenKind {
if word.parse::<f64>().is_ok() || word.parse::<i64>().is_ok() {
return TokenKind::Number;
}
if STACK_OPS.contains(&word) {
return TokenKind::StackOp;
}
if OPERATORS.contains(&word) {
return TokenKind::Operator;
}
if KEYWORDS.contains(&word) {
return TokenKind::Keyword;
}
if SOUND.contains(&word) {
return TokenKind::Sound;
}
if CONTEXT.contains(&word) {
return TokenKind::Context;
}
if PARAMS.contains(&word) {
return TokenKind::Param;
}
TokenKind::Default
}
pub fn highlight_line(line: &str) -> Vec<(Style, String)> {
let tokens = tokenize_line(line);
let mut result = Vec::new();
let mut last_end = 0;
for token in tokens {
if token.start > last_end {
result.push((
TokenKind::Default.style(),
line[last_end..token.start].to_string(),
));
}
result.push((token.kind.style(), line[token.start..token.end].to_string()));
last_end = token.end;
}
if last_end < line.len() {
result.push((TokenKind::Default.style(), line[last_end..].to_string()));
}
result
}

View File

@@ -1,11 +1,12 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::engine::SequencerSnapshot; use crate::engine::SequencerSnapshot;
use crate::state::Focus; use crate::views::highlight::highlight_line;
use crate::widgets::{Orientation, Scope, VuMeter}; use crate::widgets::{Orientation, Scope, VuMeter};
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) { pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) {
@@ -16,32 +17,22 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar
]) ])
.areas(area); .areas(area);
let [seq_area, editor_area] = let [sequencer_area, preview_area] =
Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area); Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(main_area);
render_sequencer(frame, app, snapshot, seq_area); render_sequencer(frame, app, snapshot, sequencer_area);
render_editor(frame, app, editor_area); render_step_preview(frame, app, preview_area);
render_scope(frame, app, scope_area); render_scope(frame, app, scope_area);
render_vu_meter(frame, app, vu_area); render_vu_meter(frame, app, vu_area);
} }
fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, 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 border_style = Style::new().fg(Color::Rgb(100, 160, 180));
"*"
} else {
" "
};
let border_style = if app.editor_ctx.focus == Focus::Sequencer {
Style::new().fg(Color::Rgb(100, 160, 180))
} else {
Style::new().fg(Color::Rgb(70, 75, 85))
};
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style) .border_style(border_style)
.title(format!("Sequencer{focus_indicator}")); .title("Sequencer");
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
@@ -116,6 +107,7 @@ fn render_tile(
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);
let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false);
let is_selected = step_idx == app.editor_ctx.step; let is_selected = step_idx == app.editor_ctx.step;
let playing_slot = if app.playback.playing { let playing_slot = if app.playback.playing {
@@ -132,17 +124,21 @@ fn render_tile(
let is_playing = playing_slot.is_some(); let is_playing = playing_slot.is_some();
let (bg, fg) = match (is_playing, is_active, is_selected) { let (bg, fg) = match (is_playing, is_active, is_selected, is_linked) {
(true, true, _) => (Color::Rgb(195, 85, 65), Color::White), (true, true, _, _) => (Color::Rgb(195, 85, 65), Color::White),
(true, false, _) => (Color::Rgb(180, 120, 45), Color::Black), (true, false, _, _) => (Color::Rgb(180, 120, 45), Color::Black),
(false, true, true) => (Color::Rgb(0, 220, 180), Color::Black), (false, true, true, true) => (Color::Rgb(180, 140, 220), Color::Black),
(false, true, false) => (Color::Rgb(45, 106, 95), Color::White), (false, true, true, false) => (Color::Rgb(0, 220, 180), Color::Black),
(false, false, true) => (Color::Rgb(80, 180, 255), Color::Black), (false, true, false, true) => (Color::Rgb(90, 70, 120), Color::White),
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), (false, true, false, false) => (Color::Rgb(45, 106, 95), Color::White),
(false, false, true, _) => (Color::Rgb(80, 180, 255), Color::Black),
(false, false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
}; };
let symbol = if is_playing { let symbol = if is_playing {
"".to_string() "".to_string()
} else if let Some(source) = step.and_then(|s| s.source) {
format!("{:02}", source + 1)
} else { } else {
format!("{:02}", step_idx + 1) format!("{:02}", step_idx + 1)
}; };
@@ -154,40 +150,6 @@ fn render_tile(
frame.render_widget(tile, area); frame.render_widget(tile, area);
} }
fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) {
let focus_indicator = if app.editor_ctx.focus == Focus::Editor {
"*"
} else {
" "
};
let border_style = if app.ui.is_flashing() {
Style::new().fg(Color::Green)
} else if app.editor_ctx.focus == Focus::Editor {
Style::new().fg(Color::Rgb(100, 160, 180))
} else {
Style::new().fg(Color::Rgb(70, 75, 85))
};
let step_num = app.editor_ctx.step + 1;
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(format!("Step {step_num:02} Script{focus_indicator}"));
let inner = block.inner(area);
frame.render_widget(block, area);
let cursor_style = if app.editor_ctx.focus == Focus::Editor {
Style::new().bg(Color::White).fg(Color::Black)
} else {
Style::default()
};
app.editor_ctx.text.set_cursor_style(cursor_style);
frame.render_widget(&app.editor_ctx.text, inner);
}
fn render_scope(frame: &mut Frame, app: &App, area: Rect) { fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -213,3 +175,45 @@ fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right); let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
frame.render_widget(vu, inner); frame.render_widget(vu, inner);
} }
fn render_step_preview(frame: &mut Frame, app: &App, area: Rect) {
let pattern = app.current_edit_pattern();
let step_idx = app.editor_ctx.step;
let step = pattern.step(step_idx);
let [title_area, content_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false);
let source_idx = step.and_then(|s| s.source);
let title = if let Some(src) = source_idx {
format!(" Step {:02}{:02} ", step_idx + 1, src + 1)
} else {
format!(" Step {:02} ", step_idx + 1)
};
let title_color = if is_linked {
Color::Rgb(180, 140, 220)
} else {
Color::Rgb(120, 125, 135)
};
let title_p = Paragraph::new(title).style(Style::new().fg(title_color));
frame.render_widget(title_p, title_area);
let script = pattern.resolve_script(step_idx).unwrap_or("");
if script.is_empty() {
let empty = Paragraph::new(" (empty)").style(Style::new().fg(Color::Rgb(80, 85, 95)));
frame.render_widget(empty, content_area);
return;
}
let spans: Vec<_> = highlight_line(script)
.into_iter()
.map(|(style, text)| ratatui::text::Span::styled(text, style))
.collect();
let mut line_spans = vec![ratatui::text::Span::raw(" ")];
line_spans.extend(spans);
let line = Line::from(line_spans);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, content_area);
}

View File

@@ -1,7 +1,9 @@
pub mod audio_view; pub mod audio_view;
pub mod doc_view; pub mod doc_view;
pub mod highlight;
pub mod main_view; pub mod main_view;
pub mod patterns_view; pub mod patterns_view;
mod render; mod render;
pub mod title_view;
pub use render::render; pub use render::render;

View File

@@ -1,37 +1,36 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
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; use crate::app::App;
use crate::engine::SequencerSnapshot; use crate::engine::SequencerSnapshot;
use crate::state::PatternsViewLevel; use crate::state::PatternsColumn;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
match app.patterns_view_level { let [banks_area, patterns_area] =
PatternsViewLevel::Banks => render_banks(frame, app, snapshot, area), Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
PatternsViewLevel::Patterns { bank } => render_patterns(frame, app, snapshot, area, bank),
} render_banks(frame, app, snapshot, banks_area);
render_patterns(frame, app, snapshot, patterns_area);
} }
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
let border_color = if is_focused {
Color::Rgb(100, 160, 180)
} else {
Color::Rgb(70, 75, 85)
};
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(border_color))
.title("Banks"); .title("Banks");
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
if inner.width < 50 {
let msg = Paragraph::new("Terminal too narrow")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
frame.render_widget(msg, inner);
return;
}
let banks_with_playback: Vec<usize> = snapshot let banks_with_playback: Vec<usize> = snapshot
.slot_data .slot_data
.iter() .iter()
@@ -39,57 +38,85 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
.map(|s| s.bank) .map(|s| s.bank)
.collect(); .collect();
let bank_names: Vec<Option<&str>> = app let banks_with_queued: Vec<usize> = app
.project_state .playback
.project .queued_changes
.banks
.iter() .iter()
.map(|b| b.name.as_deref()) .filter_map(|c| match c {
crate::engine::SlotChange::Add { bank, .. } => Some(*bank),
_ => None,
})
.collect(); .collect();
render_grid( let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
frame, let row_areas = Layout::vertical(rows).split(inner);
inner,
app.patterns_cursor, for idx in 0..16 {
app.editor_ctx.bank, if idx >= row_areas.len() {
&banks_with_playback, break;
&bank_names, }
); let row_area = row_areas[idx];
let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor;
let is_selected = idx == app.patterns_nav.bank_cursor;
let is_edit = idx == app.editor_ctx.bank;
let is_playing = banks_with_playback.contains(&idx);
let is_queued = banks_with_queued.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued) {
(true, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
};
let name = app.project_state.project.banks[idx]
.name
.as_deref()
.unwrap_or("");
let label = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
let style = Style::new().bg(bg).fg(fg);
let style = if is_playing || is_queued {
style.add_modifier(Modifier::BOLD)
} else {
style
};
let para = Paragraph::new(label).style(style);
frame.render_widget(para, row_area);
}
} }
fn render_patterns( fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
frame: &mut Frame, let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
app: &App, let border_color = if is_focused {
snapshot: &SequencerSnapshot, Color::Rgb(100, 160, 180)
area: Rect, } else {
bank: usize, Color::Rgb(70, 75, 85)
) { };
let bank_name = app.project_state.project.banks[bank].name.as_deref();
let title_text = match bank_name { let bank = app.patterns_nav.bank_cursor;
Some(name) => format!("{name} Patterns"), let bank_name = app.project_state.project.banks[bank].name.as_deref();
None => format!("Bank {:02} Patterns", bank + 1), let title = match bank_name {
Some(name) => format!("Patterns ({name})"),
None => format!("Patterns (Bank {:02})", bank + 1),
}; };
let title = Line::from(vec![
Span::raw(title_text),
Span::styled(" [Esc]←", Style::new().fg(Color::Rgb(120, 125, 135))),
]);
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(border_color))
.title(title); .title(title);
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
if inner.width < 50 {
let msg = Paragraph::new("Terminal too narrow")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
frame.render_widget(msg, inner);
return;
}
let playing_patterns: Vec<usize> = snapshot let playing_patterns: Vec<usize> = snapshot
.slot_data .slot_data
.iter() .iter()
@@ -97,173 +124,85 @@ fn render_patterns(
.map(|s| s.pattern) .map(|s| s.pattern)
.collect(); .collect();
let edit_pattern = if app.editor_ctx.bank == bank { let queued_to_play: Vec<usize> = app
app.editor_ctx.pattern .playback
} else { .queued_changes
usize::MAX
};
let pattern_names: Vec<Option<&str>> = app.project_state.project.banks[bank]
.patterns
.iter() .iter()
.map(|p| p.name.as_deref()) .filter_map(|c| match c {
crate::engine::SlotChange::Add {
bank: b, pattern, ..
} if *b == bank => Some(*pattern),
_ => None,
})
.collect(); .collect();
render_pattern_grid( let queued_to_stop: Vec<usize> = app
frame, .playback
app, .queued_changes
snapshot, .iter()
inner, .filter_map(|c| match c {
bank, crate::engine::SlotChange::Remove { slot } => {
app.patterns_cursor, let s = snapshot.slot_data[*slot];
edit_pattern, if s.active && s.bank == bank {
&playing_patterns, Some(s.pattern)
&pattern_names, } else {
); None
} }
fn render_grid(
frame: &mut Frame,
area: Rect,
cursor: usize,
edit_pos: usize,
playing_positions: &[usize],
names: &[Option<&str>],
) {
let rows = Layout::vertical([
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
])
.split(area);
for row in 0..4 {
let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]);
for col in 0..4 {
let idx = row * 4 + col;
let is_cursor = idx == cursor;
let is_edit = idx == edit_pos;
let is_playing = playing_positions.contains(&idx);
let (bg, fg) = match (is_cursor, is_edit, is_playing) {
(true, _, _) => (Color::Cyan, Color::Black),
(false, true, _) => (Color::Rgb(45, 106, 95), Color::White),
(false, false, true) => (Color::Rgb(45, 80, 45), Color::Green),
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
};
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
let number = format!("{:02}", idx + 1);
let cell = cols[col];
// Fill background
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
let center_y = cell.y + cell.height / 2;
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
if name.is_empty() {
// Number centered
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
} else {
// Number centered at top
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
top_area,
);
// Name centered in middle
frame.render_widget(
Paragraph::new(name)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
}
}
}
}
fn render_pattern_grid(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
area: Rect,
bank: usize,
cursor: usize,
edit_pos: usize,
playing_positions: &[usize],
names: &[Option<&str>],
) {
let rows = Layout::vertical([
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
])
.split(area);
for row in 0..4 {
let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]);
for col in 0..4 {
let idx = row * 4 + col;
let is_cursor = idx == cursor;
let is_edit = idx == edit_pos;
let is_playing = playing_positions.contains(&idx);
let queued = app.is_pattern_queued(bank, idx, snapshot);
let (bg, fg, prefix) = match (is_cursor, is_playing, queued) {
(true, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, Some(false)) => (Color::Rgb(120, 90, 30), Color::Yellow, "×"),
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, ""),
(false, false, Some(true)) => (Color::Rgb(80, 80, 45), Color::Yellow, "?"),
(false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135), ""),
};
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
let number = format!("{}{:02}", prefix, idx + 1);
let cell = cols[col];
// Fill background
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
let center_y = cell.y + cell.height / 2;
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
if name.is_empty() {
// Number centered
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
} else {
// Number centered at top
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
top_area,
);
// Name centered in middle
frame.render_widget(
Paragraph::new(name)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
} }
_ => None,
})
.collect();
let edit_pattern = if app.editor_ctx.bank == bank {
Some(app.editor_ctx.pattern)
} else {
None
};
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
let row_areas = Layout::vertical(rows).split(inner);
for idx in 0..16 {
if idx >= row_areas.len() {
break;
} }
let row_area = row_areas[idx];
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
let is_selected = idx == app.patterns_nav.pattern_cursor;
let is_edit = edit_pattern == Some(idx);
let is_playing = playing_patterns.contains(&idx);
let is_queued_play = queued_to_play.contains(&idx);
let is_queued_stop = queued_to_stop.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued_play, is_queued_stop) {
(true, _, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _, true) => (Color::Rgb(120, 90, 30), Color::Yellow, "x "),
(false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true, _) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
};
let name = app.project_state.project.banks[bank].patterns[idx]
.name
.as_deref()
.unwrap_or("");
let label = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
let style = Style::new().bg(bg).fg(fg);
let style = if is_playing || is_queued_play {
style.add_modifier(Modifier::BOLD)
} else {
style
};
let para = Paragraph::new(label).style(style);
frame.render_widget(para, row_area);
} }
} }

View File

@@ -1,4 +1,4 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
@@ -8,17 +8,25 @@ use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot}; use crate::engine::{LinkState, SequencerSnapshot};
use crate::page::Page; use crate::page::Page;
use crate::state::{Modal, PatternField}; use crate::state::{Modal, PatternField};
use crate::widgets::{ConfirmModal, TextInputModal}; use crate::views::highlight;
use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal};
use super::{audio_view, doc_view, main_view, patterns_view}; use super::{audio_view, doc_view, main_view, patterns_view, title_view};
pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) { pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) {
let term = frame.area();
if app.ui.show_title {
title_view::render(frame, term);
return;
}
let [header_area, body_area, footer_area] = Layout::vertical([ let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(1), Constraint::Length(2),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
]) ])
.areas(frame.area()); .areas(term);
render_header(frame, app, link, header_area); render_header(frame, app, link, header_area);
@@ -30,12 +38,12 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
} }
render_footer(frame, app, footer_area); render_footer(frame, app, footer_area);
render_modal(frame, app); render_modal(frame, app, term);
} }
fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [left_area, right_area] = let [top_row, bottom_row] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let play_symbol = if app.playback.playing { "" } else { "" }; let play_symbol = if app.playback.playing { "" } else { "" };
let play_color = if app.playback.playing { let play_color = if app.playback.playing {
@@ -53,8 +61,30 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
Color::Green Color::Green
}; };
let left_spans = vec![ let pattern = app
Span::styled("EDIT ", Style::new().fg(Color::Cyan)), .project_state
.project
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
let top_spans = vec![
Span::styled(play_symbol, Style::new().fg(play_color)),
Span::raw(" "),
Span::styled(
format!("{:.1} BPM", link.tempo()),
Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("CPU {cpu_pct:3.0}%"), Style::new().fg(cpu_color)),
Span::raw(" "),
Span::styled(
format!("V:{}", app.metrics.active_voices),
Style::new().fg(Color::Cyan),
),
];
frame.render_widget(Paragraph::new(Line::from(top_spans)), top_row);
let bottom_spans = vec![
Span::styled( Span::styled(
format!( format!(
"B{:02}:P{:02}", "B{:02}:P{:02}",
@@ -64,43 +94,18 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
), ),
Span::raw(" "), Span::raw(" "),
Span::styled(play_symbol, Style::new().fg(play_color)),
];
frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
let pattern = app
.project_state
.project
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
let right_spans = vec![
Span::styled( Span::styled(
format!("L:{:02}", pattern.length), format!("L:{:02}", pattern.length),
Style::new().fg(Color::Rgb(180, 140, 90)), Style::new().fg(Color::Rgb(180, 140, 90)),
), ),
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
format!("S:{}", pattern.speed.label()), format!("S:{}", pattern.speed.label()),
Style::new().fg(Color::Rgb(180, 140, 90)), Style::new().fg(Color::Rgb(180, 140, 90)),
), ),
Span::raw(" "),
Span::styled(
format!("{:.1} BPM", link.tempo()),
Style::new().fg(Color::Magenta),
),
Span::raw(" "),
Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)),
Span::raw(" "),
Span::styled(
format!("V:{}", app.metrics.active_voices),
Style::new().fg(Color::Cyan),
),
]; ];
frame.render_widget( frame.render_widget(Paragraph::new(Line::from(bottom_spans)), bottom_row);
Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
right_area,
);
} }
fn render_footer(frame: &mut Frame, app: &App, area: Rect) { fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
@@ -128,16 +133,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
), ),
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)), Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
Span::raw(":nav "), Span::raw(":nav "),
Span::styled("t", Style::new().fg(Color::Yellow)),
Span::raw(":toggle "),
Span::styled("Enter", Style::new().fg(Color::Yellow)),
Span::raw(":edit "),
Span::styled("<>", Style::new().fg(Color::Yellow)), Span::styled("<>", Style::new().fg(Color::Yellow)),
Span::raw(":len "), Span::raw(":len "),
Span::styled("[]", Style::new().fg(Color::Yellow)), Span::styled("[]", Style::new().fg(Color::Yellow)),
Span::raw(":spd "), Span::raw(":spd "),
Span::styled("Tab", Style::new().fg(Color::Yellow)),
Span::raw(":focus "),
Span::styled("s/l", Style::new().fg(Color::Yellow)), Span::styled("s/l", Style::new().fg(Color::Yellow)),
Span::raw(":save/load "), Span::raw(":save/load"),
Span::styled("C-↑", Style::new().fg(Color::Yellow)),
Span::raw(":patterns"),
]), ]),
Page::Patterns => Line::from(vec![ Page::Patterns => Line::from(vec![
Span::styled( Span::styled(
@@ -190,13 +195,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(footer, area); frame.render_widget(footer, area);
} }
fn render_modal(frame: &mut Frame, app: &App) { fn render_modal(frame: &mut Frame, app: &App, term: Rect) {
let term = frame.area();
match &app.ui.modal { match &app.ui.modal {
Modal::None => {} Modal::None => {}
Modal::ConfirmQuit { selected } => { Modal::ConfirmQuit { selected } => {
ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term); ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term);
} }
Modal::ConfirmDeleteStep { step, selected, .. } => {
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
.render_centered(frame, term);
}
Modal::SaveAs(path) => { Modal::SaveAs(path) => {
TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path) TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path)
.width(60) .width(60)
@@ -246,5 +254,79 @@ fn render_modal(frame: &mut Frame, app: &App) {
.border_color(Color::Magenta) .border_color(Color::Magenta)
.render_centered(frame, term); .render_centered(frame, term);
} }
Modal::Editor => {
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let step_num = app.editor_ctx.step + 1;
let border_color = if app.ui.is_flashing() {
Color::Green
} else {
Color::Rgb(100, 160, 180)
};
let inner = ModalFrame::new(&format!("Step {step_num:02} Script"))
.width(width)
.height(height)
.border_color(border_color)
.render_centered(frame, term);
let (cursor_row, cursor_col) = app.editor_ctx.text.cursor();
let lines: Vec<Line> = app
.editor_ctx
.text
.lines()
.iter()
.enumerate()
.map(|(row, line)| {
let mut spans: Vec<Span> = Vec::new();
let tokens = highlight::highlight_line(line);
if row == cursor_row {
let mut col = 0;
for (style, text) in tokens {
let text_len = text.chars().count();
if cursor_col >= col && cursor_col < col + text_len {
let before =
text.chars().take(cursor_col - col).collect::<String>();
let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' ');
let after =
text.chars().skip(cursor_col - col + 1).collect::<String>();
if !before.is_empty() {
spans.push(Span::styled(before, style));
}
spans.push(Span::styled(
cursor_char.to_string(),
Style::default().bg(Color::White).fg(Color::Black),
));
if !after.is_empty() {
spans.push(Span::styled(after, style));
}
} else {
spans.push(Span::styled(text, style));
}
col += text_len;
}
if cursor_col >= col {
spans.push(Span::styled(
" ",
Style::default().bg(Color::White).fg(Color::Black),
));
}
} else {
for (style, text) in tokens {
spans.push(Span::styled(text, style));
}
}
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
} }
} }

View File

@@ -0,0 +1,51 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
pub fn render(frame: &mut Frame, area: Rect) {
let title_style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
let subtitle_style = Style::new().fg(Color::White);
let dim_style = Style::new()
.fg(Color::Rgb(120, 125, 135))
.add_modifier(Modifier::DIM);
let link_style = Style::new().fg(Color::Rgb(100, 160, 180));
let lines = vec![
Line::from(""),
Line::from(""),
Line::from(Span::styled("seq", title_style)),
Line::from(""),
Line::from(Span::styled("A Forth Music Sequencer", subtitle_style)),
Line::from(""),
Line::from(""),
Line::from(Span::styled("by BuboBubo", dim_style)),
Line::from(Span::styled("Raphael Maurice Forment", dim_style)),
Line::from(""),
Line::from(Span::styled("https://raphaelforment.fr", link_style)),
Line::from(""),
Line::from(""),
Line::from(Span::styled("AGPL-3.0", dim_style)),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(Span::styled(
"Press any key to continue",
Style::new().fg(Color::DarkGray),
)),
];
let text_height = lines.len() as u16;
let vertical_padding = area.height.saturating_sub(text_height) / 2;
let [_, center_area, _] = Layout::vertical([
Constraint::Length(vertical_padding),
Constraint::Length(text_height),
Constraint::Fill(1),
])
.areas(area);
let paragraph = Paragraph::new(lines).alignment(Alignment::Center);
frame.render_widget(paragraph, center_area);
}

View File

@@ -3,6 +3,10 @@ use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
const DB_MIN: f32 = -48.0;
const DB_MAX: f32 = 3.0;
const DB_RANGE: f32 = DB_MAX - DB_MIN;
pub struct VuMeter { pub struct VuMeter {
left: f32, left: f32,
right: f32, right: f32,
@@ -13,10 +17,22 @@ impl VuMeter {
Self { left, right } Self { left, right }
} }
fn level_to_color(level: f32) -> Color { fn amplitude_to_db(amp: f32) -> f32 {
if level > 0.9 { if amp <= 0.0 {
DB_MIN
} else {
(20.0 * amp.log10()).clamp(DB_MIN, DB_MAX)
}
}
fn db_to_normalized(db: f32) -> f32 {
(db - DB_MIN) / DB_RANGE
}
fn row_to_color(row_position: f32) -> Color {
if row_position > 0.9 {
Color::Red Color::Red
} else if level > 0.7 { } else if row_position > 0.75 {
Color::Yellow Color::Yellow
} else { } else {
Color::Green Color::Green
@@ -26,40 +42,38 @@ impl VuMeter {
impl Widget for VuMeter { impl Widget for VuMeter {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 2 || area.height == 0 { if area.width < 3 || area.height == 0 {
return; return;
} }
let height = area.height as usize; let height = area.height as usize;
let left_col = area.x; let half_width = area.width / 2;
let right_col = area.x + area.width - 1; let gap = 1u16;
let left_level = (self.left.clamp(0.0, 1.0) * height as f32) as usize; let left_db = Self::amplitude_to_db(self.left);
let right_level = (self.right.clamp(0.0, 1.0) * height as f32) as usize; let right_db = Self::amplitude_to_db(self.right);
let left_norm = Self::db_to_normalized(left_db);
let right_norm = Self::db_to_normalized(right_db);
let left_rows = (left_norm * height as f32).round() as usize;
let right_rows = (right_norm * height as f32).round() as usize;
for row in 0..height { for row in 0..height {
let y = area.y + area.height - 1 - row as u16; let y = area.y + area.height - 1 - row as u16;
let level_at_row = (row as f32 + 0.5) / height as f32; let row_position = (row as f32 + 0.5) / height as f32;
let color = Self::level_to_color(level_at_row); let color = Self::row_to_color(row_position);
if row < left_level { for col in 0..half_width.saturating_sub(gap) {
buf[(left_col, y)].set_char('█').set_fg(color); let x = area.x + col;
} else { if row < left_rows {
buf[(left_col, y)].set_char('').set_fg(Color::DarkGray); buf[(x, y)].set_char(' ').set_bg(color);
}
} }
if row < right_level { for col in 0..half_width.saturating_sub(gap) {
buf[(right_col, y)].set_char('█').set_fg(color); let x = area.x + half_width + gap + col;
} else { if x < area.x + area.width && row < right_rows {
buf[(right_col, y)].set_char('').set_fg(Color::DarkGray); buf[(x, y)].set_char(' ').set_bg(color);
}
}
if area.width > 2 {
for row in 0..height {
let y = area.y + row as u16;
for x in (area.x + 1)..(area.x + area.width - 1) {
buf[(x, y)].set_char(' ');
} }
} }
} }

View File

@@ -6,6 +6,7 @@ pub struct Event {
// Timing // Timing
pub time: Option<f64>, pub time: Option<f64>,
pub delta: Option<f64>,
pub repeat: Option<f32>, pub repeat: Option<f32>,
pub duration: Option<f32>, pub duration: Option<f32>,
pub gate: Option<f32>, pub gate: Option<f32>,
@@ -172,6 +173,7 @@ impl Event {
match key { match key {
"doux" | "dirt" => event.cmd = Some(val.to_string()), "doux" | "dirt" => event.cmd = Some(val.to_string()),
"time" | "t" => event.time = val.parse().ok(), "time" | "t" => event.time = val.parse().ok(),
"delta" => event.delta = val.parse().ok(),
"repeat" | "rep" => event.repeat = val.parse().ok(), "repeat" | "rep" => event.repeat = val.parse().ok(),
"duration" | "dur" | "d" => event.duration = val.parse().ok(), "duration" | "dur" | "d" => event.duration = val.parse().ok(),
"gate" => event.gate = val.parse().ok(), "gate" => event.gate = val.parse().ok(),

View File

@@ -263,7 +263,11 @@ impl Engine {
} }
} }
fn play_event(&mut self, event: Event) -> Option<usize> { fn play_event(&mut self, mut event: Event) -> Option<usize> {
if let Some(delta) = event.delta {
event.time = Some(self.time + delta);
event.delta = None;
}
if event.time.is_some() { if event.time.is_some() {
// ALL events with time go to schedule (like dough.c) // ALL events with time go to schedule (like dough.c)
// This ensures repeat works correctly for time=0 events // This ensures repeat works correctly for time=0 events