WIP: better precision?

This commit is contained in:
2026-01-29 18:50:54 +01:00
parent 00a90f1c15
commit 89e4795e86
13 changed files with 477 additions and 224 deletions

View File

@@ -18,7 +18,7 @@ path = "src/main.rs"
cagire-forth = { path = "crates/forth" } cagire-forth = { path = "crates/forth" }
cagire-project = { path = "crates/project" } cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" } cagire-ratatui = { path = "crates/ratatui" }
doux = { git = "https://github.com/Bubobubobubobubo/doux", features = ["native"] } doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
rusty_link = "0.4" rusty_link = "0.4"
ratatui = "0.29" ratatui = "0.29"
crossterm = "0.28" crossterm = "0.28"

View File

@@ -91,6 +91,7 @@ impl App {
channels: self.audio.config.channels, channels: self.audio.config.channels,
buffer_size: self.audio.config.buffer_size, buffer_size: self.audio.config.buffer_size,
max_voices: self.audio.config.max_voices, max_voices: self.audio.config.max_voices,
lookahead_ms: self.audio.config.lookahead_ms,
}, },
display: crate::settings::DisplaySettings { display: crate::settings::DisplaySettings {
fps: self.audio.config.refresh_rate.to_fps(), fps: self.audio.config.refresh_rate.to_fps(),

View File

@@ -4,7 +4,7 @@ use crossbeam_channel::Receiver;
use doux::{Engine, EngineMetrics}; use doux::{Engine, EngineMetrics};
use ringbuf::{traits::*, HeapRb}; use ringbuf::{traits::*, HeapRb};
use rustfft::{num_complex::Complex, FftPlanner}; use rustfft::{num_complex::Complex, FftPlanner};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
@@ -225,6 +225,7 @@ pub fn build_stream(
spectrum_buffer: Arc<SpectrumBuffer>, spectrum_buffer: Arc<SpectrumBuffer>,
metrics: Arc<EngineMetrics>, metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sample::SampleEntry>, initial_samples: Vec<doux::sample::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>,
) -> Result<(Stream, f32, AnalysisHandle), String> { ) -> Result<(Stream, f32, AnalysisHandle), String> {
let host = cpal::default_host(); let host = cpal::default_host();
@@ -270,8 +271,12 @@ pub fn build_stream(
while let Ok(cmd) = audio_rx.try_recv() { while let Ok(cmd) = audio_rx.try_recv() {
match cmd { match cmd {
AudioCommand::Evaluate(s) => { AudioCommand::Evaluate { cmd, time } => {
engine.evaluate(&s); let cmd_with_time = match time {
Some(t) => format!("{cmd}/time/{t:.6}"),
None => cmd,
};
engine.evaluate(&cmd_with_time);
} }
AudioCommand::Hush => { AudioCommand::Hush => {
engine.hush(); engine.hush();
@@ -287,6 +292,7 @@ pub fn build_stream(
engine = engine =
Engine::new_with_metrics(sr, channels, max_voices, Arc::clone(&metrics_clone)); Engine::new_with_metrics(sr, channels, max_voices, Arc::clone(&metrics_clone));
engine.sample_index = old_samples; engine.sample_index = old_samples;
audio_sample_pos.store(0, Ordering::Relaxed);
} }
} }
} }
@@ -295,6 +301,8 @@ pub fn build_stream(
engine.process_block(data, &[], &[]); engine.process_block(data, &[], &[]);
scope_buffer.write(&engine.output); scope_buffer.write(&engine.output);
audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Relaxed);
// Feed mono mix to analysis thread via ring buffer (non-blocking) // Feed mono mix to analysis thread via ring buffer (non-blocking)
for chunk in engine.output.chunks(channels) { for chunk in engine.output.chunks(channels) {
let mono = chunk.iter().sum::<f32>() / channels as f32; let mono = chunk.iter().sum::<f32>() / channels as f32;

View File

@@ -5,6 +5,6 @@ mod sequencer;
pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer, SpectrumBuffer}; pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer, SpectrumBuffer};
pub use link::LinkState; pub use link::LinkState;
pub use sequencer::{ pub use sequencer::{
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig,
StepSnapshot, SequencerSnapshot, StepSnapshot,
}; };

View File

@@ -1,7 +1,7 @@
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::AtomicI64; use std::sync::atomic::{AtomicI64, AtomicU64};
use std::sync::Arc; use std::sync::Arc;
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use std::time::Duration; use std::time::Duration;
@@ -40,7 +40,7 @@ impl PatternChange {
} }
pub enum AudioCommand { pub enum AudioCommand {
Evaluate(String), Evaluate { cmd: String, time: Option<f64> },
Hush, Hush,
Panic, Panic,
LoadSamples(Vec<doux::sample::SampleEntry>), LoadSamples(Vec<doux::sample::SampleEntry>),
@@ -199,6 +199,12 @@ impl AudioState {
} }
} }
pub struct SequencerConfig {
pub audio_sample_pos: Arc<AtomicU64>,
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
pub lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn spawn_sequencer( pub fn spawn_sequencer(
link: Arc<LinkState>, link: Arc<LinkState>,
@@ -209,6 +215,7 @@ pub fn spawn_sequencer(
quantum: f64, quantum: f64,
live_keys: Arc<LiveKeyState>, live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>, nudge_us: Arc<AtomicI64>,
config: SequencerConfig,
) -> (SequencerHandle, Receiver<AudioCommand>) { ) -> (SequencerHandle, Receiver<AudioCommand>) {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64); let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256); let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
@@ -233,6 +240,9 @@ pub fn spawn_sequencer(
shared_state_clone, shared_state_clone,
live_keys, live_keys,
nudge_us, nudge_us,
config.audio_sample_pos,
config.sample_rate,
config.lookahead_ms,
); );
}) })
.expect("Failed to spawn sequencer thread"); .expect("Failed to spawn sequencer thread");
@@ -359,10 +369,18 @@ pub(crate) struct TickInput {
pub quantum: f64, pub quantum: f64,
pub fill: bool, pub fill: bool,
pub nudge_secs: f64, pub nudge_secs: f64,
pub current_time_us: i64,
pub engine_time: f64,
pub lookahead_secs: f64,
}
pub struct TimestampedCommand {
pub cmd: String,
pub time: Option<f64>,
} }
pub(crate) struct TickOutput { pub(crate) struct TickOutput {
pub audio_commands: Vec<String>, pub audio_commands: Vec<TimestampedCommand>,
pub new_tempo: Option<f64>, pub new_tempo: Option<f64>,
pub shared_state: SharedSequencerState, pub shared_state: SharedSequencerState,
} }
@@ -422,7 +440,7 @@ pub(crate) struct SequencerState {
variables: Variables, variables: Variables,
speed_overrides: HashMap<(usize, usize), f64>, speed_overrides: HashMap<(usize, usize), f64>,
key_cache: KeyCache, key_cache: KeyCache,
buf_audio_commands: Vec<String>, buf_audio_commands: Vec<TimestampedCommand>,
} }
impl SequencerState { impl SequencerState {
@@ -516,7 +534,17 @@ impl SequencerState {
let stopped = self.deactivate_pending(beat, prev_beat, input.quantum); let stopped = self.deactivate_pending(beat, prev_beat, input.quantum);
self.audio_state.pending_stops.retain(|p| !stopped.contains(&p.id)); self.audio_state.pending_stops.retain(|p| !stopped.contains(&p.id));
let steps = self.execute_steps(beat, prev_beat, input.tempo, input.quantum, input.fill, input.nudge_secs); let steps = self.execute_steps(
beat,
prev_beat,
input.tempo,
input.quantum,
input.fill,
input.nudge_secs,
input.current_time_us,
input.engine_time,
input.lookahead_secs,
);
let vars = self.read_variables(&steps.completed_iterations, &stopped, steps.any_step_fired); let vars = self.read_variables(&steps.completed_iterations, &stopped, steps.any_step_fired);
self.apply_chain_transitions(vars.chain_transitions); self.apply_chain_transitions(vars.chain_transitions);
@@ -591,6 +619,7 @@ impl SequencerState {
stopped stopped
} }
#[allow(clippy::too_many_arguments)]
fn execute_steps( fn execute_steps(
&mut self, &mut self,
beat: f64, beat: f64,
@@ -599,6 +628,9 @@ impl SequencerState {
quantum: f64, quantum: f64,
fill: bool, fill: bool,
nudge_secs: f64, nudge_secs: f64,
_current_time_us: i64,
engine_time: f64,
lookahead_secs: f64,
) -> StepResult { ) -> StepResult {
self.buf_audio_commands.clear(); self.buf_audio_commands.clear();
let mut result = StepResult { let mut result = StepResult {
@@ -670,9 +702,19 @@ impl SequencerState {
(active.bank, active.pattern, source_idx), (active.bank, active.pattern, source_idx),
std::mem::take(&mut trace), std::mem::take(&mut trace),
); );
let event_time = if lookahead_secs > 0.0 {
Some(engine_time + lookahead_secs)
} else {
None
};
for cmd in cmds { for cmd in cmds {
self.event_count += 1; self.event_count += 1;
self.buf_audio_commands.push(cmd); self.buf_audio_commands.push(TimestampedCommand {
cmd,
time: event_time,
});
} }
} }
} }
@@ -790,6 +832,9 @@ fn sequencer_loop(
shared_state: Arc<ArcSwap<SharedSequencerState>>, shared_state: Arc<ArcSwap<SharedSequencerState>>,
live_keys: Arc<LiveKeyState>, live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>, nudge_us: Arc<AtomicI64>,
audio_sample_pos: Arc<AtomicU64>,
sample_rate: Arc<std::sync::atomic::AtomicU32>,
lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
) { ) {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -807,10 +852,15 @@ fn sequencer_loop(
} }
let state = link.capture_app_state(); let state = link.capture_app_state();
let time = link.clock_micros(); let current_time_us = link.clock_micros();
let beat = state.beat_at_time(time, quantum); let beat = state.beat_at_time(current_time_us, quantum);
let tempo = state.tempo(); let tempo = state.tempo();
let sr = sample_rate.load(Ordering::Relaxed) as f64;
let audio_samples = audio_sample_pos.load(Ordering::Relaxed);
let engine_time = if sr > 0.0 { audio_samples as f64 / sr } else { 0.0 };
let lookahead_secs = lookahead_ms.load(Ordering::Relaxed) as f64 / 1000.0;
let input = TickInput { let input = TickInput {
commands, commands,
playing: playing.load(Ordering::Relaxed), playing: playing.load(Ordering::Relaxed),
@@ -819,15 +869,18 @@ fn sequencer_loop(
quantum, quantum,
fill: live_keys.fill(), fill: live_keys.fill(),
nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0, nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0,
current_time_us,
engine_time,
lookahead_secs,
}; };
let output = seq_state.tick(input); let output = seq_state.tick(input);
for cmd in output.audio_commands { for tsc in output.audio_commands {
match audio_tx.load().try_send(AudioCommand::Evaluate(cmd)) { let cmd = AudioCommand::Evaluate { cmd: tsc.cmd, time: tsc.time };
match audio_tx.load().try_send(cmd) {
Ok(()) => {} Ok(()) => {}
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => { Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
// Lags one tick in shared state: build_shared_state() already ran
seq_state.dropped_events += 1; seq_state.dropped_events += 1;
} }
} }
@@ -886,6 +939,9 @@ mod tests {
quantum: 4.0, quantum: 4.0,
fill: false, fill: false,
nudge_secs: 0.0, nudge_secs: 0.0,
current_time_us: 0,
engine_time: 0.0,
lookahead_secs: 0.0,
} }
} }
@@ -898,6 +954,9 @@ mod tests {
quantum: 4.0, quantum: 4.0,
fill: false, fill: false,
nudge_secs: 0.0, nudge_secs: 0.0,
current_time_us: 0,
engine_time: 0.0,
lookahead_secs: 0.0,
} }
} }

