Refactoring

This commit is contained in:
2026-01-20 03:30:48 +01:00
parent 06ec2ae70f
commit 276107433a
24 changed files with 582 additions and 298 deletions

View File

@@ -4,14 +4,15 @@ use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crossbeam_channel::Sender;
use crate::commands::AppCommand; use crate::commands::AppCommand;
use crate::config::MAX_SLOTS; use crate::config::MAX_SLOTS;
use crate::file; use crate::engine::{
use crate::link::LinkState; LinkState, PatternSnapshot, SeqCommand, SequencerSnapshot, SlotChange, StepSnapshot,
use crate::model::Pattern; };
use crate::model::{self, Pattern, Rng, ScriptEngine, StepContext, Variables};
use crate::page::Page; use crate::page::Page;
use crate::script::{Rng, ScriptEngine, StepContext, Variables};
use crate::sequencer::{SequencerSnapshot, SlotChange};
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, PatternsViewLevel,
@@ -334,14 +335,14 @@ impl App {
pattern: usize, pattern: usize,
snapshot: &SequencerSnapshot, snapshot: &SequencerSnapshot,
) -> Option<bool> { ) -> Option<bool> {
self.playback.queued_changes.iter().find_map(|c| match c { self.playback.queued_changes.iter().find_map(|c| match *c {
SlotChange::Add { SlotChange::Add {
slot: _, slot: _,
bank: b, bank: b,
pattern: p, pattern: p,
} if *b == bank && *p == pattern => Some(true), } if b == bank && p == pattern => Some(true),
SlotChange::Remove { slot } => { SlotChange::Remove { slot } => {
let s = snapshot.slot_data[*slot]; let s = snapshot.slot_data[slot];
if s.active && s.bank == bank && s.pattern == pattern { if s.active && s.bank == bank && s.pattern == pattern {
Some(false) Some(false)
} else { } else {
@@ -366,14 +367,14 @@ impl App {
} }
}); });
let pending = self.playback.queued_changes.iter().position(|c| match c { let pending = self.playback.queued_changes.iter().position(|c| match *c {
SlotChange::Add { SlotChange::Add {
bank: b, bank: b,
pattern: p, pattern: p,
.. ..
} => *b == bank && *p == pattern, } => b == bank && p == pattern,
SlotChange::Remove { slot } => { SlotChange::Remove { slot } => {
let s = snapshot.slot_data[*slot]; let s = snapshot.slot_data[slot];
s.bank == bank && s.pattern == pattern s.bank == bank && s.pattern == pattern
} }
}); });
@@ -428,7 +429,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();
match file::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()));
self.project_state.file_path = Some(path); self.project_state.file_path = Some(path);
@@ -440,7 +441,7 @@ impl App {
} }
pub fn load(&mut self, path: PathBuf, link: &LinkState) { pub fn load(&mut self, path: PathBuf, link: &LinkState) {
match file::load(&path) { match model::load(&path) {
Ok(project) => { Ok(project) => {
self.project_state.project = project; self.project_state.project = project;
self.editor_ctx.step = 0; self.editor_ctx.step = 0;
@@ -663,4 +664,49 @@ impl App {
}, },
} }
} }
pub fn flush_queued_changes(&mut self, cmd_tx: &Sender<SeqCommand>) {
for change in self.playback.queued_changes.drain(..) {
match change {
SlotChange::Add {
slot,
bank,
pattern,
} => {
let _ = cmd_tx.send(SeqCommand::SlotAdd {
slot,
bank,
pattern,
});
}
SlotChange::Remove { slot } => {
let _ = cmd_tx.send(SeqCommand::SlotRemove { slot });
}
}
}
}
pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender<SeqCommand>) {
for (bank, pattern) in self.project_state.take_dirty() {
let pat = self.project_state.project.pattern_at(bank, pattern);
let snapshot = PatternSnapshot {
speed: pat.speed,
length: pat.length,
steps: pat
.steps
.iter()
.take(pat.length)
.map(|s| StepSnapshot {
active: s.active,
script: s.script.clone(),
})
.collect(),
};
let _ = cmd_tx.send(SeqCommand::PatternUpdate {
bank,
pattern,
data: snapshot,
});
}
}
} }

