Feat: background head-preload for sample libraries
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(®istry));
|
||||||
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, ®istry);
|
||||||
|
})
|
||||||
|
.expect("failed to spawn preload thread");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.app.audio.error = Some(e.clone());
|
self.app.audio.error = Some(e.clone());
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/init.rs
35
src/init.rs
@@ -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(®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))
|
(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
46
src/input.rs
46
src/input.rs
@@ -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, ®istry);
|
||||||
|
})
|
||||||
|
.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, ®istry);
|
||||||
|
})
|
||||||
|
.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"
|
||||||
)));
|
)));
|
||||||
|
|||||||
20
src/main.rs
20
src/main.rs
@@ -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(®istry));
|
||||||
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, ®istry);
|
||||||
|
})
|
||||||
|
.expect("failed to spawn preload thread");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.audio.error = Some(e.clone());
|
app.audio.error = Some(e.clone());
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user