View File

@@ -1,7 +1,7 @@
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -28,6 +28,7 @@ pub struct InputContext<'a> {
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>, pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
pub seq_cmd_tx: &'a Sender<SeqCommand>, pub seq_cmd_tx: &'a Sender<SeqCommand>,
pub nudge_us: &'a Arc<AtomicI64>, pub nudge_us: &'a Arc<AtomicI64>,
pub lookahead_ms: &'a Arc<AtomicU32>,
} }
impl<'a> InputContext<'a> { impl<'a> InputContext<'a> {
@@ -697,7 +698,7 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let folder = &entry.folder; let folder = &entry.folder;
let idx = entry.index; let idx = entry.index;
let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1"); let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(cmd)); let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate { cmd, time: None });
} }
_ => state.toggle_expand(), _ => state.toggle_expand(),
} }
@@ -1082,6 +1083,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.nudge_us ctx.nudge_us
.store((prev - 1000).max(-100_000), Ordering::Relaxed); .store((prev - 1000).max(-100_000), Ordering::Relaxed);
} }
SettingKind::Lookahead => {
ctx.app.audio.adjust_lookahead(-1);
ctx.lookahead_ms
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
}
} }
ctx.app.save_settings(ctx.link); ctx.app.save_settings(ctx.link);
} }
@@ -1101,6 +1107,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.nudge_us ctx.nudge_us
.store((prev + 1000).min(100_000), Ordering::Relaxed); .store((prev + 1000).min(100_000), Ordering::Relaxed);
} }
SettingKind::Lookahead => {
ctx.app.audio.adjust_lookahead(1);
ctx.lookahead_ms
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
}
} }
ctx.app.save_settings(ctx.link); ctx.app.save_settings(ctx.link);
} }
@@ -1134,9 +1145,10 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} }
KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0, KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0,
KeyCode::Char('t') => { KeyCode::Char('t') => {
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate( let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate {
"/sound/sine/dur/0.5/decay/0.2".into(), cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
)); time: None,
});
} }
KeyCode::Char('?') => { KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));

