This commit is contained in:
2026-01-20 17:12:56 +01:00
parent 1ba74f850c
commit f7797664bd
13 changed files with 86318 additions and 209 deletions

0
seq/cpu.prof Normal file
View File

42841
seq/ok Normal file

File diff suppressed because it is too large Load Diff

42841
seq/something Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ use crate::config::MAX_SLOTS;
use crate::engine::{ use crate::engine::{
LinkState, PatternSnapshot, SeqCommand, SequencerSnapshot, SlotChange, StepSnapshot, LinkState, PatternSnapshot, SeqCommand, SequencerSnapshot, SlotChange, StepSnapshot,
}; };
use crate::model::{self, Pattern, Rng, ScriptEngine, StepContext, Variables}; use crate::model::{self, Bank, 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::{
@@ -36,6 +36,8 @@ pub struct App {
pub variables: Variables, pub variables: Variables,
pub rng: Rng, pub rng: Rng,
pub clipboard: Option<arboard::Clipboard>, pub clipboard: Option<arboard::Clipboard>,
pub copied_pattern: Option<Pattern>,
pub copied_bank: Option<Bank>,
pub audio: AudioSettings, pub audio: AudioSettings,
} }
@@ -62,6 +64,8 @@ impl App {
rng, rng,
script_engine, script_engine,
clipboard: arboard::Clipboard::new().ok(), clipboard: arboard::Clipboard::new().ok(),
copied_pattern: None,
copied_bank: None,
audio: AudioSettings::default(), audio: AudioSettings::default(),
} }
@@ -351,30 +355,6 @@ impl App {
} }
} }
pub fn is_pattern_queued(
&self,
bank: usize,
pattern: usize,
snapshot: &SequencerSnapshot,
) -> Option<bool> {
self.playback.queued_changes.iter().find_map(|c| match *c {
SlotChange::Add {
slot: _,
bank: b,
pattern: p,
} if b == bank && p == pattern => Some(true),
SlotChange::Remove { slot } => {
let s = snapshot.slot_data[slot];
if s.active && s.bank == bank && s.pattern == pattern {
Some(false)
} else {
None
}
}
_ => None,
})
}
pub fn toggle_pattern_playback( pub fn toggle_pattern_playback(
&mut self, &mut self,
bank: usize, bank: usize,
@@ -539,6 +519,74 @@ impl App {
self.ui.flash("Step deleted", 150); self.ui.flash("Step deleted", 150);
} }
pub fn reset_pattern(&mut self, bank: usize, pattern: usize) {
self.project_state.project.banks[bank].patterns[pattern] = Pattern::default();
self.project_state.mark_dirty(bank, pattern);
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
self.load_step_to_editor();
}
self.ui.flash("Pattern reset", 150);
}
pub fn reset_bank(&mut self, bank: usize) {
self.project_state.project.banks[bank] = Bank::default();
for pattern in 0..self.project_state.project.banks[bank].patterns.len() {
self.project_state.mark_dirty(bank, pattern);
}
if self.editor_ctx.bank == bank {
self.load_step_to_editor();
}
self.ui.flash("Bank reset", 150);
}
pub fn copy_pattern(&mut self, bank: usize, pattern: usize) {
let pat = self.project_state.project.banks[bank].patterns[pattern].clone();
self.copied_pattern = Some(pat);
self.ui.flash("Pattern copied", 150);
}
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) {
if let Some(src) = &self.copied_pattern {
let mut pat = src.clone();
pat.name = match &src.name {
Some(name) if !name.ends_with(" (copy)") => Some(format!("{} (copy)", name)),
Some(name) => Some(name.clone()),
None => Some("(copy)".to_string()),
};
self.project_state.project.banks[bank].patterns[pattern] = pat;
self.project_state.mark_dirty(bank, pattern);
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
self.load_step_to_editor();
}
self.ui.flash("Pattern pasted", 150);
}
}
pub fn copy_bank(&mut self, bank: usize) {
let b = self.project_state.project.banks[bank].clone();
self.copied_bank = Some(b);
self.ui.flash("Bank copied", 150);
}
pub fn paste_bank(&mut self, bank: usize) {
if let Some(src) = &self.copied_bank {
let mut b = src.clone();
b.name = match &src.name {
Some(name) if !name.ends_with(" (copy)") => Some(format!("{} (copy)", name)),
Some(name) => Some(name.clone()),
None => Some("(copy)".to_string()),
};
self.project_state.project.banks[bank] = b;
for pattern in 0..self.project_state.project.banks[bank].patterns.len() {
self.project_state.mark_dirty(bank, pattern);
}
if self.editor_ctx.bank == bank {
self.load_step_to_editor();
}
self.ui.flash("Bank pasted", 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
@@ -717,6 +765,24 @@ impl App {
} => { } => {
self.delete_step(bank, pattern, step); self.delete_step(bank, pattern, step);
} }
AppCommand::ResetPattern { bank, pattern } => {
self.reset_pattern(bank, pattern);
}
AppCommand::ResetBank { bank } => {
self.reset_bank(bank);
}
AppCommand::CopyPattern { bank, pattern } => {
self.copy_pattern(bank, pattern);
}
AppCommand::PastePattern { bank, pattern } => {
self.paste_pattern(bank, pattern);
}
AppCommand::CopyBank { bank } => {
self.copy_bank(bank);
}
AppCommand::PasteBank { bank } => {
self.paste_bank(bank);
}
// Clipboard // Clipboard
AppCommand::CopyStep => self.copy_step(), AppCommand::CopyStep => self.copy_step(),

View File

@@ -4,6 +4,7 @@ use crate::engine::SlotChange;
use crate::model::PatternSpeed; use crate::model::PatternSpeed;
use crate::state::{Modal, PatternField}; use crate::state::{Modal, PatternField};
#[allow(dead_code)]
pub enum AppCommand { pub enum AppCommand {
// Playback // Playback
TogglePlaying, TogglePlaying,
@@ -45,6 +46,27 @@ pub enum AppCommand {
pattern: usize, pattern: usize,
step: usize, step: usize,
}, },
ResetPattern {
bank: usize,
pattern: usize,
},
ResetBank {
bank: usize,
},
CopyPattern {
bank: usize,
pattern: usize,
},
PastePattern {
bank: usize,
pattern: usize,
},
CopyBank {
bank: usize,
},
PasteBank {
bank: usize,
},
// Clipboard // Clipboard
CopyStep, CopyStep,

View File

@@ -288,7 +288,7 @@ fn sequencer_loop(
slot_steps: [Arc<AtomicUsize>; MAX_SLOTS], slot_steps: [Arc<AtomicUsize>; MAX_SLOTS],
event_count: Arc<AtomicUsize>, event_count: Arc<AtomicUsize>,
) { ) {
let script_engine = ScriptEngine::new(variables, rng); let script_engine = ScriptEngine::new(Arc::clone(&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(); let mut runs_counter = RunsCounter::new();
@@ -413,6 +413,12 @@ fn sequencer_loop(
} }
} }
} }
if let Some(new_tempo) = {
let mut vars = variables.lock().unwrap();
vars.remove("__tempo__").and_then(|v| v.as_float().ok())
} {
link.set_tempo(new_tempo);
}
} }
} }
} }