View File

@@ -1,7 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use crate::engine::SlotChange;
use crate::model::PatternSpeed; use crate::model::PatternSpeed;
use crate::sequencer::SlotChange;
use crate::state::{Modal, PatternField}; use crate::state::{Modal, PatternField};
pub enum AppCommand { pub enum AppCommand {

View File

@@ -5,29 +5,49 @@ use doux::{Engine, EngineMetrics};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use crate::sequencer::AudioCommand; use super::AudioCommand;
pub struct ScopeBuffer { pub struct ScopeBuffer {
pub samples: [AtomicU32; 64], pub samples: [AtomicU32; 64],
peak_left: AtomicU32,
peak_right: AtomicU32,
} }
impl ScopeBuffer { impl ScopeBuffer {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
samples: std::array::from_fn(|_| AtomicU32::new(0)), samples: std::array::from_fn(|_| AtomicU32::new(0)),
peak_left: AtomicU32::new(0),
peak_right: AtomicU32::new(0),
} }
} }
pub fn write(&self, data: &[f32]) { pub fn write(&self, data: &[f32]) {
let mut peak_l: f32 = 0.0;
let mut peak_r: f32 = 0.0;
for (i, atom) in self.samples.iter().enumerate() { for (i, atom) in self.samples.iter().enumerate() {
let val = data.get(i * 2).copied().unwrap_or(0.0); let idx = i * 2;
atom.store(val.to_bits(), Ordering::Relaxed); let left = data.get(idx).copied().unwrap_or(0.0);
let right = data.get(idx + 1).copied().unwrap_or(0.0);
peak_l = peak_l.max(left.abs());
peak_r = peak_r.max(right.abs());
atom.store(left.to_bits(), Ordering::Relaxed);
} }
self.peak_left.store(peak_l.to_bits(), Ordering::Relaxed);
self.peak_right.store(peak_r.to_bits(), Ordering::Relaxed);
} }
pub fn read(&self) -> [f32; 64] { pub fn read(&self) -> [f32; 64] {
std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed))) std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed)))
} }
pub fn peaks(&self) -> (f32, f32) {
let left = f32::from_bits(self.peak_left.load(Ordering::Relaxed));
let right = f32::from_bits(self.peak_right.load(Ordering::Relaxed));
(left, right)
}
} }
pub struct AudioStreamConfig { pub struct AudioStreamConfig {
@@ -56,7 +76,9 @@ pub fn build_stream(
let device = match &config.output_device { let device = match &config.output_device {
Some(name) => doux::audio::find_output_device(name) Some(name) => doux::audio::find_output_device(name)
.ok_or_else(|| format!("Device not found: {name}"))?, .ok_or_else(|| format!("Device not found: {name}"))?,
None => host.default_output_device().ok_or("No default output device")?, None => host
.default_output_device()
.ok_or("No default output device")?,
}; };
let default_config = device.default_output_config().map_err(|e| e.to_string())?; let default_config = device.default_output_config().map_err(|e| e.to_string())?;
@@ -104,7 +126,8 @@ pub fn build_stream(
} }
AudioCommand::ResetEngine => { AudioCommand::ResetEngine => {
let old_samples = std::mem::take(&mut engine.sample_index); let old_samples = std::mem::take(&mut engine.sample_index);
engine = Engine::new_with_metrics(sr, channels, Arc::clone(&metrics_clone)); engine =
Engine::new_with_metrics(sr, channels, Arc::clone(&metrics_clone));
engine.sample_index = old_samples; engine.sample_index = old_samples;
} }
} }
@@ -119,6 +142,8 @@ pub fn build_stream(
) )
.map_err(|e| format!("Failed to build stream: {e}"))?; .map_err(|e| format!("Failed to build stream: {e}"))?;
stream.play().map_err(|e| format!("Failed to play stream: {e}"))?; stream
.play()
.map_err(|e| format!("Failed to play stream: {e}"))?;
Ok((stream, sample_rate)) Ok((stream, sample_rate))
} }