View File

@@ -12,7 +12,7 @@ mod widgets;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -28,7 +28,8 @@ use ratatui::Terminal;
use app::App; use app::App;
use engine::{ use engine::{
build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer, build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SequencerConfig,
SpectrumBuffer,
}; };
use input::{handle_key, InputContext, InputResult}; use input::{handle_key, InputContext, InputResult};
use settings::Settings; use settings::Settings;
@@ -88,6 +89,7 @@ fn main() -> io::Result<()> {
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels); app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size); app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
app.audio.config.max_voices = settings.audio.max_voices; app.audio.config.max_voices = settings.audio.max_voices;
app.audio.config.lookahead_ms = settings.audio.lookahead_ms;
app.audio.config.sample_paths = args.samples; app.audio.config.sample_paths = args.samples;
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps); app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
app.ui.runtime_highlight = settings.display.runtime_highlight; app.ui.runtime_highlight = settings.display.runtime_highlight;
@@ -100,6 +102,10 @@ fn main() -> io::Result<()> {
let scope_buffer = Arc::new(ScopeBuffer::new()); let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new()); let spectrum_buffer = Arc::new(SpectrumBuffer::new());
let audio_sample_pos = Arc::new(AtomicU64::new(0));
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
let lookahead_ms = Arc::new(AtomicU32::new(settings.audio.lookahead_ms));
let mut initial_samples = Vec::new(); let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths { for path in &app.audio.config.sample_paths {
let index = doux::loader::scan_samples_dir(path); let index = doux::loader::scan_samples_dir(path);
@@ -107,6 +113,12 @@ fn main() -> io::Result<()> {
initial_samples.extend(index); initial_samples.extend(index);
} }
let seq_config = SequencerConfig {
audio_sample_pos: Arc::clone(&audio_sample_pos),
sample_rate: Arc::clone(&sample_rate_shared),
lookahead_ms: Arc::clone(&lookahead_ms),
};
let (sequencer, initial_audio_rx) = spawn_sequencer( let (sequencer, initial_audio_rx) = spawn_sequencer(
Arc::clone(&link), Arc::clone(&link),
Arc::clone(&playing), Arc::clone(&playing),
@@ -116,6 +128,7 @@ fn main() -> io::Result<()> {
settings.link.quantum, settings.link.quantum,
Arc::clone(&app.live_keys), Arc::clone(&app.live_keys),
Arc::clone(&nudge_us), Arc::clone(&nudge_us),
seq_config,
); );
let stream_config = AudioStreamConfig { let stream_config = AudioStreamConfig {
@@ -132,9 +145,11 @@ fn main() -> io::Result<()> {
Arc::clone(&spectrum_buffer), Arc::clone(&spectrum_buffer),
Arc::clone(&metrics), Arc::clone(&metrics),
initial_samples, initial_samples,
Arc::clone(&audio_sample_pos),
) { ) {
Ok((s, sample_rate, analysis)) => { Ok((s, sample_rate, analysis)) => {
app.audio.config.sample_rate = sample_rate; app.audio.config.sample_rate = sample_rate;
sample_rate_shared.store(sample_rate as u32, Ordering::Relaxed);
(Some(s), Some(analysis)) (Some(s), Some(analysis))
} }
Err(e) => { Err(e) => {
@@ -174,6 +189,8 @@ fn main() -> io::Result<()> {
} }
app.audio.config.sample_count = restart_samples.len(); app.audio.config.sample_count = restart_samples.len();
audio_sample_pos.store(0, Ordering::Relaxed);
match build_stream( match build_stream(
&new_config, &new_config,
new_audio_rx, new_audio_rx,
@@ -181,11 +198,13 @@ fn main() -> io::Result<()> {
Arc::clone(&spectrum_buffer), Arc::clone(&spectrum_buffer),
Arc::clone(&metrics), Arc::clone(&metrics),
restart_samples, restart_samples,
Arc::clone(&audio_sample_pos),
) { ) {
Ok((new_stream, sr, new_analysis)) => { Ok((new_stream, sr, new_analysis)) => {
_stream = Some(new_stream); _stream = Some(new_stream);
_analysis_handle = Some(new_analysis); _analysis_handle = Some(new_analysis);
app.audio.config.sample_rate = sr; app.audio.config.sample_rate = sr;
sample_rate_shared.store(sr as u32, Ordering::Relaxed);
app.audio.error = None; app.audio.error = None;
app.ui.set_status("Audio restarted".to_string()); app.ui.set_status("Audio restarted".to_string());
} }
@@ -241,6 +260,7 @@ fn main() -> io::Result<()> {
audio_tx: &sequencer.audio_tx, audio_tx: &sequencer.audio_tx,
seq_cmd_tx: &sequencer.cmd_tx, seq_cmd_tx: &sequencer.cmd_tx,
nudge_us: &nudge_us, nudge_us: &nudge_us,
lookahead_ms: &lookahead_ms,
}; };
if let InputResult::Quit = handle_key(&mut ctx, key) { if let InputResult::Quit = handle_key(&mut ctx, key) {

View File

@@ -17,9 +17,12 @@ pub struct AudioSettings {
pub buffer_size: u32, pub buffer_size: u32,
#[serde(default = "default_max_voices")] #[serde(default = "default_max_voices")]
pub max_voices: usize, pub max_voices: usize,
#[serde(default = "default_lookahead_ms")]
pub lookahead_ms: u32,
} }
fn default_max_voices() -> usize { 32 } fn default_max_voices() -> usize { 32 }
fn default_lookahead_ms() -> u32 { 15 }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct DisplaySettings { pub struct DisplaySettings {
@@ -50,6 +53,7 @@ impl Default for AudioSettings {
channels: 2, channels: 2,
buffer_size: 512, buffer_size: 512,
max_voices: 32, max_voices: 32,
lookahead_ms: 15,
} }
} }
} }

View File

@@ -59,6 +59,7 @@ pub struct AudioConfig {
pub refresh_rate: RefreshRate, pub refresh_rate: RefreshRate,
pub show_scope: bool, pub show_scope: bool,
pub show_spectrum: bool, pub show_spectrum: bool,
pub lookahead_ms: u32,
} }
impl Default for AudioConfig { impl Default for AudioConfig {
@@ -75,6 +76,7 @@ impl Default for AudioConfig {
refresh_rate: RefreshRate::default(), refresh_rate: RefreshRate::default(),
show_scope: true, show_scope: true,
show_spectrum: true, show_spectrum: true,
lookahead_ms: 15,
} }
} }
} }
@@ -140,6 +142,7 @@ pub enum SettingKind {
BufferSize, BufferSize,
Polyphony, Polyphony,
Nudge, Nudge,
Lookahead,
} }
impl SettingKind { impl SettingKind {
@@ -148,16 +151,18 @@ impl SettingKind {
Self::Channels => Self::BufferSize, Self::Channels => Self::BufferSize,
Self::BufferSize => Self::Polyphony, Self::BufferSize => Self::Polyphony,
Self::Polyphony => Self::Nudge, Self::Polyphony => Self::Nudge,
Self::Nudge => Self::Channels, Self::Nudge => Self::Lookahead,
Self::Lookahead => Self::Channels,
} }
} }
pub fn prev(self) -> Self { pub fn prev(self) -> Self {
match self { match self {
Self::Channels => Self::Nudge, Self::Channels => Self::Lookahead,
Self::BufferSize => Self::Channels, Self::BufferSize => Self::Channels,
Self::Polyphony => Self::BufferSize, Self::Polyphony => Self::BufferSize,
Self::Nudge => Self::Polyphony, Self::Nudge => Self::Polyphony,
Self::Lookahead => Self::Nudge,
} }
} }
} }
@@ -297,6 +302,11 @@ impl AudioSettings {
self.config.max_voices = new_val; self.config.max_voices = new_val;
} }
pub fn adjust_lookahead(&mut self, delta: i32) {
let new_val = (self.config.lookahead_ms as i32 + delta).clamp(0, 50) as u32;
self.config.lookahead_ms = new_val;
}
pub fn toggle_refresh_rate(&mut self) { pub fn toggle_refresh_rate(&mut self) {
self.config.refresh_rate = self.config.refresh_rate.toggle(); self.config.refresh_rate = self.config.refresh_rate.toggle();
} }

