Feat: background head-preload for sample libraries

This commit is contained in:
2026-02-05 14:35:26 +01:00
parent fb751c8691
commit 07523a49e7
8 changed files with 139 additions and 20 deletions

View File

@@ -7,6 +7,9 @@ All notable changes to this project will be documented in this file.
### Added ### 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. - 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 ## [0.0.6] - 2026-05-02
### Added ### Added

View File

@@ -1,3 +1,5 @@
use std::cell::Cell;
use crate::theme; use crate::theme;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
@@ -59,6 +61,7 @@ pub struct Editor {
text: TextArea<'static>, text: TextArea<'static>,
completion: CompletionState, completion: CompletionState,
search: SearchState, search: SearchState,
scroll_offset: Cell<u16>,
} }
impl Editor { impl Editor {
@@ -107,6 +110,7 @@ impl Editor {
text: TextArea::default(), text: TextArea::default(),
completion: CompletionState::new(), completion: CompletionState::new(),
search: SearchState::new(), search: SearchState::new(),
scroll_offset: Cell::new(0),
} }
} }
@@ -115,6 +119,7 @@ impl Editor {
self.completion.active = false; self.completion.active = false;
self.search.query.clear(); self.search.query.clear();
self.search.active = false; self.search.active = false;
self.scroll_offset.set(0);
} }
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) { pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
@@ -376,10 +381,21 @@ impl Editor {
}) })
.collect(); .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() { if self.completion.active && !self.completion.matches.is_empty() {
self.render_completion(frame, area, cursor_row); self.render_completion(frame, area, cursor_row - offset);
} }
} }

View File

@@ -15,11 +15,11 @@ use soft_ratatui::embedded_graphics_unicodefonts::{
}; };
use soft_ratatui::{EmbeddedGraphics, SoftBackend}; use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use cagire::init::{init, InitArgs};
use cagire::engine::{ use cagire::engine::{
build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer, build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer,
SequencerHandle, SpectrumBuffer, SequencerHandle, SpectrumBuffer,
}; };
use cagire::init::{init, InitArgs};
use cagire::input::{handle_key, InputContext, InputResult}; use cagire::input::{handle_key, InputContext, InputResult};
use cagire::input_egui::convert_egui_events; use cagire::input_egui::convert_egui_events;
use cagire::settings::Settings; use cagire::settings::Settings;
@@ -218,6 +218,11 @@ impl CagireDesktop {
self.audio_sample_pos.store(0, Ordering::Release); 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( match build_stream(
&new_config, &new_config,
new_audio_rx, new_audio_rx,
@@ -227,7 +232,7 @@ impl CagireDesktop {
restart_samples, restart_samples,
Arc::clone(&self.audio_sample_pos), 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._stream = Some(new_stream);
self._analysis_handle = Some(new_analysis); self._analysis_handle = Some(new_analysis);
self.app.audio.config.sample_rate = info.sample_rate; self.app.audio.config.sample_rate = info.sample_rate;
@@ -236,7 +241,18 @@ impl CagireDesktop {
self.sample_rate_shared self.sample_rate_shared
.store(info.sample_rate as u32, Ordering::Relaxed); .store(info.sample_rate as u32, Ordering::Relaxed);
self.app.audio.error = None; self.app.audio.error = None;
self.app.audio.sample_registry = Some(std::sync::Arc::clone(&registry));
self.app.ui.set_status("Audio restarted".to_string()); 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, &registry);
})
.expect("failed to spawn preload thread");
}
} }
Err(e) => { Err(e) => {
self.app.audio.error = Some(e.clone()); self.app.audio.error = Some(e.clone());

View File

@@ -251,7 +251,15 @@ pub fn build_stream(
metrics: Arc<EngineMetrics>, metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sampling::SampleEntry>, initial_samples: Vec<doux::sampling::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>, audio_sample_pos: Arc<AtomicU64>,
) -> Result<(Stream, AudioStreamInfo, AnalysisHandle), String> { ) -> Result<
(
Stream,
AudioStreamInfo,
AnalysisHandle,
Arc<doux::SampleRegistry>,
),
String,
> {
let device = match &config.output_device { let device = match &config.output_device {
Some(name) => doux::audio::find_output_device(name) Some(name) => doux::audio::find_output_device(name)
.ok_or_else(|| format!("Device not found: {name}"))?, .ok_or_else(|| format!("Device not found: {name}"))?,
@@ -287,6 +295,7 @@ pub fn build_stream(
let mut engine = let mut engine =
Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics)); Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics));
engine.sample_index = initial_samples; 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); let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer);
@@ -356,5 +365,5 @@ pub fn build_stream(
host_name, host_name,
channels: effective_channels, channels: effective_channels,
}; };
Ok((stream, info, analysis_handle)) Ok((stream, info, analysis_handle, registry))
} }

View File