10
seq/src/engine/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
mod audio;
mod link;
mod sequencer;
pub use audio::{build_stream, AudioStreamConfig, PatternSlot, ScopeBuffer};
pub use link::LinkState;
pub use sequencer::{
spawn_sequencer, AudioCommand, PatternSnapshot, SeqCommand, SequencerSnapshot, SlotChange,
StepSnapshot,
};

View File

@@ -4,10 +4,9 @@ use std::sync::Arc;
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use std::time::Duration; use std::time::Duration;
use crate::audio::PatternSlot; use super::{LinkState, PatternSlot};
use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS}; use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS};
use crate::link::LinkState; use crate::model::{Rng, ScriptEngine, StepContext, Variables};
use crate::script::{Rng, ScriptEngine, StepContext, Variables};
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum SlotChange { pub enum SlotChange {

View File

@@ -6,10 +6,9 @@ use std::sync::Arc;
use crate::app::App; use crate::app::App;
use crate::commands::AppCommand; use crate::commands::AppCommand;
use crate::link::LinkState; use crate::engine::{AudioCommand, LinkState, SequencerSnapshot};
use crate::model::PatternSpeed; use crate::model::PatternSpeed;
use crate::page::Page; use crate::page::Page;
use crate::sequencer::{AudioCommand, SequencerSnapshot};
use crate::state::{AudioFocus, Focus, Modal, PatternField, PatternsViewLevel}; use crate::state::{AudioFocus, Focus, Modal, PatternField, PatternsViewLevel};
pub enum InputResult { pub enum InputResult {

View File

@@ -1,17 +1,12 @@
mod app; mod app;
mod audio;
mod commands; mod commands;
mod config; mod config;
mod file; mod engine;
mod input; mod input;
mod link;
mod model; mod model;
mod page; mod page;
mod script;
mod sequencer;
mod services; mod services;
mod state; mod state;
mod ui;
mod views; mod views;
mod widgets; mod widgets;
@@ -32,10 +27,8 @@ use ratatui::prelude::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use app::App; use app::App;
use audio::{AudioStreamConfig, ScopeBuffer}; use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer};
use input::{handle_key, InputContext, InputResult}; use input::{handle_key, InputContext, InputResult};
use link::LinkState;
use sequencer::{spawn_sequencer, PatternSnapshot, SeqCommand, SlotChange, StepSnapshot};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "seq", about = "A step sequencer with Ableton Link support")] #[command(name = "seq", about = "A step sequencer with Ableton Link support")]
@@ -103,7 +96,7 @@ fn main() -> io::Result<()> {
buffer_size: app.audio.config.buffer_size, buffer_size: app.audio.config.buffer_size,
}; };
let (mut stream, sample_rate) = audio::build_stream( let (mut stream, sample_rate) = build_stream(
&stream_config, &stream_config,
sequencer.audio_rx.clone(), sequencer.audio_rx.clone(),
Arc::clone(&scope_buffer), Arc::clone(&scope_buffer),
@@ -139,7 +132,7 @@ fn main() -> io::Result<()> {
} }
app.audio.config.sample_count = restart_samples.len(); app.audio.config.sample_count = restart_samples.len();
match audio::build_stream( match build_stream(
&new_config, &new_config,
sequencer.audio_rx.clone(), sequencer.audio_rx.clone(),
Arc::clone(&scope_buffer), Arc::clone(&scope_buffer),
@@ -158,7 +151,7 @@ fn main() -> io::Result<()> {
let index = doux::loader::scan_samples_dir(path); let index = doux::loader::scan_samples_dir(path);
fallback_samples.extend(index); fallback_samples.extend(index);
} }
let (fallback_stream, _) = audio::build_stream( let (fallback_stream, _) = build_stream(
&AudioStreamConfig { &AudioStreamConfig {
output_device: None, output_device: None,
channels: 2, channels: 2,
@@ -183,53 +176,16 @@ fn main() -> io::Result<()> {
app.metrics.cpu_load = metrics.load.get_load(); app.metrics.cpu_load = metrics.load.get_load();
app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize; app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize;
app.metrics.scope = scope_buffer.read(); app.metrics.scope = scope_buffer.read();
(app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks();
} }
let seq_snapshot = sequencer.snapshot(); let seq_snapshot = sequencer.snapshot();
app.metrics.event_count = seq_snapshot.event_count; app.metrics.event_count = seq_snapshot.event_count;
for change in app.playback.queued_changes.drain(..) { app.flush_queued_changes(&sequencer.cmd_tx);
match change { app.flush_dirty_patterns(&sequencer.cmd_tx);
SlotChange::Add {
slot,
bank,
pattern,
} => {
let _ = sequencer.cmd_tx.send(SeqCommand::SlotAdd {
slot,
bank,
pattern,
});
}
SlotChange::Remove { slot } => {
let _ = sequencer.cmd_tx.send(SeqCommand::SlotRemove { slot });
}
}
}
for (bank, pattern) in app.project_state.take_dirty() { terminal.draw(|frame| views::render(frame, &mut app, &link, &seq_snapshot))?;
let pat = app.project_state.project.pattern_at(bank, pattern);
let snapshot = PatternSnapshot {
speed: pat.speed,
length: pat.length,
steps: pat
.steps
.iter()
.take(pat.length)
.map(|s| StepSnapshot {
active: s.active,
script: s.script.clone(),
})
.collect(),
};
let _ = sequencer.cmd_tx.send(SeqCommand::PatternUpdate {
bank,
pattern,
data: snapshot,
});
}
terminal.draw(|frame| ui::render(frame, &mut app, &link, &seq_snapshot))?;
if event::poll(Duration::from_millis(16))? { if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {

View File

@@ -4,7 +4,7 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::model::{Bank, Project}; use super::{Bank, Project};
const VERSION: u8 = 1; const VERSION: u8 = 1;

7
seq/src/model/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
mod file;
mod project;
mod script;
pub use file::{load, save};
pub use project::{Bank, Pattern, PatternSpeed, Project};
pub use script::{Rng, ScriptEngine, StepContext, Variables};

View File

@@ -9,9 +9,9 @@ pub enum PatternSpeed {
Half, // 1/2x Half, // 1/2x
#[default] #[default]
Normal, // 1x Normal, // 1x
Double, // 2x Double, // 2x
Quad, // 4x Quad, // 4x
Octo, // 8x Octo, // 8x
} }
impl PatternSpeed { impl PatternSpeed {

View File

@@ -43,11 +43,7 @@ impl Cmd {
impl std::fmt::Display for Cmd { impl std::fmt::Display for Cmd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<String> = self let parts: Vec<String> = self.pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect();
.pairs
.iter()
.map(|(k, v)| format!("{k}/{v}"))
.collect();
write!(f, "/{}", parts.join("/")) write!(f, "/{}", parts.join("/"))
} }
} }
@@ -77,10 +73,7 @@ impl ScriptEngine {
let vars_for_get = Arc::clone(&vars); let vars_for_get = Arc::clone(&vars);
engine.register_fn("set", move |name: &str, value: Dynamic| { engine.register_fn("set", move |name: &str, value: Dynamic| {
vars_for_set vars_for_set.lock().unwrap().insert(name.to_string(), value);
.lock()
.unwrap()
.insert(name.to_string(), value);
}); });
engine.register_fn("get", move |name: &str| -> Dynamic { engine.register_fn("get", move |name: &str| -> Dynamic {
@@ -102,11 +95,17 @@ impl ScriptEngine {
rng_rand_ff.lock().unwrap().gen_range(min..max) rng_rand_ff.lock().unwrap().gen_range(min..max)
}); });
engine.register_fn("rand", move |min: i64, max: i64| -> f64 { engine.register_fn("rand", move |min: i64, max: i64| -> f64 {
rng_rand_ii.lock().unwrap().gen_range(min as f64..max as f64) rng_rand_ii
.lock()
.unwrap()
.gen_range(min as f64..max as f64)
}); });
engine.register_fn("rrand", move |min: f64, max: f64| -> i64 { engine.register_fn("rrand", move |min: f64, max: f64| -> i64 {
rng_rrand_ff.lock().unwrap().gen_range(min as i64..=max as i64) rng_rrand_ff
.lock()
.unwrap()
.gen_range(min as i64..=max as i64)
}); });
engine.register_fn("rrand", move |min: i64, max: i64| -> i64 { engine.register_fn("rrand", move |min: i64, max: i64| -> i64 {
rng_rrand_ii.lock().unwrap().gen_range(min..=max) rng_rrand_ii.lock().unwrap().gen_range(min..=max)
@@ -162,28 +161,111 @@ fn register_cmd(engine: &mut Engine) {
} }
reg_both!( reg_both!(
"time", "repeat", "dur", "gate", "time",
"freq", "detune", "speed", "glide", "repeat",
"pw", "spread", "mult", "warp", "mirror", "harmonics", "timbre", "morph", "begin", "end", "dur",
"gain", "postgain", "velocity", "pan", "gate",
"attack", "decay", "sustain", "release", "freq",
"lpf", "lpq", "lpe", "lpa", "lpd", "lps", "lpr", "detune",
"hpf", "hpq", "hpe", "hpa", "hpd", "hps", "hpr", "speed",
"bpf", "bpq", "bpe", "bpa", "bpd", "bps", "bpr", "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", "ftype",
"penv", "patt", "pdec", "psus", "prel", "penv",
"vib", "vibmod", "vibshape", "patt",
"fm", "fmh", "fmshape", "fme", "fma", "fmd", "fms", "fmr", "pdec",
"am", "amdepth", "amshape", "psus",
"rm", "rmdepth", "rmshape", "prel",
"phaser", "phaserdepth", "phasersweep", "phasercenter", "vib",
"flanger", "flangerdepth", "flangerfeedback", "vibmod",
"chorus", "chorusdepth", "chorusdelay", "vibshape",
"comb", "combfreq", "combfeedback", "combdamp", "fm",
"coarse", "crush", "fold", "wrap", "distort", "distortvol", "fmh",
"delay", "delaytime", "delayfeedback", "delaytype", "fmshape",
"verb", "verbdecay", "verbdamp", "verbpredelay", "verbdiff", "fme",
"voice", "orbit", "note", "size", "n", "cut" "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| { engine.register_fn("reset", |c: &mut Cmd, v: bool| {

View File

@@ -43,6 +43,8 @@ pub struct Metrics {
pub cpu_load: f32, pub cpu_load: f32,
pub schedule_depth: usize, pub schedule_depth: usize,
pub scope: [f32; 64], pub scope: [f32; 64],
pub peak_left: f32,
pub peak_right: f32,
} }
impl Default for Metrics { impl Default for Metrics {
@@ -54,6 +56,8 @@ impl Default for Metrics {
cpu_load: 0.0, cpu_load: 0.0,
schedule_depth: 0, schedule_depth: 0,
scope: [0.0; 64], scope: [0.0; 64],
peak_left: 0.0,
peak_right: 0.0,
} }
} }
} }

View File

@@ -1,4 +1,4 @@
use crate::sequencer::SlotChange; use crate::engine::SlotChange;
pub struct PlaybackState { pub struct PlaybackState {
pub playing: bool, pub playing: bool,

View File

@@ -4,13 +4,17 @@ use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::sequencer::SequencerSnapshot; use crate::engine::SequencerSnapshot;
use crate::state::Focus; use crate::state::Focus;
use crate::widgets::{Orientation, Scope}; 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) {
let [main_area, scope_area] = let [main_area, scope_area, vu_area] = Layout::horizontal([
Layout::horizontal([Constraint::Fill(1), Constraint::Length(10)]).areas(area); Constraint::Fill(1),
Constraint::Length(10),
Constraint::Length(10),
])
.areas(area);
let [seq_area, editor_area] = let [seq_area, editor_area] =
Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area); Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area);
@@ -18,6 +22,7 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar
render_sequencer(frame, app, snapshot, seq_area); render_sequencer(frame, app, snapshot, seq_area);
render_editor(frame, app, editor_area); render_editor(frame, app, editor_area);
render_scope(frame, app, scope_area); render_scope(frame, app, scope_area);
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) {
@@ -186,8 +191,7 @@ fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) {
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)
.border_style(Style::new().fg(Color::Rgb(70, 75, 85))) .border_style(Style::new().fg(Color::Rgb(70, 75, 85)));
.title("Scope");
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
@@ -197,3 +201,15 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
.color(Color::Green); .color(Color::Green);
frame.render_widget(scope, inner); frame.render_widget(scope, inner);
} }
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);
}

View File

@@ -2,3 +2,6 @@ pub mod audio_view;
pub mod doc_view; pub mod doc_view;
pub mod main_view; pub mod main_view;
pub mod patterns_view; pub mod patterns_view;
mod render;
pub use render::render;

View File

@@ -5,7 +5,7 @@ use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::sequencer::SequencerSnapshot; use crate::engine::SequencerSnapshot;
use crate::state::PatternsViewLevel; use crate::state::PatternsViewLevel;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {

View File

@@ -1,15 +1,16 @@
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, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::link::LinkState; use crate::engine::{LinkState, SequencerSnapshot};
use crate::page::Page; use crate::page::Page;
use crate::sequencer::SequencerSnapshot;
use crate::state::{Modal, PatternField}; use crate::state::{Modal, PatternField};
use crate::views::{audio_view, doc_view, main_view, patterns_view}; use crate::widgets::{ConfirmModal, TextInputModal};
use super::{audio_view, doc_view, main_view, patterns_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 [header_area, body_area, footer_area] = Layout::vertical([ let [header_area, body_area, footer_area] = Layout::vertical([
@@ -189,202 +190,61 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(footer, area); frame.render_widget(footer, area);
} }
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
fn render_modal(frame: &mut Frame, app: &App) { fn render_modal(frame: &mut Frame, app: &App) {
let term = frame.area(); let term = frame.area();
match &app.ui.modal { match &app.ui.modal {
Modal::None => {} Modal::None => {}
Modal::ConfirmQuit { selected } => { Modal::ConfirmQuit { selected } => {
let width = 30.min(term.width.saturating_sub(4)); ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term);
let height = 5.min(term.height.saturating_sub(4));
let area = centered_rect(width, height, term);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title("Confirm")
.border_style(Style::new().fg(Color::Yellow));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
frame.render_widget(
Paragraph::new("Quit?").alignment(Alignment::Center),
rows[0],
);
let yes_style = if *selected {
Style::new().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default()
};
let no_style = if !*selected {
Style::new().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default()
};
let buttons = Line::from(vec![
Span::styled(" Yes ", yes_style),
Span::raw(" "),
Span::styled(" No ", no_style),
]);
frame.render_widget(
Paragraph::new(buttons).alignment(Alignment::Center),
rows[1],
);
} }
Modal::SaveAs(path) => { Modal::SaveAs(path) => {
let width = (term.width * 60 / 100) TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path)
.clamp(40, 70) .width(60)
.min(term.width.saturating_sub(4)); .border_color(Color::Green)
let height = 5.min(term.height.saturating_sub(4)); .render_centered(frame, term);
let area = centered_rect(width, height, term);
frame.render_widget(Clear, area);
let modal = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(path, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.title("Save As (Enter to confirm, Esc to cancel)")
.border_style(Style::new().fg(Color::Green)),
);
frame.render_widget(modal, area);
} }
Modal::LoadFrom(path) => { Modal::LoadFrom(path) => {
let width = (term.width * 60 / 100) TextInputModal::new("Load From (Enter to confirm, Esc to cancel)", path)
.clamp(40, 70) .width(60)
.min(term.width.saturating_sub(4)); .border_color(Color::Blue)
let height = 5.min(term.height.saturating_sub(4)); .render_centered(frame, term);
let area = centered_rect(width, height, term);
frame.render_widget(Clear, area);
let modal = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(path, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.title("Load From (Enter to confirm, Esc to cancel)")
.border_style(Style::new().fg(Color::Blue)),
);
frame.render_widget(modal, area);
} }
Modal::RenameBank { bank, name } => { Modal::RenameBank { bank, name } => {
let width = 40.min(term.width.saturating_sub(4)); TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name)
let height = 5.min(term.height.saturating_sub(4)); .width(40)
let area = centered_rect(width, height, term); .border_color(Color::Magenta)
frame.render_widget(Clear, area); .render_centered(frame, term);
let modal = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(name, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Rename Bank {:02}", bank + 1))
.border_style(Style::new().fg(Color::Magenta)),
);
frame.render_widget(modal, area);
} }
Modal::RenamePattern { Modal::RenamePattern {
bank, bank,
pattern, pattern,
name, name,
} => { } => {
let width = 40.min(term.width.saturating_sub(4)); TextInputModal::new(
let height = 5.min(term.height.saturating_sub(4)); &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1),
let area = centered_rect(width, height, term); name,
frame.render_widget(Clear, area); )
let modal = Paragraph::new(Line::from(vec![ .width(40)
Span::raw("> "), .border_color(Color::Magenta)
Span::styled(name, Style::new().fg(Color::Cyan)), .render_centered(frame, term);
Span::styled("", Style::new().fg(Color::White)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1))
.border_style(Style::new().fg(Color::Magenta)),
);
frame.render_widget(modal, area);
} }
Modal::SetPattern { field, input } => { Modal::SetPattern { field, input } => {
let (title, hint) = match field { let (title, hint) = match field {
PatternField::Length => ("Set Length (2-32)", "Enter number"), PatternField::Length => ("Set Length (2-32)", "Enter number"),
PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"), PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"),
}; };
let width = 45.min(term.width.saturating_sub(4)); TextInputModal::new(title, input)
let height = 6.min(term.height.saturating_sub(4)); .hint(hint)
let area = centered_rect(width, height, term); .width(45)
frame.render_widget(Clear, area); .border_color(Color::Yellow)
.render_centered(frame, term);
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::new().fg(Color::Yellow));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(input, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
])),
rows[0],
);
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))),
rows[1],
);
} }
Modal::AddSamplePath(path) => { Modal::AddSamplePath(path) => {
let width = 60.min(term.width.saturating_sub(4)); TextInputModal::new("Add Sample Path", path)
let height = 6.min(term.height.saturating_sub(4)); .hint("Enter directory path containing samples")
let area = centered_rect(width, height, term); .width(60)
frame.render_widget(Clear, area); .border_color(Color::Magenta)
.render_centered(frame, term);
let block = Block::default()
.borders(Borders::ALL)
.title("Add Sample Path")
.border_style(Style::new().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(path, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
])),
rows[0],
);
frame.render_widget(
Paragraph::new(Span::styled(
"Enter directory path containing samples",
Style::new().fg(Color::DarkGray),
)),
rows[1],
);
} }
} }
} }