View File

@@ -11,6 +11,7 @@ use crate::widgets::{Orientation, Scope, Spectrum};
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180); const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70); const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70);
const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95);
pub fn render(frame: &mut Frame, app: &App, area: Rect) { pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [left_col, _, right_col] = Layout::horizontal([ let [left_col, _, right_col] = Layout::horizontal([
@@ -40,20 +41,105 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
height: inner.height.saturating_sub(1), height: inner.height.saturating_sub(1),
}; };
let devices_height = devices_section_height(app); // Calculate section heights
let devices_lines = devices_section_height(app) as usize;
let settings_lines: usize = 8; // header(1) + divider(1) + 6 rows
let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1)
let total_lines = devices_lines + 1 + settings_lines + 1 + samples_lines;
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ let max_visible = padded.height as usize;
Constraint::Length(devices_height),
Constraint::Length(1),
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(6),
])
.areas(padded);
render_devices(frame, app, devices_area); // Calculate scroll offset based on focused section
render_settings(frame, app, settings_area); let (focus_start, focus_height) = match app.audio.section {
render_samples(frame, app, samples_area); EngineSection::Devices => (0, devices_lines),
EngineSection::Settings => (devices_lines + 1, settings_lines),
EngineSection::Samples => (devices_lines + 1 + settings_lines + 1, samples_lines),
};
let scroll_offset = if total_lines <= max_visible {
0
} else {
// Keep focused section in view (top-aligned when possible)
let focus_end = focus_start + focus_height;
if focus_end <= max_visible {
0
} else {
focus_start.min(total_lines.saturating_sub(max_visible))
}
};
let viewport_top = padded.y as i32;
let viewport_bottom = (padded.y + padded.height) as i32;
// Render each section at adjusted position
let mut y = viewport_top - scroll_offset as i32;
// Devices section
let devices_top = y;
let devices_bottom = y + devices_lines as i32;
if devices_bottom > viewport_top && devices_top < viewport_bottom {
let clipped_y = devices_top.max(viewport_top) as u16;
let clipped_height =
(devices_bottom.min(viewport_bottom) - devices_top.max(viewport_top)) as u16;
let devices_area = Rect {
x: padded.x,
y: clipped_y,
width: padded.width,
height: clipped_height,
};
render_devices(frame, app, devices_area);
}
y += devices_lines as i32 + 1; // +1 for blank line
// Settings section
let settings_top = y;
let settings_bottom = y + settings_lines as i32;
if settings_bottom > viewport_top && settings_top < viewport_bottom {
let clipped_y = settings_top.max(viewport_top) as u16;
let clipped_height =
(settings_bottom.min(viewport_bottom) - settings_top.max(viewport_top)) as u16;
let settings_area = Rect {
x: padded.x,
y: clipped_y,
width: padded.width,
height: clipped_height,
};
render_settings(frame, app, settings_area);
}
y += settings_lines as i32 + 1;
// Samples section
let samples_top = y;
let samples_bottom = y + samples_lines as i32;
if samples_bottom > viewport_top && samples_top < viewport_bottom {
let clipped_y = samples_top.max(viewport_top) as u16;
let clipped_height =
(samples_bottom.min(viewport_bottom) - samples_top.max(viewport_top)) as u16;
let samples_area = Rect {
x: padded.x,
y: clipped_y,
width: padded.width,
height: clipped_height,
};
render_samples(frame, app, samples_area);
}
// Scroll indicators
let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR);
let indicator_x = padded.x + padded.width.saturating_sub(1);
if scroll_offset > 0 {
let up_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(up_indicator, Rect::new(indicator_x, padded.y, 1, 1));
}
if scroll_offset + max_visible < total_lines {
let down_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
down_indicator,
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
);
}
} }
fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) { fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) {
@@ -241,6 +327,7 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize; let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize;
let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony; let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge; let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge;
let lookahead_focused = section_focused && app.audio.setting_kind == SettingKind::Lookahead;
let nudge_ms = app.metrics.nudge_ms; let nudge_ms = app.metrics.nudge_ms;
let nudge_label = if nudge_ms == 0.0 { let nudge_label = if nudge_ms == 0.0 {
@@ -249,6 +336,12 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
format!("{nudge_ms:+.1} ms") format!("{nudge_ms:+.1} ms")
}; };
let lookahead_label = if app.audio.config.lookahead_ms == 0 {
"off".to_string()
} else {
format!("{} ms", app.audio.config.lookahead_ms)
};
let rows = vec![ let rows = vec![
Row::new(vec![ Row::new(vec![
Span::styled( Span::styled(
@@ -305,6 +398,17 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
), ),
render_selector(&nudge_label, nudge_focused, highlight, normal), render_selector(&nudge_label, nudge_focused, highlight, normal),
]), ]),
Row::new(vec![
Span::styled(
if lookahead_focused {
"> Lookahead"
} else {
" Lookahead"
},
label_style,
),
render_selector(&lookahead_label, lookahead_focused, highlight, normal),
]),
Row::new(vec![ Row::new(vec![
Span::styled(" Sample rate", label_style), Span::styled(" Sample rate", label_style),
Span::styled( Span::styled(

View File

@@ -1,4 +1,4 @@
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
@@ -11,6 +11,7 @@ use crate::state::OptionsFocus;
const LABEL_COLOR: Color = Color::Rgb(120, 125, 135); const LABEL_COLOR: Color = Color::Rgb(120, 125, 135);
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180); const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70); const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70);
const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95);
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let block = Block::default() let block = Block::default()
@@ -28,43 +29,59 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
height: inner.height.saturating_sub(2), height: inner.height.saturating_sub(2),
}; };
let [display_area, _, link_area, _, session_area] = Layout::vertical([
Constraint::Length(8),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Min(5),
])
.areas(padded);
render_display_section(frame, app, display_area);
render_link_section(frame, app, link, link_area);
render_session_section(frame, link, session_area);
}
fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let header = Line::from(Span::styled(
"DISPLAY",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let focus = app.options.focus; let focus = app.options.focus;
let content_width = padded.width as usize;
// Build link header with status
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
("DISABLED", Color::Rgb(120, 60, 60))
} else if peers > 0 {
("CONNECTED", Color::Rgb(60, 120, 60))
} else {
("LISTENING", Color::Rgb(120, 120, 60))
};
let peer_text = if enabled && peers > 0 {
if peers == 1 {
" · 1 peer".to_string()
} else {
format!(" · {peers} peers")
}
} else {
String::new()
};
let link_header = Line::from(vec![
Span::styled(
"ABLETON LINK",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
status_text,
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
),
Span::styled(peer_text, Style::new().fg(LABEL_COLOR)),
]);
// Prepare values
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0); let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
let lines = vec![ let quantum_str = format!("{:.0}", link.quantum());
let tempo_str = format!("{:.1} BPM", link.tempo());
let beat_str = format!("{:.2}", link.beat());
let phase_str = format!("{:.2}", link.phase());
let tempo_style = Style::new()
.fg(Color::Rgb(220, 180, 100))
.add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(Color::Rgb(140, 145, 155));
// Build flat list of all lines
let lines: Vec<Line> = vec![
// DISPLAY section (lines 0-7)
render_section_header("DISPLAY"),
render_divider(content_width),
render_option_line( render_option_line(
"Refresh rate", "Refresh rate",
app.audio.config.refresh_rate.label(), app.audio.config.refresh_rate.label(),
@@ -94,68 +111,12 @@ fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
if app.ui.show_completion { "On" } else { "Off" }, if app.ui.show_completion { "On" } else { "Off" },
focus == OptionsFocus::ShowCompletion, focus == OptionsFocus::ShowCompletion,
), ),
render_option_line( render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness),
"Flash brightness", // Blank line (line 8)
&flash_str, Line::from(""),
focus == OptionsFocus::FlashBrightness, // ABLETON LINK section (lines 9-14)
), link_header,
]; render_divider(content_width),
frame.render_widget(Paragraph::new(lines), content_area);
}
fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
("DISABLED", Color::Rgb(120, 60, 60))
} else if peers > 0 {
("CONNECTED", Color::Rgb(60, 120, 60))
} else {
("LISTENING", Color::Rgb(120, 120, 60))
};
let peer_text = if enabled && peers > 0 {
if peers == 1 {
" · 1 peer".to_string()
} else {
format!(" · {peers} peers")
}
} else {
String::new()
};
let header = Line::from(vec![
Span::styled(
"ABLETON LINK",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
status_text,
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
),
Span::styled(peer_text, Style::new().fg(LABEL_COLOR)),
]);
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let focus = app.options.focus;
let quantum_str = format!("{:.0}", link.quantum());
let lines = vec![
render_option_line( render_option_line(
"Enabled", "Enabled",
if link.is_enabled() { "On" } else { "Off" }, if link.is_enabled() { "On" } else { "Off" },
@@ -171,47 +132,84 @@ fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rec
focus == OptionsFocus::StartStopSync, focus == OptionsFocus::StartStopSync,
), ),
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum), render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum),
]; // Blank line (line 15)
Line::from(""),
frame.render_widget(Paragraph::new(lines), content_area); // SESSION section (lines 16-21)
} render_section_header("SESSION"),
render_divider(content_width),
fn render_session_section(frame: &mut Frame, link: &LinkState, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let header = Line::from(Span::styled(
"SESSION",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let tempo_style = Style::new()
.fg(Color::Rgb(220, 180, 100))
.add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(Color::Rgb(140, 145, 155));
let tempo_str = format!("{:.1} BPM", link.tempo());
let beat_str = format!("{:.2}", link.beat());
let phase_str = format!("{:.2}", link.phase());
let lines = vec![
render_readonly_line("Tempo", &tempo_str, tempo_style), render_readonly_line("Tempo", &tempo_str, tempo_style),
render_readonly_line("Beat", &beat_str, value_style), render_readonly_line("Beat", &beat_str, value_style),
render_readonly_line("Phase", &phase_str, value_style), render_readonly_line("Phase", &phase_str, value_style),
]; ];
frame.render_widget(Paragraph::new(lines), content_area); let total_lines = lines.len();
let max_visible = padded.height as usize;
// Map focus to line index
let focus_line: usize = match focus {
OptionsFocus::RefreshRate => 2,
OptionsFocus::RuntimeHighlight => 3,
OptionsFocus::ShowScope => 4,
OptionsFocus::ShowSpectrum => 5,
OptionsFocus::ShowCompletion => 6,
OptionsFocus::FlashBrightness => 7,
OptionsFocus::LinkEnabled => 11,
OptionsFocus::StartStopSync => 12,
OptionsFocus::Quantum => 13,
};
// Calculate scroll offset to keep focused line visible (centered when possible)
let scroll_offset = if total_lines <= max_visible {
0
} else {
focus_line
.saturating_sub(max_visible / 2)
.min(total_lines.saturating_sub(max_visible))
};
// Render visible portion
let visible_end = (scroll_offset + max_visible).min(total_lines);
let visible_lines: Vec<Line> = lines
.into_iter()
.skip(scroll_offset)
.take(visible_end - scroll_offset)
.collect();
frame.render_widget(Paragraph::new(visible_lines), padded);
// Render scroll indicators
let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR);
let indicator_x = padded.x + padded.width.saturating_sub(1);
if scroll_offset > 0 {
let up_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
up_indicator,
Rect::new(indicator_x, padded.y, 1, 1),
);
}
if visible_end < total_lines {
let down_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
down_indicator,
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
);
}
}
fn render_section_header(title: &str) -> Line<'static> {
Line::from(Span::styled(
title.to_string(),
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
))
}
fn render_divider(width: usize) -> Line<'static> {
Line::from(Span::styled(
"".repeat(width),
Style::new().fg(DIVIDER_COLOR),
))
} }
fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> { fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {

View File

@@ -9,6 +9,8 @@ use crate::engine::SequencerSnapshot;
use crate::model::{MAX_BANKS, MAX_PATTERNS}; use crate::model::{MAX_BANKS, MAX_PATTERNS};
use crate::state::PatternsColumn; use crate::state::PatternsColumn;
const MIN_ROW_HEIGHT: u16 = 1;
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, gap, patterns_area] = Layout::horizontal([ let [banks_area, gap, patterns_area] = Layout::horizontal([
Constraint::Fill(1), Constraint::Fill(1),
@@ -55,16 +57,25 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
}) })
.collect(); .collect();
let row_height = (inner.height / MAX_BANKS as u16).max(1); let cursor = app.patterns_nav.bank_cursor;
let total_needed = row_height * MAX_BANKS as u16; let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
let top_padding = if inner.height > total_needed { let max_visible = max_visible.max(1);
(inner.height - total_needed) / 2
} else { let scroll_offset = if MAX_BANKS <= max_visible {
0 0
} else {
cursor
.saturating_sub(max_visible / 2)
.min(MAX_BANKS - max_visible)
}; };
for idx in 0..MAX_BANKS { let visible_count = MAX_BANKS.min(max_visible);
let y = inner.y + top_padding + (idx as u16) * row_height; let row_height = inner.height / visible_count as u16;
let row_height = row_height.max(MIN_ROW_HEIGHT);
for visible_idx in 0..visible_count {
let idx = scroll_offset + visible_idx;
let y = inner.y + (visible_idx as u16) * row_height;
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; break;
} }
@@ -126,6 +137,22 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
let para = Paragraph::new(label).style(style); let para = Paragraph::new(label).style(style);
frame.render_widget(para, text_area); frame.render_widget(para, text_area);
} }
// Scroll indicators
let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135));
if scroll_offset > 0 {
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { height: 1, ..inner });
}
if scroll_offset + visible_count < MAX_BANKS {
let y = inner.y + inner.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
}
} }
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
@@ -191,16 +218,25 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
None None
}; };
let row_height = (inner.height / MAX_PATTERNS as u16).max(1); let cursor = app.patterns_nav.pattern_cursor;
let total_needed = row_height * MAX_PATTERNS as u16; let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
let top_padding = if inner.height > total_needed { let max_visible = max_visible.max(1);
(inner.height - total_needed) / 2
} else { let scroll_offset = if MAX_PATTERNS <= max_visible {
0 0
} else {
cursor
.saturating_sub(max_visible / 2)
.min(MAX_PATTERNS - max_visible)
}; };
for idx in 0..MAX_PATTERNS { let visible_count = MAX_PATTERNS.min(max_visible);
let y = inner.y + top_padding + (idx as u16) * row_height; let row_height = inner.height / visible_count as u16;
let row_height = row_height.max(MIN_ROW_HEIGHT);
for visible_idx in 0..visible_count {
let idx = scroll_offset + visible_idx;
let y = inner.y + (visible_idx as u16) * row_height;
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; break;
} }
@@ -247,52 +283,56 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
row_area.y row_area.y
}; };
// Split row into columns: [index+name] [length] [speed] let text_area = Rect {
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, x: row_area.x,
y: text_y, y: text_y,
width: row_area.width, width: row_area.width,
height: 1, height: 1,
});
// Column 1: prefix + index + name (left-aligned)
let name_text = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
}; };
// Build the line: [prefix][idx] [name] ... [length] [speed]
let name_style = if is_playing || is_staged_play { let name_style = if is_playing || is_staged_play {
bold_style bold_style
} else { } else {
base_style base_style
}; };
frame.render_widget(Paragraph::new(name_text).style(name_style), name_area); let dim_style = base_style.remove_modifier(Modifier::BOLD);
// Column 2: length let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)];
let length_line = Line::from(vec![ if !name.is_empty() {
Span::styled("Length: ", bold_style), spans.push(Span::styled(format!(" {name}"), name_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);
} }
// Right-aligned info: length and speed
let speed_str = if speed != PatternSpeed::NORMAL {
format!(" {}", speed.label())
} else {
String::new()
};
let right_info = format!("{length}{speed_str}");
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let right_width = right_info.chars().count();
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(right_info, dim_style));
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
}
// Scroll indicators
let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135));
if scroll_offset > 0 {
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { height: 1, ..inner });
}
if scroll_offset + visible_count < MAX_PATTERNS {
let y = inner.y + inner.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
} }
} }

View File

@@ -430,12 +430,9 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("?", "Keys"), ("?", "Keys"),
], ],
Page::Patterns => vec![ Page::Patterns => vec![
("←→↑↓", "Navigate"),
("Enter", "Select"), ("Enter", "Select"),
("Space", "Play"), ("Space", "Play"),
("Esc", "Back"),
("r", "Rename"), ("r", "Rename"),
("Del", "Reset"),
("?", "Keys"), ("?", "Keys"),
], ],
Page::Engine => vec![ Page::Engine => vec![