@@ -121,6 +121,10 @@ pub fn init(args: InitArgs) -> Init {
app.audio.config.sample_count += index.len(); app.audio.config.sample_count += index.len();
initial_samples.extend(index); 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")] #[cfg(feature = "desktop")]
let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits())); let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
@@ -168,11 +172,23 @@ pub fn init(args: InitArgs) -> Init {
initial_samples, initial_samples,
Arc::clone(&audio_sample_pos), 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.sample_rate = info.sample_rate;
app.audio.config.host_name = info.host_name; app.audio.config.host_name = info.host_name;
app.audio.config.channels = info.channels; app.audio.config.channels = info.channels;
sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed); sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.sample_registry = Some(Arc::clone(&registry));
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, &registry);
})
.expect("failed to spawn preload thread");
}
(Some(s), Some(analysis)) (Some(s), Some(analysis))
} }
Err(e) => { Err(e) => {
@@ -208,3 +224,20 @@ pub fn init(args: InitArgs) -> Init {
mouse_down, 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);
}
}

View File

@@ -445,9 +445,22 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
if let Some(path) = sample_path { if let Some(path) = sample_path {
let index = doux::sampling::scan_samples_dir(&path); let index = doux::sampling::scan_samples_dir(&path);
let count = index.len(); 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)); let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index));
ctx.app.audio.config.sample_count += count; ctx.app.audio.config.sample_count += count;
ctx.app.audio.add_sample_path(path); 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, &registry);
})
.expect("failed to spawn preload thread");
}
ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples")));
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
} }
@@ -502,18 +515,16 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
} }
} }
KeyCode::Char('e') if ctrl => { KeyCode::Char('e') if ctrl => match ctx.app.editor_ctx.target {
match ctx.app.editor_ctx.target { EditorTarget::Step => {
EditorTarget::Step => { ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::SaveEditorToStep); ctx.dispatch(AppCommand::CompileCurrentStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
}
EditorTarget::Prelude => {
ctx.dispatch(AppCommand::SavePrelude);
ctx.dispatch(AppCommand::EvaluatePrelude);
}
} }
} EditorTarget::Prelude => {
ctx.dispatch(AppCommand::SavePrelude);
ctx.dispatch(AppCommand::EvaluatePrelude);
}
},
KeyCode::Char('f') if ctrl => { KeyCode::Char('f') if ctrl => {
editor.activate_search(); editor.activate_search();
} }
@@ -1705,11 +1716,15 @@ fn load_project_samples(ctx: &mut InputContext) {
} }
let mut total_count = 0; let mut total_count = 0;
let mut all_preload_entries = Vec::new();
for path in &paths { for path in &paths {
if path.is_dir() { if path.is_dir() {
let index = doux::sampling::scan_samples_dir(path); let index = doux::sampling::scan_samples_dir(path);
let count = index.len(); let count = index.len();
total_count += count; 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)); 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; ctx.app.audio.config.sample_count = total_count;
if total_count > 0 { 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, &registry);
})
.expect("failed to spawn preload thread");
}
ctx.dispatch(AppCommand::SetStatus(format!( ctx.dispatch(AppCommand::SetStatus(format!(
"Loaded {total_count} samples from project" "Loaded {total_count} samples from project"
))); )));

View File

@@ -1,7 +1,7 @@
mod app; mod app;
mod init;
mod commands; mod commands;
mod engine; mod engine;
mod init;
mod input; mod input;
mod midi; mod midi;
mod model; mod model;
@@ -118,6 +118,11 @@ fn main() -> io::Result<()> {
audio_sample_pos.store(0, Ordering::Relaxed); 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( match build_stream(
&new_config, &new_config,
new_audio_rx, new_audio_rx,
@@ -127,7 +132,7 @@ fn main() -> io::Result<()> {
restart_samples, restart_samples,
Arc::clone(&audio_sample_pos), Arc::clone(&audio_sample_pos),
) { ) {
Ok((new_stream, info, new_analysis)) => { Ok((new_stream, info, new_analysis, registry)) => {
_stream = Some(new_stream); _stream = Some(new_stream);
_analysis_handle = Some(new_analysis); _analysis_handle = Some(new_analysis);
app.audio.config.sample_rate = info.sample_rate; app.audio.config.sample_rate = info.sample_rate;
@@ -135,7 +140,18 @@ fn main() -> io::Result<()> {
app.audio.config.channels = info.channels; app.audio.config.channels = info.channels;
sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed); sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.error = None; app.audio.error = None;
app.audio.sample_registry = Some(Arc::clone(&registry));
app.ui.set_status("Audio restarted".to_string()); 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, &registry);
})
.expect("failed to spawn preload thread");
}
} }
Err(e) => { Err(e) => {
app.audio.error = Some(e.clone()); app.audio.error = Some(e.clone());

View File

@@ -223,6 +223,7 @@ pub struct AudioSettings {
pub input_list: ListSelectState, pub input_list: ListSelectState,
pub restart_pending: bool, pub restart_pending: bool,
pub error: Option<String>, pub error: Option<String>,
pub sample_registry: Option<std::sync::Arc<doux::SampleRegistry>>,
} }
impl Default for AudioSettings { impl Default for AudioSettings {
@@ -244,6 +245,7 @@ impl Default for AudioSettings {
}, },
restart_pending: false, restart_pending: false,
error: None, error: None,
sample_registry: None,
} }
} }
} }