View File

@@ -0,0 +1,60 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use super::ModalFrame;
pub struct ConfirmModal<'a> {
title: &'a str,
message: &'a str,
selected: bool,
}
impl<'a> ConfirmModal<'a> {
pub fn new(title: &'a str, message: &'a str, selected: bool) -> Self {
Self {
title,
message,
selected,
}
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
let inner = ModalFrame::new(self.title)
.width(30)
.height(5)
.border_color(Color::Yellow)
.render_centered(frame, term);
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
frame.render_widget(
Paragraph::new(self.message).alignment(Alignment::Center),
rows[0],
);
let yes_style = if self.selected {
Style::new().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default()
};
let no_style = if !self.selected {
Style::new().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default()
};
let buttons = Line::from(vec![
Span::styled(" Yes ", yes_style),
Span::raw(" "),
Span::styled(" No ", no_style),
]);
frame.render_widget(
Paragraph::new(buttons).alignment(Alignment::Center),
rows[1],
);
}
}

View File

@@ -1,3 +1,11 @@
mod confirm;
mod modal;
mod scope; mod scope;
mod text_input;
mod vu_meter;
pub use confirm::ConfirmModal;
pub use modal::ModalFrame;
pub use scope::{Orientation, Scope}; pub use scope::{Orientation, Scope};
pub use text_input::TextInputModal;
pub use vu_meter::VuMeter;

