flesh out sequencer
This commit is contained in:
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
227
seq/src/app.rs
227
seq/src/app.rs
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
266
seq/src/input.rs
266
seq/src/input.rs
@@ -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"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
788
seq/src/model/forth.rs
Normal 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("/"))
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod file;
|
mod file;
|
||||||
|
mod forth;
|
||||||
mod project;
|
mod project;
|
||||||
mod script;
|
mod script;
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
277
seq/src/views/highlight.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
seq/src/views/title_view.rs
Normal file
51
seq/src/views/title_view.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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(' ');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user