View File

@@ -107,6 +107,70 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
_ => {} _ => {}
} }
} }
Modal::ConfirmResetPattern {
bank,
pattern,
selected: _,
} => {
let (bank, pattern) = (*bank, *pattern);
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.dispatch(AppCommand::ResetPattern { bank, pattern });
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::ConfirmResetPattern { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
let do_reset =
if let Modal::ConfirmResetPattern { selected, .. } = &ctx.app.ui.modal {
*selected
} else {
false
};
if do_reset {
ctx.dispatch(AppCommand::ResetPattern { bank, pattern });
}
ctx.dispatch(AppCommand::CloseModal);
}
_ => {}
}
}
Modal::ConfirmResetBank { bank, selected: _ } => {
let bank = *bank;
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.dispatch(AppCommand::ResetBank { bank });
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::ConfirmResetBank { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
let do_reset =
if let Modal::ConfirmResetBank { selected, .. } = &ctx.app.ui.modal {
*selected
} else {
false
};
if do_reset {
ctx.dispatch(AppCommand::ResetBank { bank });
}
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());
@@ -400,12 +464,14 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
use crate::state::PatternsColumn; use crate::state::PatternsColumn;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
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),
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp), KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown), KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
KeyCode::Esc | KeyCode::Backspace => ctx.dispatch(AppCommand::PatternsBack), KeyCode::Esc => ctx.dispatch(AppCommand::PatternsBack),
KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter),
KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay), KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay),
KeyCode::Char('q') => { KeyCode::Char('q') => {
@@ -413,6 +479,49 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
selected: false, selected: false,
})); }));
} }
KeyCode::Char('c') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::CopyBank { bank });
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::CopyPattern { bank, pattern });
}
}
}
KeyCode::Char('v') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::PasteBank { bank });
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::PastePattern { bank, pattern });
}
}
}
KeyCode::Delete | KeyCode::Backspace => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank {
bank,
selected: false,
}));
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern {
bank,
pattern,
selected: false,
}));
}
}
}
KeyCode::Char('r') => { KeyCode::Char('r') => {
let bank = ctx.app.patterns_nav.bank_cursor; let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column { match ctx.app.patterns_nav.column {

View File

@@ -25,17 +25,37 @@ pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type Rng = Arc<Mutex<StdRng>>; pub type Rng = Arc<Mutex<StdRng>>;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) enum Value { pub enum Value {
Int(i64), Int(i64),
Float(f64), Float(f64),
Str(String), Str(String),
Cmd(Vec<(String, String)>),
Param(String, String),
Marker, Marker,
} }
#[derive(Clone, Debug, Default)]
struct CmdRegister {
sound: Option<String>,
params: Vec<(String, String)>,
}
impl CmdRegister {
fn set_sound(&mut self, name: String) {
self.sound = Some(name);
}
fn set_param(&mut self, key: String, value: String) {
self.params.push((key, value));
}
fn take(&mut self) -> Option<(String, Vec<(String, String)>)> {
let sound = self.sound.take()?;
let params = std::mem::take(&mut self.params);
Some((sound, params))
}
}
impl Value { impl Value {
fn as_float(&self) -> Result<f64, String> { pub fn as_float(&self) -> Result<f64, String> {
match self { match self {
Value::Float(f) => Ok(*f), Value::Float(f) => Ok(*f),
Value::Int(i) => Ok(*i as f64), Value::Int(i) => Ok(*i as f64),
@@ -63,8 +83,6 @@ impl Value {
Value::Int(i) => *i != 0, Value::Int(i) => *i != 0,
Value::Float(f) => *f != 0.0, Value::Float(f) => *f != 0.0,
Value::Str(s) => !s.is_empty(), Value::Str(s) => !s.is_empty(),
Value::Cmd(_) => true,
Value::Param(_, _) => true,
Value::Marker => false, Value::Marker => false,
} }
} }
@@ -73,16 +91,12 @@ impl Value {
matches!(self, Value::Marker) matches!(self, Value::Marker)
} }
fn is_param(&self) -> bool {
matches!(self, Value::Param(_, _))
}
fn to_param_string(&self) -> String { fn to_param_string(&self) -> String {
match self { match self {
Value::Int(i) => i.to_string(), Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(), Value::Float(f) => f.to_string(),
Value::Str(s) => s.clone(), Value::Str(s) => s.clone(),
Value::Cmd(_) | Value::Param(_, _) | Value::Marker => String::new(), Value::Marker => String::new(),
} }
} }
} }
@@ -132,9 +146,21 @@ enum Op {
Choose, Choose,
Chance, Chance,
Maybe, Maybe,
Wait,
ListStart, ListStart,
ListEnd, ListEnd,
At,
Window,
Pop,
Subdivide,
SetTempo,
Each,
}
#[derive(Clone, Debug)]
struct TimeContext {
start: f64,
duration: f64,
subdivisions: Option<Vec<(f64, f64)>>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -386,7 +412,12 @@ fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
ops.push(Op::PushFloat(0.9)); ops.push(Op::PushFloat(0.9));
ops.push(Op::Maybe); ops.push(Op::Maybe);
} }
"wait" => ops.push(Op::Wait), "at" => ops.push(Op::At),
"window" => ops.push(Op::Window),
"pop" => ops.push(Op::Pop),
"div" => ops.push(Op::Subdivide),
"each" => ops.push(Op::Each),
"tempo!" => ops.push(Op::SetTempo),
"[" => ops.push(Op::ListStart), "[" => ops.push(Op::ListStart),
"]" => ops.push(Op::ListEnd), "]" => ops.push(Op::ListEnd),
"step" => ops.push(Op::GetContext("step".into())), "step" => ops.push(Op::GetContext("step".into())),
@@ -397,7 +428,6 @@ fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
"phase" => ops.push(Op::GetContext("phase".into())), "phase" => ops.push(Op::GetContext("phase".into())),
"slot" => ops.push(Op::GetContext("slot".into())), "slot" => ops.push(Op::GetContext("slot".into())),
"runs" => ops.push(Op::GetContext("runs".into())), "runs" => ops.push(Op::GetContext("runs".into())),
"speed" => ops.push(Op::GetContext("speed".into())),
"stepdur" => ops.push(Op::GetContext("stepdur".into())), "stepdur" => ops.push(Op::GetContext("stepdur".into())),
"if" => { "if" => {
let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?; let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?;
@@ -487,7 +517,12 @@ impl Forth {
fn execute(&self, ops: &[Op], ctx: &StepContext) -> Result<Vec<String>, String> { fn execute(&self, ops: &[Op], ctx: &StepContext) -> Result<Vec<String>, String> {
let mut stack: Vec<Value> = Vec::new(); let mut stack: Vec<Value> = Vec::new();
let mut outputs: Vec<String> = Vec::new(); let mut outputs: Vec<String> = Vec::new();
let mut time_offset: f64 = 0.0; let mut time_stack: Vec<TimeContext> = vec![TimeContext {
start: 0.0,
duration: ctx.step_duration(),
subdivisions: None,
}];
let mut cmd = CmdRegister::default();
let mut pc = 0; let mut pc = 0;
while pc < ops.len() { while pc < ops.len() {
@@ -605,45 +640,31 @@ impl Forth {
Op::NewCmd => { Op::NewCmd => {
let name = stack.pop().ok_or("stack underflow")?; let name = stack.pop().ok_or("stack underflow")?;
let name = name.as_str()?; let name = name.as_str()?;
stack.push(Value::Cmd(vec![("sound".into(), name.into())])); cmd.set_sound(name.to_string());
} }
Op::SetParam(param) => { Op::SetParam(param) => {
let val = stack.pop().ok_or("stack underflow")?; let val = stack.pop().ok_or("stack underflow")?;
stack.push(Value::Param(param.clone(), val.to_param_string())); cmd.set_param(param.clone(), val.to_param_string());
} }
Op::Emit => { Op::Emit => {
let mut params = Vec::new(); let (sound, mut params) = cmd.take().ok_or("no sound set")?;
while let Some(v) = stack.last() { let mut pairs = vec![("sound".into(), sound)];
if v.is_param() { pairs.append(&mut params);
if let Value::Param(k, v) = stack.pop().unwrap() { let time_ctx = time_stack.last().ok_or("time stack underflow")?;
params.push((k, v)); if time_ctx.start > 0.0 {
} pairs.push(("delta".into(), time_ctx.start.to_string()));
} else {
break;
}
} }
params.reverse(); if !pairs.iter().any(|(k, _)| k == "dur") {
let cmd = stack.pop().ok_or("stack underflow")?; pairs.push(("dur".into(), ctx.step_duration().to_string()));
if let Value::Cmd(mut pairs) = cmd { }
pairs.extend(params); let stepdur = ctx.step_duration();
if time_offset > 0.0 { if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
pairs.push(("delta".into(), time_offset.to_string())); let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
} pairs[idx].1 = (ratio * stepdur).to_string();
let has_dur = pairs.iter().any(|(k, _)| k == "dur");
if !has_dur {
pairs.push(("dur".into(), ctx.step_duration().to_string()));
}
let stepdur = ctx.step_duration();
if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
pairs[idx].1 = (ratio * stepdur).to_string();
} else {
pairs.push(("delaytime".into(), stepdur.to_string()));
}
outputs.push(format_cmd(&pairs));
} else { } else {
return Err("expected command".into()); pairs.push(("delaytime".into(), stepdur.to_string()));
} }
outputs.push(format_cmd(&pairs));
} }
Op::Get => { Op::Get => {
@@ -729,20 +750,100 @@ impl Forth {
} }
Op::Maybe => { Op::Maybe => {
let prob = stack.pop().ok_or("stack underflow")?.as_float()?; return Err("? is not yet implemented with the new param model".into());
let param = stack.pop().ok_or("stack underflow")?; }
if !param.is_param() {
return Err("? requires a param".into()); Op::At => {
let pos = stack.pop().ok_or("stack underflow")?.as_float()?;
let (sound, mut params) = cmd.take().ok_or("no sound set")?;
let mut pairs = vec![("sound".into(), sound)];
pairs.append(&mut params);
let time_ctx = time_stack.last().ok_or("time stack underflow")?;
let absolute_time = time_ctx.start + time_ctx.duration * pos;
if absolute_time > 0.0 {
pairs.push(("delta".into(), absolute_time.to_string()));
} }
let val: f64 = self.rng.lock().unwrap().gen(); if !pairs.iter().any(|(k, _)| k == "dur") {
if val < prob { pairs.push(("dur".into(), ctx.step_duration().to_string()));
stack.push(param); }
let stepdur = ctx.step_duration();
if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
pairs[idx].1 = (ratio * stepdur).to_string();
} else {
pairs.push(("delaytime".into(), stepdur.to_string()));
}
outputs.push(format_cmd(&pairs));
}
Op::Window => {
let end = stack.pop().ok_or("stack underflow")?.as_float()?;
let start_pos = stack.pop().ok_or("stack underflow")?.as_float()?;
let parent = time_stack.last().ok_or("time stack underflow")?;
let new_start = parent.start + parent.duration * start_pos;
let new_duration = parent.duration * (end - start_pos);
time_stack.push(TimeContext {
start: new_start,
duration: new_duration,
subdivisions: None,
});
}
Op::Pop => {
if time_stack.len() <= 1 {
return Err("cannot pop root time context".into());
}
time_stack.pop();
}
Op::Subdivide => {
let n = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if n == 0 {
return Err("subdivide count must be > 0".into());
}
let time_ctx = time_stack.last_mut().ok_or("time stack underflow")?;
let sub_duration = time_ctx.duration / n as f64;
let mut subs = Vec::with_capacity(n);
for i in 0..n {
subs.push((time_ctx.start + sub_duration * i as f64, sub_duration));
}
time_ctx.subdivisions = Some(subs);
}
Op::Each => {
let (sound, params) = cmd.take().ok_or("no sound set")?;
let time_ctx = time_stack.last().ok_or("time stack underflow")?;
let subs = time_ctx
.subdivisions
.as_ref()
.ok_or("each requires subdivide first")?;
for (sub_start, _sub_dur) in subs {
let mut pairs = vec![("sound".into(), sound.clone())];
pairs.extend(params.iter().cloned());
if *sub_start > 0.0 {
pairs.push(("delta".into(), sub_start.to_string()));
}
if !pairs.iter().any(|(k, _)| k == "dur") {
pairs.push(("dur".into(), ctx.step_duration().to_string()));
}
let stepdur = ctx.step_duration();
if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
pairs[idx].1 = (ratio * stepdur).to_string();
} else {
pairs.push(("delaytime".into(), stepdur.to_string()));
}
outputs.push(format_cmd(&pairs));
} }
} }
Op::Wait => { Op::SetTempo => {
let duration = stack.pop().ok_or("stack underflow")?.as_float()?; let tempo = stack.pop().ok_or("stack underflow")?.as_float()?;
time_offset += duration; let clamped = tempo.clamp(20.0, 300.0);
self.vars
.lock()
.unwrap()
.insert("__tempo__".to_string(), Value::Float(clamped));
} }
Op::ListStart => { Op::ListStart => {
@@ -770,7 +871,11 @@ impl Forth {
} }
if outputs.is_empty() { if outputs.is_empty() {
if let Some(Value::Cmd(pairs)) = stack.pop() { if let Some((sound, params)) = cmd.take() {
let mut pairs = vec![("sound".into(), sound)];
pairs.extend(params);
pairs.push(("dur".into(), ctx.step_duration().to_string()));
pairs.push(("delaytime".into(), ctx.step_duration().to_string()));
outputs.push(format_cmd(&pairs)); outputs.push(format_cmd(&pairs));
} }
} }

View File

@@ -12,6 +12,15 @@ pub enum Modal {
step: usize, step: usize,
selected: bool, selected: bool,
}, },
ConfirmResetPattern {
bank: usize,
pattern: usize,
selected: bool,
},
ConfirmResetBank {
bank: usize,
selected: bool,
},
SaveAs(String), SaveAs(String),
LoadFrom(String), LoadFrom(String),
RenameBank { RenameBank {

View File

@@ -1,7 +1,7 @@
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::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::Paragraph;
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;

View File

@@ -1,6 +1,7 @@
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
@@ -8,28 +9,34 @@ use crate::engine::SequencerSnapshot;
use crate::state::PatternsColumn; 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) {
let [banks_area, patterns_area] = let [banks_area, gap, patterns_area] = Layout::horizontal([
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(area);
render_banks(frame, app, snapshot, banks_area); render_banks(frame, app, snapshot, banks_area);
// gap is just empty space
let _ = gap;
render_patterns(frame, app, snapshot, patterns_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 is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
let border_color = if is_focused {
let [title_area, inner] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
let title_color = if is_focused {
Color::Rgb(100, 160, 180) Color::Rgb(100, 160, 180)
} else { } else {
Color::Rgb(70, 75, 85) Color::Rgb(70, 75, 85)
}; };
let title = Paragraph::new("Banks")
let block = Block::default() .style(Style::new().fg(title_color))
.borders(Borders::ALL) .alignment(ratatui::layout::Alignment::Center);
.border_style(Style::new().fg(border_color)) frame.render_widget(title, title_area);
.title("Banks");
let inner = block.inner(area);
frame.render_widget(block, area);
let banks_with_playback: Vec<usize> = snapshot let banks_with_playback: Vec<usize> = snapshot
.slot_data .slot_data
@@ -48,14 +55,26 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
}) })
.collect(); .collect();
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect(); let row_height = (inner.height / 16).max(1);
let row_areas = Layout::vertical(rows).split(inner); let total_needed = row_height * 16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 { for idx in 0..16 {
if idx >= row_areas.len() { let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break; break;
} }
let row_area = row_areas[idx];
let row_area = Rect {
x: inner.x,
y,
width: inner.width,
height: row_height.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor; let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor;
let is_selected = idx == app.patterns_nav.bank_cursor; let is_selected = idx == app.patterns_nav.bank_cursor;
@@ -89,14 +108,35 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
style style
}; };
// Fill the entire row with background color
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let text_y = if row_height > 1 {
row_area.y + (row_height - 1) / 2
} else {
row_area.y
};
let text_area = Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
};
let para = Paragraph::new(label).style(style); let para = Paragraph::new(label).style(style);
frame.render_widget(para, row_area); frame.render_widget(para, text_area);
} }
} }
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
use crate::model::PatternSpeed;
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns); let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
let border_color = if is_focused {
let [title_area, inner] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
let title_color = if is_focused {
Color::Rgb(100, 160, 180) Color::Rgb(100, 160, 180)
} else { } else {
Color::Rgb(70, 75, 85) Color::Rgb(70, 75, 85)
@@ -104,18 +144,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let bank = app.patterns_nav.bank_cursor; let bank = app.patterns_nav.bank_cursor;
let bank_name = app.project_state.project.banks[bank].name.as_deref(); let bank_name = app.project_state.project.banks[bank].name.as_deref();
let title = match bank_name { let title_text = match bank_name {
Some(name) => format!("Patterns ({name})"), Some(name) => format!("Patterns ({name})"),
None => format!("Patterns (Bank {:02})", bank + 1), None => format!("Patterns (Bank {:02})", bank + 1),
}; };
let title = Paragraph::new(title_text)
let block = Block::default() .style(Style::new().fg(title_color))
.borders(Borders::ALL) .alignment(ratatui::layout::Alignment::Center);
.border_style(Style::new().fg(border_color)) frame.render_widget(title, title_area);
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let playing_patterns: Vec<usize> = snapshot let playing_patterns: Vec<usize> = snapshot
.slot_data .slot_data
@@ -159,14 +195,26 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
None None
}; };
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect(); let row_height = (inner.height / 16).max(1);
let row_areas = Layout::vertical(rows).split(inner); let total_needed = row_height * 16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 { for idx in 0..16 {
if idx >= row_areas.len() { let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break; break;
} }
let row_area = row_areas[idx];
let row_area = Rect {
x: inner.x,
y,
width: inner.width,
height: row_height.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor; let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
let is_selected = idx == app.patterns_nav.pattern_cursor; let is_selected = idx == app.patterns_nav.pattern_cursor;
@@ -185,24 +233,70 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
(false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""), (false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
}; };
let name = app.project_state.project.banks[bank].patterns[idx] let pattern = &app.project_state.project.banks[bank].patterns[idx];
.name let name = pattern.name.as_deref().unwrap_or("");
.as_deref() let length = pattern.length;
.unwrap_or(""); let speed = pattern.speed;
let label = if name.is_empty() {
let base_style = Style::new().bg(bg).fg(fg);
let bold_style = base_style.add_modifier(Modifier::BOLD);
// Fill the entire row with background color
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let text_y = if row_height > 1 {
row_area.y + (row_height - 1) / 2
} else {
row_area.y
};
// Split row into columns: [index+name] [length] [speed]
let speed_width: u16 = 14; // "Speed: 1/4x "
let length_width: u16 = 13; // "Length: 16 "
let name_width = row_area
.width
.saturating_sub(speed_width + length_width + 2);
let [name_area, length_area, speed_area] = Layout::horizontal([
Constraint::Length(name_width),
Constraint::Length(length_width),
Constraint::Length(speed_width),
])
.areas(Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
});
// Column 1: prefix + index + name (left-aligned)
let name_text = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1) format!("{}{:02}", prefix, idx + 1)
} else { } else {
format!("{}{:02} {}", prefix, idx + 1, name) format!("{}{:02} {}", prefix, idx + 1, name)
}; };
let name_style = if is_playing || is_queued_play {
let style = Style::new().bg(bg).fg(fg); bold_style
let style = if is_playing || is_queued_play {
style.add_modifier(Modifier::BOLD)
} else { } else {
style base_style
}; };
frame.render_widget(Paragraph::new(name_text).style(name_style), name_area);
let para = Paragraph::new(label).style(style); // Column 2: length
frame.render_widget(para, row_area); let length_line = Line::from(vec![
Span::styled("Length: ", bold_style),
Span::styled(format!("{}", length), base_style),
]);
frame.render_widget(Paragraph::new(length_line), length_area);
// Column 3: speed (only if non-default)
if speed != PatternSpeed::Normal {
let speed_line = Line::from(vec![
Span::styled("Speed: ", bold_style),
Span::styled(speed.label(), base_style),
]);
frame.render_widget(Paragraph::new(speed_line), speed_area);
}
} }
} }