58
seq/src/widgets/modal.rs Normal file
View File

@@ -0,0 +1,58 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Clear};
use ratatui::Frame;
pub struct ModalFrame<'a> {
title: &'a str,
width: u16,
height: u16,
border_color: Color,
}
impl<'a> ModalFrame<'a> {
pub fn new(title: &'a str) -> Self {
Self {
title,
width: 40,
height: 5,
border_color: Color::White,
}
}
pub fn width(mut self, w: u16) -> Self {
self.width = w;
self
}
pub fn height(mut self, h: u16) -> Self {
self.height = h;
self
}
pub fn border_color(mut self, c: Color) -> Self {
self.border_color = c;
self
}
pub fn render_centered(&self, frame: &mut Frame, term: Rect) -> Rect {
let width = self.width.min(term.width.saturating_sub(4));
let height = self.height.min(term.height.saturating_sub(4));
let x = term.x + (term.width.saturating_sub(width)) / 2;
let y = term.y + (term.height.saturating_sub(height)) / 2;
let area = Rect::new(x, y, width, height);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(self.title)
.border_style(Style::new().fg(self.border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
}

View File

@@ -43,7 +43,9 @@ impl Widget for Scope<'_> {
} }
match self.orientation { match self.orientation {
Orientation::Horizontal => render_horizontal(self.data, area, buf, self.color, self.gain), Orientation::Horizontal => {
render_horizontal(self.data, area, buf, self.color, self.gain)
}
Orientation::Vertical => render_vertical(self.data, area, buf, self.color, self.gain), Orientation::Vertical => render_vertical(self.data, area, buf, self.color, self.gain),
} }
} }

