Continue work on seq

This commit is contained in:
2026-01-20 15:52:17 +01:00
parent ebb82b6f7d
commit 1ba74f850c
11 changed files with 43132 additions and 155 deletions

View File

@@ -243,6 +243,12 @@ impl App {
return;
}
let speed = self
.project_state
.project
.pattern_at(bank, pattern)
.speed
.multiplier();
let ctx = StepContext {
step: step_idx,
beat: link.beat(),
@@ -252,6 +258,7 @@ impl App {
phase: link.phase(),
slot: 0,
runs: 0,
speed,
};
match self.script_engine.evaluate(&script, &ctx) {
@@ -309,6 +316,12 @@ impl App {
continue;
}
let speed = self
.project_state
.project
.pattern_at(bank, pattern)
.speed
.multiplier();
let ctx = StepContext {
step: step_idx,
beat: 0.0,
@@ -318,6 +331,7 @@ impl App {
phase: 0.0,
slot: 0,
runs: 0,
speed,
};
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
@@ -435,9 +449,10 @@ impl App {
self.load_step_to_editor();
}
pub fn save(&mut self, path: PathBuf) {
pub fn save(&mut self, path: PathBuf, link: &LinkState) {
self.save_editor_to_step();
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
self.project_state.project.tempo = link.tempo();
match model::save(&self.project_state.project, &path) {
Ok(()) => {
self.ui.set_status(format!("Saved: {}", path.display()));
@@ -452,11 +467,13 @@ impl App {
pub fn load(&mut self, path: PathBuf, link: &LinkState) {
match model::load(&path) {
Ok(project) => {
let tempo = project.tempo;
self.project_state.project = project;
self.editor_ctx.step = 0;
self.load_step_to_editor();
self.compile_all_steps(link);
self.mark_all_patterns_dirty();
link.set_tempo(tempo);
self.ui.set_status(format!("Loaded: {}", path.display()));
self.project_state.file_path = Some(path);
}
@@ -726,7 +743,7 @@ impl App {
} => {
self.project_state.project.banks[bank].patterns[pattern].name = name;
}
AppCommand::Save(path) => self.save(path),
AppCommand::Save(path) => self.save(path, link),
AppCommand::Load(path) => self.load(path, link),
// UI

View File

@@ -398,6 +398,7 @@ fn sequencer_loop(
phase: beat % quantum,
slot: slot_idx,
runs,
speed: speed_mult,
};
if let Some(script) = resolved_script {
if let Ok(cmds) = script_engine.evaluate(script, &ctx) {

View File

@@ -231,6 +231,26 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char(c) => input.push(c),
_ => {}
},
Modal::SetTempo(input) => match key.code {
KeyCode::Enter => {
if let Ok(tempo) = input.parse::<f64>() {
let tempo = tempo.clamp(20.0, 300.0);
ctx.link.set_tempo(tempo);
ctx.dispatch(AppCommand::SetStatus(format!(
"Tempo set to {tempo:.1} BPM"
)));
} else {
ctx.dispatch(AppCommand::SetStatus("Invalid tempo".to_string()));
}
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => input.push(c),
_ => {}
},
Modal::AddSamplePath(path) => match key.code {
KeyCode::Enter => {
let sample_path = PathBuf::from(path.as_str());
@@ -352,6 +372,10 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
}
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
KeyCode::Char('T') => {
let current = format!("{:.1}", ctx.link.tempo());
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
}
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),

View File

@@ -14,6 +14,12 @@ struct ProjectFile {
banks: Vec<Bank>,
#[serde(default)]
sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
tempo: f64,
}
fn default_tempo() -> f64 {
120.0
}
impl From<&Project> for ProjectFile {
@@ -22,6 +28,7 @@ impl From<&Project> for ProjectFile {
version: VERSION,
banks: project.banks.clone(),
sample_paths: project.sample_paths.clone(),
tempo: project.tempo,
}
}
}
@@ -31,6 +38,7 @@ impl From<ProjectFile> for Project {
Self {
banks: file.banks,
sample_paths: file.sample_paths,
tempo: file.tempo,
}
}
}

View File

@@ -12,6 +12,13 @@ pub struct StepContext {
pub phase: f64,
pub slot: usize,
pub runs: usize,
pub speed: f64,
}
impl StepContext {
pub fn step_duration(&self) -> f64 {
60.0 / self.tempo / 4.0 / self.speed
}
}
pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
@@ -390,6 +397,8 @@ fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
"phase" => ops.push(Op::GetContext("phase".into())),
"slot" => ops.push(Op::GetContext("slot".into())),
"runs" => ops.push(Op::GetContext("runs".into())),
"speed" => ops.push(Op::GetContext("speed".into())),
"stepdur" => ops.push(Op::GetContext("stepdur".into())),
"if" => {
let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?;
i += consumed;
@@ -620,6 +629,17 @@ impl Forth {
if time_offset > 0.0 {
pairs.push(("delta".into(), time_offset.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 {
return Err("expected command".into());
@@ -650,6 +670,8 @@ impl Forth {
"phase" => Value::Float(ctx.phase),
"slot" => Value::Int(ctx.slot as i64),
"runs" => Value::Int(ctx.runs as i64),
"speed" => Value::Float(ctx.speed),
"stepdur" => Value::Float(ctx.step_duration()),
_ => Value::Int(0),
};
stack.push(val);

View File

@@ -181,6 +181,12 @@ pub struct Project {
pub banks: Vec<Bank>,
#[serde(default)]
pub sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
pub tempo: f64,
}
fn default_tempo() -> f64 {
120.0
}
impl Default for Project {
@@ -188,6 +194,7 @@ impl Default for Project {
Self {
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
sample_paths: Vec::new(),
tempo: default_tempo(),
}
}
}

View File

@@ -27,6 +27,7 @@ pub enum Modal {
field: PatternField,
input: String,
},
SetTempo(String),
AddSamplePath(String),
Editor,
}

View File

@@ -10,38 +10,32 @@ use crate::views::highlight::highlight_line;
use crate::widgets::{Orientation, Scope, VuMeter};
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) {
let [main_area, scope_area, vu_area] = Layout::horizontal([
let [left_area, _spacer, vu_area] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(2),
Constraint::Length(8),
])
.areas(area);
let [sequencer_area, preview_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(main_area);
let [scope_area, sequencer_area, preview_area] = Layout::vertical([
Constraint::Length(8),
Constraint::Fill(1),
Constraint::Length(2),
])
.areas(left_area);
render_scope(frame, app, scope_area);
render_sequencer(frame, app, snapshot, sequencer_area);
render_step_preview(frame, app, preview_area);
render_scope(frame, app, scope_area);
render_vu_meter(frame, app, vu_area);
}
fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let border_style = Style::new().fg(Color::Rgb(100, 160, 180));
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title("Sequencer");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 50 {
if area.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);
frame.render_widget(msg, area);
return;
}
@@ -56,7 +50,7 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
let steps_per_row = length.div_ceil(num_rows);
let spacing = num_rows.saturating_sub(1) as u16;
let row_height = inner.height.saturating_sub(spacing) / num_rows as u16;
let row_height = area.height.saturating_sub(spacing) / num_rows as u16;
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
.map(|i| {
@@ -67,7 +61,7 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
}
})
.collect();
let rows = Layout::vertical(row_constraints).split(inner);
let rows = Layout::vertical(row_constraints).split(area);
for row_idx in 0..num_rows {
let row_area = rows[row_idx * 2];
@@ -151,29 +145,15 @@ fn render_tile(
}
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Rgb(70, 75, 85)));
let inner = block.inner(area);
frame.render_widget(block, area);
let scope = Scope::new(&app.metrics.scope)
.orientation(Orientation::Vertical)
.orientation(Orientation::Horizontal)
.color(Color::Green);
frame.render_widget(scope, inner);
frame.render_widget(scope, area);
}
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Rgb(70, 75, 85)));
let inner = block.inner(area);
frame.render_widget(block, area);
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
frame.render_widget(vu, inner);
frame.render_widget(vu, area);
}
fn render_step_preview(frame: &mut Frame, app: &App, area: Rect) {

View File

@@ -1,4 +1,4 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
@@ -21,12 +21,19 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
return;
}
let padded = Rect {
x: term.x + 1,
y: term.y + 1,
width: term.width.saturating_sub(2),
height: term.height.saturating_sub(2),
};
let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(3),
])
.areas(term);
.areas(padded);
render_header(frame, app, link, header_area);
@@ -45,6 +52,11 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [top_row, bottom_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let pattern = app
.project_state
.project
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
let play_symbol = if app.playback.playing { "" } else { "" };
let play_color = if app.playback.playing {
Color::Green
@@ -61,137 +73,162 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
Color::Green
};
let pattern = app
.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(
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),
),
Span::raw(" "),
Span::styled(
format!("L:{:02}", pattern.length),
Style::new().fg(Color::Rgb(180, 140, 90)),
),
Span::raw(" "),
Span::styled(
format!("S:{}", pattern.speed.label()),
Style::new().fg(Color::Rgb(180, 140, 90)),
),
];
)]),
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),
),
]),
);
frame.render_widget(Paragraph::new(Line::from(bottom_spans)), bottom_row);
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),
])
.areas(area);
frame.render_widget(Paragraph::new(left).alignment(Alignment::Left), left_area);
frame.render_widget(
Paragraph::new(center).alignment(Alignment::Center),
center_area,
);
frame.render_widget(
Paragraph::new(right).alignment(Alignment::Right),
right_area,
);
}
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(area);
let available_width = inner.width as usize;
let page_indicator = match app.page {
Page::Main => "[MAIN] ",
Page::Patterns => "[PATTERNS] ",
Page::Audio => "[AUDIO] ",
Page::Doc => "[DOC] ",
Page::Main => "[MAIN]",
Page::Patterns => "[PATTERNS]",
Page::Audio => "[AUDIO]",
Page::Doc => "[DOC]",
};
let content = if let Some(ref msg) = app.ui.status_message {
Line::from(vec![
Span::styled(
page_indicator,
page_indicator.to_string(),
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::raw(" "),
Span::styled(msg.clone(), Style::new().fg(Color::Yellow)),
])
} else {
match app.page {
Page::Main => Line::from(vec![
Span::styled(
page_indicator,
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
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::raw(":len "),
Span::styled("[]", Style::new().fg(Color::Yellow)),
Span::raw(":spd "),
Span::styled("s/l", Style::new().fg(Color::Yellow)),
Span::raw(":save/load"),
]),
Page::Patterns => Line::from(vec![
Span::styled(
page_indicator,
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
Span::raw(":nav "),
Span::styled("Enter", Style::new().fg(Color::Yellow)),
Span::raw(":select "),
Span::styled("Space", Style::new().fg(Color::Yellow)),
Span::raw(":play "),
Span::styled("Esc", Style::new().fg(Color::Yellow)),
Span::raw(":back"),
]),
Page::Audio => Line::from(vec![
Span::styled(
page_indicator,
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::styled("q", Style::new().fg(Color::Yellow)),
Span::raw(":quit "),
Span::styled("h", Style::new().fg(Color::Yellow)),
Span::raw(":hush "),
Span::styled("p", Style::new().fg(Color::Yellow)),
Span::raw(":panic "),
Span::styled("r", Style::new().fg(Color::Yellow)),
Span::raw(":reset "),
Span::styled("t", Style::new().fg(Color::Yellow)),
Span::raw(":test "),
Span::styled("C-←→", Style::new().fg(Color::Yellow)),
Span::raw(":page"),
]),
Page::Doc => Line::from(vec![
Span::styled(
page_indicator,
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::styled("j/k", Style::new().fg(Color::Yellow)),
Span::raw(":topic "),
Span::styled("PgUp/Dn", Style::new().fg(Color::Yellow)),
Span::raw(":scroll "),
Span::styled("C-←→", Style::new().fg(Color::Yellow)),
Span::raw(":page"),
]),
let bindings: Vec<(&str, &str)> = match app.page {
Page::Main => vec![
("←→↑↓", "nav"),
("t", "toggle"),
("Enter", "edit"),
("<>", "len"),
("[]", "spd"),
("s/l", "save/load"),
],
Page::Patterns => vec![
("←→↑↓", "nav"),
("Enter", "select"),
("Space", "play"),
("Esc", "back"),
],
Page::Audio => vec![
("q", "quit"),
("h", "hush"),
("p", "panic"),
("r", "reset"),
("t", "test"),
("C-←→", "page"),
],
Page::Doc => vec![("j/k", "topic"), ("PgUp/Dn", "scroll"), ("C-←→", "page")],
};
let page_width = page_indicator.chars().count();
let bindings_content_width: usize = bindings
.iter()
.map(|(k, a)| k.chars().count() + 1 + a.chars().count())
.sum();
let n = bindings.len();
let total_content = page_width + bindings_content_width;
let total_gaps = available_width.saturating_sub(total_content);
let gap_count = n + 1;
let base_gap = total_gaps / gap_count;
let extra = total_gaps % gap_count;
let mut spans = vec![
Span::styled(
page_indicator.to_string(),
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })),
];
for (i, (key, action)) in bindings.into_iter().enumerate() {
spans.push(Span::styled(
key.to_string(),
Style::new().fg(Color::Yellow),
));
spans.push(Span::styled(
format!(" {action}"),
Style::new().fg(Color::Rgb(120, 125, 135)),
));
if i < n - 1 {
let gap = base_gap + if i + 1 < extra { 1 } else { 0 };
spans.push(Span::raw(" ".repeat(gap)));
}
}
Line::from(spans)
};
let footer = Paragraph::new(content).block(Block::default().borders(Borders::ALL));
let footer = Paragraph::new(content).block(block);
frame.render_widget(footer, area);
}
@@ -247,6 +284,13 @@ fn render_modal(frame: &mut Frame, app: &App, term: Rect) {
.border_color(Color::Yellow)
.render_centered(frame, term);
}
Modal::SetTempo(input) => {
TextInputModal::new("Set Tempo (20-300 BPM)", input)
.hint("Enter BPM")
.width(30)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::AddSamplePath(path) => {
TextInputModal::new("Add Sample Path", path)
.hint("Enter directory path containing samples")

View File

@@ -52,15 +52,50 @@ impl Widget for Scope<'_> {
}
fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
for x in 0..area.width {
let sample_idx = (x as usize * data.len()) / area.width as usize;
let sample = data.get(sample_idx).copied().unwrap_or(0.0) * gain;
let level = (sample.abs() * 8.0).min(7.0) as usize;
let ch = BLOCKS[level];
let mut patterns = vec![0u8; width * height];
buf[(area.x + x, area.y)].set_char(ch).set_fg(color);
for fine_x in 0..fine_width {
let sample_idx = (fine_x * data.len()) / fine_width;
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0);
let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fine_y = fine_y.min(fine_height - 1);
let char_x = fine_x / 2;
let char_y = fine_y / 4;
let dot_x = fine_x % 2;
let dot_y = fine_y % 4;
let bit = match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
};
patterns[char_y * width + char_x] |= bit;
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
}

42838
seq/test Normal file

File diff suppressed because it is too large Load Diff