View File

@@ -28,8 +28,9 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
height: term.height.saturating_sub(2), height: term.height.saturating_sub(2),
}; };
let [header_area, body_area, footer_area] = Layout::vertical([ let [header_area, _padding, body_area, footer_area] = Layout::vertical([
Constraint::Length(2), Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
]) ])
@@ -49,94 +50,94 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
} }
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 [top_row, bottom_row] = use crate::model::PatternSpeed;
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let pattern = app let bank = &app.project_state.project.banks[app.editor_ctx.bank];
.project_state let pattern = &bank.patterns[app.editor_ctx.pattern];
.project
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
let play_symbol = if app.playback.playing { "" } else { "" }; // Layout: [Transport] [Tempo] [Bank] [Pattern] [Stats]
let play_color = if app.playback.playing { let [transport_area, tempo_area, bank_area, pattern_area, stats_area] = Layout::horizontal([
Color::Green Constraint::Min(12),
} else { Constraint::Min(14),
Color::Red
};
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let cpu_color = if cpu_pct > 80.0 {
Color::Red
} else if cpu_pct > 50.0 {
Color::Yellow
} else {
Color::Green
};
render_header_row(
frame,
top_row,
Line::from(vec![Span::styled(
format!(
"B{:02}:P{:02}",
app.editor_ctx.bank + 1,
app.editor_ctx.pattern + 1
),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(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),
),
]),
Line::from(vec![
Span::styled(format!("CPU {:3.0}%", cpu_pct), Style::new().fg(cpu_color)),
Span::raw(" "),
Span::styled(
format!("V:{}", app.metrics.active_voices),
Style::new().fg(Color::Cyan),
),
]),
);
render_header_row(
frame,
bottom_row,
Line::from(vec![
Span::styled(
format!("Len {:02}", pattern.length),
Style::new().fg(Color::Rgb(180, 140, 90)),
),
Span::raw(" "),
Span::styled(
format!("Spd {}", pattern.speed.label()),
Style::new().fg(Color::Rgb(180, 140, 90)),
),
]),
Line::from(vec![]),
Line::from(vec![]),
);
}
fn render_header_row(frame: &mut Frame, area: Rect, left: Line, center: Line, right: Line) {
let [left_area, center_area, right_area] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Fill(2),
Constraint::Min(20),
]) ])
.areas(area); .areas(area);
frame.render_widget(Paragraph::new(left).alignment(Alignment::Left), left_area); // Transport block
let (transport_bg, transport_text) = if app.playback.playing {
(Color::Rgb(30, 80, 30), " ▶ PLAYING ")
} else {
(Color::Rgb(80, 30, 30), " ■ STOPPED ")
};
let transport_style = Style::new().bg(transport_bg).fg(Color::White);
frame.render_widget( frame.render_widget(
Paragraph::new(center).alignment(Alignment::Center), Paragraph::new(transport_text)
center_area, .style(transport_style)
.alignment(Alignment::Center),
transport_area,
); );
// Tempo block
let tempo_style = Style::new()
.bg(Color::Rgb(60, 30, 60))
.fg(Color::White)
.add_modifier(Modifier::BOLD);
frame.render_widget( frame.render_widget(
Paragraph::new(right).alignment(Alignment::Right), Paragraph::new(format!(" {:.1} BPM ", link.tempo()))
right_area, .style(tempo_style)
.alignment(Alignment::Center),
tempo_area,
);
// Bank block
let bank_name = bank
.name
.as_deref()
.map(|n| format!(" {} ", n))
.unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1));
let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White);
frame.render_widget(
Paragraph::new(bank_name)
.style(bank_style)
.alignment(Alignment::Center),
bank_area,
);
// Pattern block (name + length + speed)
let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1);
let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name);
let speed_info = if pattern.speed != PatternSpeed::Normal {
format!(" · {}", pattern.speed.label())
} else {
String::new()
};
let pattern_text = format!(
" {} · {} steps{} ",
pattern_name, pattern.length, speed_info
);
let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White);
frame.render_widget(
Paragraph::new(pattern_text)
.style(pattern_style)
.alignment(Alignment::Center),
pattern_area,
);
// Stats block
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let peers = link.peers();
let voices = app.metrics.active_voices;
let stats_text = format!(" CPU {:.0}% V:{} L:{} ", cpu_pct, voices, peers);
let stats_style = Style::new()
.bg(Color::Rgb(35, 35, 40))
.fg(Color::Rgb(150, 150, 160));
frame.render_widget(
Paragraph::new(stats_text)
.style(stats_style)
.alignment(Alignment::Right),
stats_area,
); );
} }
@@ -242,6 +243,20 @@ fn render_modal(frame: &mut Frame, app: &App, term: Rect) {
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
.render_centered(frame, term); .render_centered(frame, term);
} }
Modal::ConfirmResetPattern {
pattern, selected, ..
} => {
ConfirmModal::new(
"Confirm",
&format!("Reset pattern {}?", pattern + 1),
*selected,
)
.render_centered(frame, term);
}
Modal::ConfirmResetBank { bank, selected } => {
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 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)

View File

@@ -3,6 +3,7 @@ use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
#[allow(dead_code)]
pub enum Orientation { pub enum Orientation {
Horizontal, Horizontal,
Vertical, Vertical,