View File

@@ -0,0 +1,82 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use super::ModalFrame;
pub struct TextInputModal<'a> {
title: &'a str,
input: &'a str,
hint: Option<&'a str>,
border_color: Color,
width: u16,
}
impl<'a> TextInputModal<'a> {
pub fn new(title: &'a str, input: &'a str) -> Self {
Self {
title,
input,
hint: None,
border_color: Color::White,
width: 50,
}
}
pub fn hint(mut self, h: &'a str) -> Self {
self.hint = Some(h);
self
}
pub fn border_color(mut self, c: Color) -> Self {
self.border_color = c;
self
}
pub fn width(mut self, w: u16) -> Self {
self.width = w;
self
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
let height = if self.hint.is_some() { 6 } else { 5 };
let inner = ModalFrame::new(self.title)
.width(self.width)
.height(height)
.border_color(self.border_color)
.render_centered(frame, term);
if self.hint.is_some() {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(self.input, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
])),
rows[0],
);
if let Some(hint) = self.hint {
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))),
rows[1],
);
}
} else {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(self.input, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
])),
inner,
);
}
}
}

View File

@@ -0,0 +1,67 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
pub struct VuMeter {
left: f32,
right: f32,
}
impl VuMeter {
pub fn new(left: f32, right: f32) -> Self {
Self { left, right }
}
fn level_to_color(level: f32) -> Color {
if level > 0.9 {
Color::Red
} else if level > 0.7 {
Color::Yellow
} else {
Color::Green
}
}
}
impl Widget for VuMeter {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 2 || area.height == 0 {
return;
}
let height = area.height as usize;
let left_col = area.x;
let right_col = area.x + area.width - 1;
let left_level = (self.left.clamp(0.0, 1.0) * height as f32) as usize;
let right_level = (self.right.clamp(0.0, 1.0) * height as f32) as usize;
for row in 0..height {
let y = area.y + area.height - 1 - row as u16;
let level_at_row = (row as f32 + 0.5) / height as f32;
let color = Self::level_to_color(level_at_row);
if row < left_level {
buf[(left_col, y)].set_char('█').set_fg(color);
} else {
buf[(left_col, y)].set_char('░').set_fg(Color::DarkGray);
}
if row < right_level {
buf[(right_col, y)].set_char('█').set_fg(color);
} else {
buf[(right_col, y)].set_char('░').set_fg(Color::DarkGray);
}
}
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(' ');
}
}
}
}
}