WIP: better precision?

This commit is contained in:
2026-01-29 18:50:54 +01:00
parent 4d22bd5d2b
commit a72772c8cc
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-project = { path = "crates/project" }
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"
ratatui = "0.29"
crossterm = "0.28"

View File

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

View File

@@ -4,7 +4,7 @@ use crossbeam_channel::Receiver;
use doux::{Engine, EngineMetrics};
use ringbuf::{traits::*, HeapRb};
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::thread::{self, JoinHandle};
@@ -225,6 +225,7 @@ pub fn build_stream(
spectrum_buffer: Arc<SpectrumBuffer>,
metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sample::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>,
) -> Result<(Stream, f32, AnalysisHandle), String> {
let host = cpal::default_host();
@@ -270,8 +271,12 @@ pub fn build_stream(
while let Ok(cmd) = audio_rx.try_recv() {
match cmd {
AudioCommand::Evaluate(s) => {
engine.evaluate(&s);
AudioCommand::Evaluate { cmd, time } => {
let cmd_with_time = match time {
Some(t) => format!("{cmd}/time/{t:.6}"),
None => cmd,
};
engine.evaluate(&cmd_with_time);
}
AudioCommand::Hush => {
engine.hush();
@@ -287,6 +292,7 @@ pub fn build_stream(
engine =
Engine::new_with_metrics(sr, channels, max_voices, Arc::clone(&metrics_clone));
engine.sample_index = old_samples;
audio_sample_pos.store(0, Ordering::Relaxed);
}
}
}
@@ -295,6 +301,8 @@ pub fn build_stream(
engine.process_block(data, &[], &[]);
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)
for chunk in engine.output.chunks(channels) {
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 link::LinkState;
pub use sequencer::{
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot,
StepSnapshot,
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig,
SequencerSnapshot, StepSnapshot,
};

View File

@@ -1,7 +1,7 @@
use arc_swap::ArcSwap;
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
use std::collections::HashMap;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::{AtomicI64, AtomicU64};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
@@ -40,7 +40,7 @@ impl PatternChange {
}
pub enum AudioCommand {
Evaluate(String),
Evaluate { cmd: String, time: Option<f64> },
Hush,
Panic,
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)]
pub fn spawn_sequencer(
link: Arc<LinkState>,
@@ -209,6 +215,7 @@ pub fn spawn_sequencer(
quantum: f64,
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
config: SequencerConfig,
) -> (SequencerHandle, Receiver<AudioCommand>) {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
@@ -233,6 +240,9 @@ pub fn spawn_sequencer(
shared_state_clone,
live_keys,
nudge_us,
config.audio_sample_pos,
config.sample_rate,
config.lookahead_ms,
);
})
.expect("Failed to spawn sequencer thread");
@@ -359,10 +369,18 @@ pub(crate) struct TickInput {
pub quantum: f64,
pub fill: bool,
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 audio_commands: Vec<String>,
pub audio_commands: Vec<TimestampedCommand>,
pub new_tempo: Option<f64>,
pub shared_state: SharedSequencerState,
}
@@ -422,7 +440,7 @@ pub(crate) struct SequencerState {
variables: Variables,
speed_overrides: HashMap<(usize, usize), f64>,
key_cache: KeyCache,
buf_audio_commands: Vec<String>,
buf_audio_commands: Vec<TimestampedCommand>,
}
impl SequencerState {
@@ -516,7 +534,17 @@ impl SequencerState {
let stopped = self.deactivate_pending(beat, prev_beat, input.quantum);
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);
self.apply_chain_transitions(vars.chain_transitions);
@@ -591,6 +619,7 @@ impl SequencerState {
stopped
}
#[allow(clippy::too_many_arguments)]
fn execute_steps(
&mut self,
beat: f64,
@@ -599,6 +628,9 @@ impl SequencerState {
quantum: f64,
fill: bool,
nudge_secs: f64,
_current_time_us: i64,
engine_time: f64,
lookahead_secs: f64,
) -> StepResult {
self.buf_audio_commands.clear();
let mut result = StepResult {
@@ -670,9 +702,19 @@ impl SequencerState {
(active.bank, active.pattern, source_idx),
std::mem::take(&mut trace),
);
let event_time = if lookahead_secs > 0.0 {
Some(engine_time + lookahead_secs)
} else {
None
};
for cmd in cmds {
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>>,
live_keys: Arc<LiveKeyState>,
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;
@@ -807,10 +852,15 @@ fn sequencer_loop(
}
let state = link.capture_app_state();
let time = link.clock_micros();
let beat = state.beat_at_time(time, quantum);
let current_time_us = link.clock_micros();
let beat = state.beat_at_time(current_time_us, quantum);
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 {
commands,
playing: playing.load(Ordering::Relaxed),
@@ -819,15 +869,18 @@ fn sequencer_loop(
quantum,
fill: live_keys.fill(),
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);
for cmd in output.audio_commands {
match audio_tx.load().try_send(AudioCommand::Evaluate(cmd)) {
for tsc in output.audio_commands {
let cmd = AudioCommand::Evaluate { cmd: tsc.cmd, time: tsc.time };
match audio_tx.load().try_send(cmd) {
Ok(()) => {}
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
// Lags one tick in shared state: build_shared_state() already ran
seq_state.dropped_events += 1;
}
}
@@ -886,6 +939,9 @@ mod tests {
quantum: 4.0,
fill: false,
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,
fill: false,
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 crossbeam_channel::Sender;
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::time::{Duration, Instant};
@@ -28,6 +28,7 @@ pub struct InputContext<'a> {
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
pub seq_cmd_tx: &'a Sender<SeqCommand>,
pub nudge_us: &'a Arc<AtomicI64>,
pub lookahead_ms: &'a Arc<AtomicU32>,
}
impl<'a> InputContext<'a> {
@@ -697,7 +698,7 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let folder = &entry.folder;
let idx = entry.index;
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(),
}
@@ -1082,6 +1083,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.nudge_us
.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);
}
@@ -1101,6 +1107,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.nudge_us
.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);
}
@@ -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('t') => {
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(
"/sound/sine/dur/0.5/decay/0.2".into(),
));
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate {
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
time: None,
});
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));

View File

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

View File

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

View File

@@ -59,6 +59,7 @@ pub struct AudioConfig {
pub refresh_rate: RefreshRate,
pub show_scope: bool,
pub show_spectrum: bool,
pub lookahead_ms: u32,
}
impl Default for AudioConfig {
@@ -75,6 +76,7 @@ impl Default for AudioConfig {
refresh_rate: RefreshRate::default(),
show_scope: true,
show_spectrum: true,
lookahead_ms: 15,
}
}
}
@@ -140,6 +142,7 @@ pub enum SettingKind {
BufferSize,
Polyphony,
Nudge,
Lookahead,
}
impl SettingKind {
@@ -148,16 +151,18 @@ impl SettingKind {
Self::Channels => Self::BufferSize,
Self::BufferSize => Self::Polyphony,
Self::Polyphony => Self::Nudge,
Self::Nudge => Self::Channels,
Self::Nudge => Self::Lookahead,
Self::Lookahead => Self::Channels,
}
}
pub fn prev(self) -> Self {
match self {
Self::Channels => Self::Nudge,
Self::Channels => Self::Lookahead,
Self::BufferSize => Self::Channels,
Self::Polyphony => Self::BufferSize,
Self::Nudge => Self::Polyphony,
Self::Lookahead => Self::Nudge,
}
}
}
@@ -297,6 +302,11 @@ impl AudioSettings {
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) {
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 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) {
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),
};
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([
Constraint::Length(devices_height),
Constraint::Length(1),
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(6),
])
.areas(padded);
let max_visible = padded.height as usize;
render_devices(frame, app, devices_area);
render_settings(frame, app, settings_area);
render_samples(frame, app, samples_area);
// Calculate scroll offset based on focused section
let (focus_start, focus_height) = match app.audio.section {
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) {
@@ -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 polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
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_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")
};
let lookahead_label = if app.audio.config.lookahead_ms == 0 {
"off".to_string()
} else {
format!("{} ms", app.audio.config.lookahead_ms)
};
let rows = vec![
Row::new(vec![
Span::styled(
@@ -305,6 +398,17 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
),
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![
Span::styled(" Sample rate", label_style),
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::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
@@ -11,6 +11,7 @@ use crate::state::OptionsFocus;
const LABEL_COLOR: Color = Color::Rgb(120, 125, 135);
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
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) {
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),
};
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 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 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(
"Refresh rate",
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" },
focus == OptionsFocus::ShowCompletion,
),
render_option_line(
"Flash brightness",
&flash_str,
focus == OptionsFocus::FlashBrightness,
),
];
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("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness),
// Blank line (line 8)
Line::from(""),
// ABLETON LINK section (lines 9-14)
link_header,
render_divider(content_width),
render_option_line(
"Enabled",
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,
),
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum),
];
frame.render_widget(Paragraph::new(lines), content_area);
}
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![
// Blank line (line 15)
Line::from(""),
// SESSION section (lines 16-21)
render_section_header("SESSION"),
render_divider(content_width),
render_readonly_line("Tempo", &tempo_str, tempo_style),
render_readonly_line("Beat", &beat_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> {

View File

@@ -9,6 +9,8 @@ use crate::engine::SequencerSnapshot;
use crate::model::{MAX_BANKS, MAX_PATTERNS};
use crate::state::PatternsColumn;
const MIN_ROW_HEIGHT: u16 = 1;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let [banks_area, gap, patterns_area] = Layout::horizontal([
Constraint::Fill(1),
@@ -55,16 +57,25 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
})
.collect();
let row_height = (inner.height / MAX_BANKS as u16).max(1);
let total_needed = row_height * MAX_BANKS as u16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
let cursor = app.patterns_nav.bank_cursor;
let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
let max_visible = max_visible.max(1);
let scroll_offset = if MAX_BANKS <= max_visible {
0
} else {
cursor
.saturating_sub(max_visible / 2)
.min(MAX_BANKS - max_visible)
};
for idx in 0..MAX_BANKS {
let y = inner.y + top_padding + (idx as u16) * row_height;
let visible_count = MAX_BANKS.min(max_visible);
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 {
break;
}
@@ -126,6 +137,22 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
let para = Paragraph::new(label).style(style);
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) {
@@ -191,16 +218,25 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
None
};
let row_height = (inner.height / MAX_PATTERNS as u16).max(1);
let total_needed = row_height * MAX_PATTERNS as u16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
let cursor = app.patterns_nav.pattern_cursor;
let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
let max_visible = max_visible.max(1);
let scroll_offset = if MAX_PATTERNS <= max_visible {
0
} else {
cursor
.saturating_sub(max_visible / 2)
.min(MAX_PATTERNS - max_visible)
};
for idx in 0..MAX_PATTERNS {
let y = inner.y + top_padding + (idx as u16) * row_height;
let visible_count = MAX_PATTERNS.min(max_visible);
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 {
break;
}
@@ -247,52 +283,56 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
row_area.y
};
// Split row into columns: [index+name] [length] [speed]
let speed_width: u16 = 14; // "Speed: 1/4x "
let length_width: u16 = 13; // "Length: 16 "
let name_width = row_area
.width
.saturating_sub(speed_width + length_width + 2);
let [name_area, length_area, speed_area] = Layout::horizontal([
Constraint::Length(name_width),
Constraint::Length(length_width),
Constraint::Length(speed_width),
])
.areas(Rect {
let text_area = Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
});
// Column 1: prefix + index + name (left-aligned)
let name_text = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
// Build the line: [prefix][idx] [name] ... [length] [speed]
let name_style = if is_playing || is_staged_play {
bold_style
} else {
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 length_line = Line::from(vec![
Span::styled("Length: ", bold_style),
Span::styled(format!("{length}"), base_style),
]);
frame.render_widget(Paragraph::new(length_line), length_area);
// Column 3: speed (only if non-default)
if speed != PatternSpeed::NORMAL {
let speed_line = Line::from(vec![
Span::styled("Speed: ", bold_style),
Span::styled(speed.label(), base_style),
]);
frame.render_widget(Paragraph::new(speed_line), speed_area);
let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)];
if !name.is_empty() {
spans.push(Span::styled(format!(" {name}"), name_style));
}
// 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"),
],
Page::Patterns => vec![
("←→↑↓", "Navigate"),
("Enter", "Select"),
("Space", "Play"),
("Esc", "Back"),
("r", "Rename"),
("Del", "Reset"),
("?", "Keys"),
],
Page::Engine => vec![