From 07523a49e74f781adadf08b6d81bfd663e71e94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Thu, 5 Feb 2026 14:35:26 +0100 Subject: [PATCH] Feat: background head-preload for sample libraries --- CHANGELOG.md | 3 +++ crates/ratatui/src/editor.rs | 20 ++++++++++++++-- src/bin/desktop.rs | 20 ++++++++++++++-- src/engine/audio.rs | 13 ++++++++-- src/init.rs | 35 ++++++++++++++++++++++++++- src/input.rs | 46 +++++++++++++++++++++++++++--------- src/main.rs | 20 ++++++++++++++-- src/state/audio.rs | 2 ++ 8 files changed, 139 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bee867b..8d0b19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ All notable changes to this project will be documented in this file. ### Added - 3-operator FM synthesis words: `fm2` (operator 2 depth), `fm2h` (operator 2 harmonic ratio), `fmalgo` (algorithm: 0=cascade, 1=parallel, 2=branch), `fmfb` (feedback amount). Extends the existing 2-OP FM engine to a full 3-OP architecture with configurable routing and operator feedback. +### Fixed +- Code editor now scrolls vertically to keep the cursor visible. Previously, lines beyond the visible area were clipped and the cursor could move off-screen. + ## [0.0.6] - 2026-05-02 ### Added diff --git a/crates/ratatui/src/editor.rs b/crates/ratatui/src/editor.rs index acd2bae..76614c0 100644 --- a/crates/ratatui/src/editor.rs +++ b/crates/ratatui/src/editor.rs @@ -1,3 +1,5 @@ +use std::cell::Cell; + use crate::theme; use ratatui::{ layout::Rect, @@ -59,6 +61,7 @@ pub struct Editor { text: TextArea<'static>, completion: CompletionState, search: SearchState, + scroll_offset: Cell, } impl Editor { @@ -107,6 +110,7 @@ impl Editor { text: TextArea::default(), completion: CompletionState::new(), search: SearchState::new(), + scroll_offset: Cell::new(0), } } @@ -115,6 +119,7 @@ impl Editor { self.completion.active = false; self.search.query.clear(); self.search.active = false; + self.scroll_offset.set(0); } pub fn set_candidates(&mut self, candidates: Vec) { @@ -376,10 +381,21 @@ impl Editor { }) .collect(); - frame.render_widget(Paragraph::new(lines), area); + let viewport_height = area.height as usize; + let offset = self.scroll_offset.get() as usize; + let offset = if cursor_row < offset { + cursor_row + } else if cursor_row >= offset + viewport_height { + cursor_row - viewport_height + 1 + } else { + offset + }; + self.scroll_offset.set(offset as u16); + + frame.render_widget(Paragraph::new(lines).scroll((offset as u16, 0)), area); if self.completion.active && !self.completion.matches.is_empty() { - self.render_completion(frame, area, cursor_row); + self.render_completion(frame, area, cursor_row - offset); } } diff --git a/src/bin/desktop.rs b/src/bin/desktop.rs index fddb098..8a38d44 100644 --- a/src/bin/desktop.rs +++ b/src/bin/desktop.rs @@ -15,11 +15,11 @@ use soft_ratatui::embedded_graphics_unicodefonts::{ }; use soft_ratatui::{EmbeddedGraphics, SoftBackend}; -use cagire::init::{init, InitArgs}; use cagire::engine::{ build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer, SequencerHandle, SpectrumBuffer, }; +use cagire::init::{init, InitArgs}; use cagire::input::{handle_key, InputContext, InputResult}; use cagire::input_egui::convert_egui_events; use cagire::settings::Settings; @@ -218,6 +218,11 @@ impl CagireDesktop { self.audio_sample_pos.store(0, Ordering::Release); + let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples + .iter() + .map(|e| (e.name.clone(), e.path.clone())) + .collect(); + match build_stream( &new_config, new_audio_rx, @@ -227,7 +232,7 @@ impl CagireDesktop { restart_samples, Arc::clone(&self.audio_sample_pos), ) { - Ok((new_stream, info, new_analysis)) => { + Ok((new_stream, info, new_analysis, registry)) => { self._stream = Some(new_stream); self._analysis_handle = Some(new_analysis); self.app.audio.config.sample_rate = info.sample_rate; @@ -236,7 +241,18 @@ impl CagireDesktop { self.sample_rate_shared .store(info.sample_rate as u32, Ordering::Relaxed); self.app.audio.error = None; + self.app.audio.sample_registry = Some(std::sync::Arc::clone(®istry)); self.app.ui.set_status("Audio restarted".to_string()); + + if !preload_entries.is_empty() { + let sr = info.sample_rate; + std::thread::Builder::new() + .name("sample-preload".into()) + .spawn(move || { + cagire::init::preload_sample_heads(preload_entries, sr, ®istry); + }) + .expect("failed to spawn preload thread"); + } } Err(e) => { self.app.audio.error = Some(e.clone()); diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 40e89bb..ff3e2c4 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -251,7 +251,15 @@ pub fn build_stream( metrics: Arc, initial_samples: Vec, audio_sample_pos: Arc, -) -> Result<(Stream, AudioStreamInfo, AnalysisHandle), String> { +) -> Result< + ( + Stream, + AudioStreamInfo, + AnalysisHandle, + Arc, + ), + String, +> { let device = match &config.output_device { Some(name) => doux::audio::find_output_device(name) .ok_or_else(|| format!("Device not found: {name}"))?, @@ -287,6 +295,7 @@ pub fn build_stream( let mut engine = Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics)); engine.sample_index = initial_samples; + let registry = Arc::clone(&engine.sample_registry); let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer); @@ -356,5 +365,5 @@ pub fn build_stream( host_name, channels: effective_channels, }; - Ok((stream, info, analysis_handle)) + Ok((stream, info, analysis_handle, registry)) } diff --git a/src/init.rs b/src/init.rs index 96653e6..bf6bbda 100644 --- a/src/init.rs +++ b/src/init.rs @@ -121,6 +121,10 @@ pub fn init(args: InitArgs) -> Init { app.audio.config.sample_count += index.len(); initial_samples.extend(index); } + let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples + .iter() + .map(|e| (e.name.clone(), e.path.clone())) + .collect(); #[cfg(feature = "desktop")] let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits())); @@ -168,11 +172,23 @@ pub fn init(args: InitArgs) -> Init { initial_samples, Arc::clone(&audio_sample_pos), ) { - Ok((s, info, analysis)) => { + Ok((s, info, analysis, registry)) => { app.audio.config.sample_rate = info.sample_rate; app.audio.config.host_name = info.host_name; app.audio.config.channels = info.channels; sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed); + app.audio.sample_registry = Some(Arc::clone(®istry)); + + if !preload_entries.is_empty() { + let sr = info.sample_rate; + std::thread::Builder::new() + .name("sample-preload".into()) + .spawn(move || { + preload_sample_heads(preload_entries, sr, ®istry); + }) + .expect("failed to spawn preload thread"); + } + (Some(s), Some(analysis)) } Err(e) => { @@ -208,3 +224,20 @@ pub fn init(args: InitArgs) -> Init { mouse_down, } } + +pub fn preload_sample_heads( + entries: Vec<(String, std::path::PathBuf)>, + target_sr: f32, + registry: &doux::SampleRegistry, +) { + let mut batch = Vec::with_capacity(entries.len()); + for (name, path) in &entries { + match doux::sampling::decode_sample_head(path, target_sr) { + Ok(data) => batch.push((name.clone(), Arc::new(data))), + Err(e) => eprintln!("preload {name}: {e}"), + } + } + if !batch.is_empty() { + registry.insert_batch(batch); + } +} diff --git a/src/input.rs b/src/input.rs index 4455111..a520375 100644 --- a/src/input.rs +++ b/src/input.rs @@ -445,9 +445,22 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { if let Some(path) = sample_path { let index = doux::sampling::scan_samples_dir(&path); let count = index.len(); + let preload_entries: Vec<(String, std::path::PathBuf)> = index + .iter() + .map(|e| (e.name.clone(), e.path.clone())) + .collect(); let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); ctx.app.audio.config.sample_count += count; ctx.app.audio.add_sample_path(path); + if let Some(registry) = ctx.app.audio.sample_registry.clone() { + let sr = ctx.app.audio.config.sample_rate; + std::thread::Builder::new() + .name("sample-preload".into()) + .spawn(move || { + crate::init::preload_sample_heads(preload_entries, sr, ®istry); + }) + .expect("failed to spawn preload thread"); + } ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); ctx.dispatch(AppCommand::CloseModal); } @@ -502,18 +515,16 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { ctx.dispatch(AppCommand::CloseModal); } } - KeyCode::Char('e') if ctrl => { - match ctx.app.editor_ctx.target { - EditorTarget::Step => { - ctx.dispatch(AppCommand::SaveEditorToStep); - ctx.dispatch(AppCommand::CompileCurrentStep); - } - EditorTarget::Prelude => { - ctx.dispatch(AppCommand::SavePrelude); - ctx.dispatch(AppCommand::EvaluatePrelude); - } + KeyCode::Char('e') if ctrl => match ctx.app.editor_ctx.target { + EditorTarget::Step => { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); } - } + EditorTarget::Prelude => { + ctx.dispatch(AppCommand::SavePrelude); + ctx.dispatch(AppCommand::EvaluatePrelude); + } + }, KeyCode::Char('f') if ctrl => { editor.activate_search(); } @@ -1705,11 +1716,15 @@ fn load_project_samples(ctx: &mut InputContext) { } let mut total_count = 0; + let mut all_preload_entries = Vec::new(); for path in &paths { if path.is_dir() { let index = doux::sampling::scan_samples_dir(path); let count = index.len(); total_count += count; + for e in &index { + all_preload_entries.push((e.name.clone(), e.path.clone())); + } let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); } } @@ -1718,6 +1733,15 @@ fn load_project_samples(ctx: &mut InputContext) { ctx.app.audio.config.sample_count = total_count; if total_count > 0 { + if let Some(registry) = ctx.app.audio.sample_registry.clone() { + let sr = ctx.app.audio.config.sample_rate; + std::thread::Builder::new() + .name("sample-preload".into()) + .spawn(move || { + crate::init::preload_sample_heads(all_preload_entries, sr, ®istry); + }) + .expect("failed to spawn preload thread"); + } ctx.dispatch(AppCommand::SetStatus(format!( "Loaded {total_count} samples from project" ))); diff --git a/src/main.rs b/src/main.rs index 0611a44..e9d824d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ mod app; -mod init; mod commands; mod engine; +mod init; mod input; mod midi; mod model; @@ -118,6 +118,11 @@ fn main() -> io::Result<()> { audio_sample_pos.store(0, Ordering::Relaxed); + let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples + .iter() + .map(|e| (e.name.clone(), e.path.clone())) + .collect(); + match build_stream( &new_config, new_audio_rx, @@ -127,7 +132,7 @@ fn main() -> io::Result<()> { restart_samples, Arc::clone(&audio_sample_pos), ) { - Ok((new_stream, info, new_analysis)) => { + Ok((new_stream, info, new_analysis, registry)) => { _stream = Some(new_stream); _analysis_handle = Some(new_analysis); app.audio.config.sample_rate = info.sample_rate; @@ -135,7 +140,18 @@ fn main() -> io::Result<()> { app.audio.config.channels = info.channels; sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed); app.audio.error = None; + app.audio.sample_registry = Some(Arc::clone(®istry)); app.ui.set_status("Audio restarted".to_string()); + + if !preload_entries.is_empty() { + let sr = info.sample_rate; + std::thread::Builder::new() + .name("sample-preload".into()) + .spawn(move || { + init::preload_sample_heads(preload_entries, sr, ®istry); + }) + .expect("failed to spawn preload thread"); + } } Err(e) => { app.audio.error = Some(e.clone()); diff --git a/src/state/audio.rs b/src/state/audio.rs index 877d6f2..a3c1fe3 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -223,6 +223,7 @@ pub struct AudioSettings { pub input_list: ListSelectState, pub restart_pending: bool, pub error: Option, + pub sample_registry: Option>, } impl Default for AudioSettings { @@ -244,6 +245,7 @@ impl Default for AudioSettings { }, restart_pending: false, error: None, + sample_registry: None, } } }