diff --git a/Cargo.lock b/Cargo.lock index 44bce5a..976b53f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1825,7 +1825,7 @@ dependencies = [ [[package]] name = "doux" version = "0.0.15" -source = "git+https://github.com/sova-org/doux?tag=v0.0.15#1f11f795b877d9c15f65d88eb5576e8149092b17" +source = "git+https://github.com/sova-org/doux?tag=v0.0.15#29d8f055612f6141d7546d72b91e60026937b0fd" dependencies = [ "arc-swap", "clap", diff --git a/plugins/cagire-plugins/src/lib.rs b/plugins/cagire-plugins/src/lib.rs index 2cbb676..0cd642d 100644 --- a/plugins/cagire-plugins/src/lib.rs +++ b/plugins/cagire-plugins/src/lib.rs @@ -292,7 +292,7 @@ impl Plugin for CagirePlugin { fill: false, nudge_secs: 0.0, current_time_us: 0, - audio_sample_pos: self.sample_pos, + corrected_audio_pos: self.sample_pos as f64, sr: self.sample_rate as f64, mouse_x: 0.5, mouse_y: 0.5, diff --git a/src/bin/desktop/main.rs b/src/bin/desktop/main.rs index e2509eb..7741609 100644 --- a/src/bin/desktop/main.rs +++ b/src/bin/desktop/main.rs @@ -1,15 +1,16 @@ #![windows_subsystem = "windows"] -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; use std::sync::Arc; use std::time::Duration; +use arc_swap::ArcSwap; use clap::Parser; use doux::EngineMetrics; use eframe::NativeOptions; use cagire::engine::{ - build_stream, AnalysisHandle, AudioStreamConfig, LinkState, ScopeBuffer, + build_stream, AnalysisHandle, AudioRef, AudioStreamConfig, LinkState, ScopeBuffer, SequencerHandle, SpectrumBuffer, }; use cagire::terminal::{create_terminal, FontChoice, TerminalType}; @@ -48,7 +49,7 @@ struct CagireDesktop { metrics: Arc, scope_buffer: Arc, spectrum_buffer: Arc, - audio_sample_pos: Arc, + audio_ref: Arc>, sample_rate_shared: Arc, _stream: Option, _input_stream: Option, @@ -94,7 +95,7 @@ impl CagireDesktop { metrics: b.metrics, scope_buffer: b.scope_buffer, spectrum_buffer: b.spectrum_buffer, - audio_sample_pos: b.audio_sample_pos, + audio_ref: b.audio_ref, sample_rate_shared: b.sample_rate_shared, _stream: b.stream, _input_stream: b.input_stream, @@ -152,7 +153,11 @@ impl CagireDesktop { } } - self.audio_sample_pos.store(0, Ordering::Release); + self.audio_ref.store(Arc::new(AudioRef { + sample_pos: 0, + timestamp: std::time::Instant::now(), + sample_rate: 44100.0, + })); let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples .iter() @@ -166,7 +171,7 @@ impl CagireDesktop { Arc::clone(&self.spectrum_buffer), Arc::clone(&self.metrics), restart_samples, - Arc::clone(&self.audio_sample_pos), + Arc::clone(&self.audio_ref), new_error_tx, &self.app.audio.config.sample_paths, Arc::clone(&self.device_lost), diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 0abb3ac..7b99bbf 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -1,13 +1,22 @@ //! Audio output stream (cpal) and FFT spectrum analysis. +use arc_swap::ArcSwap; use ringbuf::{traits::*, HeapRb}; use rustfft::{num_complex::Complex, FftPlanner}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; +use std::time::Instant; -#[cfg(feature = "cli")] -use std::sync::atomic::AtomicU64; +/// Timestamped audio position reference for jitter-free tick interpolation. +/// Published by the audio callback after each `process_block`, read by the +/// sequencer to compute the correct sample position at any instant. +#[derive(Clone)] +pub struct AudioRef { + pub sample_pos: u64, + pub timestamp: Instant, + pub sample_rate: f64, +} pub struct ScopeBuffer { pub samples: [AtomicU32; 256], @@ -303,7 +312,7 @@ pub fn build_stream( spectrum_buffer: Arc, metrics: Arc, initial_samples: Vec, - audio_sample_pos: Arc, + audio_ref: Arc>, error_tx: Sender, sample_paths: &[std::path::PathBuf], device_lost: Arc, @@ -438,6 +447,7 @@ pub fn build_stream( let mut rt_set = false; let mut live_scratch = vec![0.0f32; 4096]; let mut input_consumer = input_consumer; + let mut current_pos: u64 = 0; let stream = device .build_output_stream( @@ -454,8 +464,6 @@ pub fn build_stream( let buffer_samples = data.len() / channels; let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64; - audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Release); - while let Ok(cmd) = audio_rx.try_recv() { match cmd { AudioCommand::Evaluate { cmd, tick } => { @@ -497,6 +505,16 @@ pub fn build_stream( engine.metrics.load.set_buffer_time(buffer_time_ns); engine.process_block(data, &[], &live_scratch[..raw_len]); + + // Publish accurate audio reference AFTER process_block + // so sample_pos matches doux's internal tick exactly. + current_pos += buffer_samples as u64; + audio_ref.store(Arc::new(AudioRef { + sample_pos: current_pos, + timestamp: Instant::now(), + sample_rate: sr as f64, + })); + scope_buffer.write(data); // Feed mono mix to analysis thread via ring buffer (non-blocking) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 62850d2..97910cb 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -7,7 +7,7 @@ mod timing; pub use timing::{next_boundary, substeps_in_window, SyncTime}; -pub use audio::{preload_sample_heads, AnalysisHandle, ScopeBuffer, SpectrumBuffer}; +pub use audio::{preload_sample_heads, AnalysisHandle, AudioRef, ScopeBuffer, SpectrumBuffer}; // Re-exported for the plugin crate (not used by the terminal binary). #[allow(unused_imports)] diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 3a4a3b9..b72f8bd 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -8,10 +8,12 @@ use rand::SeedableRng; use std::collections::HashMap; #[cfg(feature = "desktop")] use std::sync::atomic::AtomicU32; -use std::sync::atomic::{AtomicI64, AtomicU64}; +use std::sync::atomic::AtomicI64; use std::sync::Arc; use std::thread::{self, JoinHandle}; +use super::audio::AudioRef; + use super::dispatcher::{dispatcher_loop, MidiDispatch, TimedMidiCommand}; use super::realtime::set_realtime_priority; use super::{next_boundary, substeps_in_window, LinkState, SyncTime}; @@ -329,7 +331,7 @@ impl AudioState { } pub struct SequencerConfig { - pub audio_sample_pos: Arc, + pub audio_ref: Arc>, pub sample_rate: Arc, pub cc_access: Option>, pub variables: Variables, @@ -393,7 +395,7 @@ pub fn spawn_sequencer( shared_state_clone, live_keys, nudge_us, - config.audio_sample_pos, + config.audio_ref, config.sample_rate, config.cc_access, variables, @@ -502,7 +504,7 @@ pub struct TickInput { pub fill: bool, pub nudge_secs: f64, pub current_time_us: SyncTime, - pub audio_sample_pos: u64, + pub corrected_audio_pos: f64, pub sr: f64, pub mouse_x: f64, pub mouse_y: f64, @@ -765,7 +767,7 @@ impl SequencerState { input.fill, input.nudge_secs, input.current_time_us, - input.audio_sample_pos, + input.corrected_audio_pos, input.sr, input.mouse_x, input.mouse_y, @@ -780,7 +782,7 @@ impl SequencerState { input.quantum, input.fill, input.nudge_secs, - input.audio_sample_pos, + input.corrected_audio_pos, input.sr, input.mouse_x, input.mouse_y, @@ -934,7 +936,7 @@ impl SequencerState { fill: bool, nudge_secs: f64, _current_time_us: SyncTime, - audio_sample_pos: u64, + corrected_audio_pos: f64, sr: f64, mouse_x: f64, mouse_y: f64, @@ -983,7 +985,7 @@ impl SequencerState { } else { 0.0 }; - let event_tick = Some(audio_sample_pos + (time_delta * sr).round() as u64); + let event_tick = Some((corrected_audio_pos + time_delta * sr).round() as u64); if let Some(step) = pattern.steps.get(step_idx) { let resolved_script = pattern.resolve_script(step_idx); @@ -1095,7 +1097,7 @@ impl SequencerState { quantum: f64, fill: bool, nudge_secs: f64, - audio_sample_pos: u64, + corrected_audio_pos: f64, sr: f64, mouse_x: f64, mouse_y: f64, @@ -1117,7 +1119,7 @@ impl SequencerState { } else { 0.0 }; - let event_tick = Some(audio_sample_pos + (time_delta * sr).round() as u64); + let event_tick = Some((corrected_audio_pos + time_delta * sr).round() as u64); let step_in_cycle = self.script_step % self.script_length; @@ -1263,7 +1265,7 @@ fn sequencer_loop( shared_state: Arc>, live_keys: Arc, nudge_us: Arc, - audio_sample_pos: Arc, + audio_ref: Arc>, sample_rate: Arc, cc_access: Option>, variables: Variables, @@ -1330,7 +1332,9 @@ fn sequencer_loop( let lookahead_end = beat + lookahead_beats; let sr = sample_rate.load(Ordering::Relaxed) as f64; - let audio_samples = audio_sample_pos.load(Ordering::Acquire); + let ref_snapshot = audio_ref.load(); + let elapsed_secs = ref_snapshot.timestamp.elapsed().as_secs_f64(); + let corrected_pos = ref_snapshot.sample_pos as f64 + elapsed_secs * ref_snapshot.sample_rate; let input = TickInput { commands, playing: is_playing, @@ -1341,7 +1345,7 @@ fn sequencer_loop( fill: live_keys.fill(), nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0, current_time_us, - audio_sample_pos: audio_samples, + corrected_audio_pos: corrected_pos, sr, #[cfg(feature = "desktop")] mouse_x: f32::from_bits(mouse_x.load(Ordering::Relaxed)) as f64, @@ -1563,7 +1567,7 @@ mod tests { fill: false, nudge_secs: 0.0, current_time_us: 0, - audio_sample_pos: 0, + corrected_audio_pos: 0.0, sr: 48000.0, mouse_x: 0.5, mouse_y: 0.5, @@ -1582,7 +1586,7 @@ mod tests { fill: false, nudge_secs: 0.0, current_time_us: 0, - audio_sample_pos: 0, + corrected_audio_pos: 0.0, sr: 48000.0, mouse_x: 0.5, mouse_y: 0.5, diff --git a/src/init.rs b/src/init.rs index 78fa2ba..24a02d7 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,14 +1,16 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; use std::sync::Arc; +use std::time::Instant; +use arc_swap::ArcSwap; use doux::EngineMetrics; use crate::app::App; use crate::engine::{ - build_stream, preload_sample_heads, spawn_sequencer, AnalysisHandle, AudioStreamConfig, - LinkState, PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle, - SpectrumBuffer, + build_stream, preload_sample_heads, spawn_sequencer, AnalysisHandle, AudioRef, + AudioStreamConfig, LinkState, PatternChange, ScopeBuffer, SequencerConfig, + SequencerHandle, SpectrumBuffer, }; use crate::midi; use crate::model; @@ -36,7 +38,7 @@ pub struct Init { pub metrics: Arc, pub scope_buffer: Arc, pub spectrum_buffer: Arc, - pub audio_sample_pos: Arc, + pub audio_ref: Arc>, pub sample_rate_shared: Arc, pub stream: Option, pub input_stream: Option, @@ -151,7 +153,11 @@ pub fn init(args: InitArgs) -> Init { let scope_buffer = Arc::new(ScopeBuffer::new()); let spectrum_buffer = Arc::new(SpectrumBuffer::new()); - let audio_sample_pos = Arc::new(AtomicU64::new(0)); + let audio_ref = Arc::new(ArcSwap::from_pointee(AudioRef { + sample_pos: 0, + timestamp: Instant::now(), + sample_rate: 44100.0, + })); let sample_rate_shared = Arc::new(AtomicU32::new(44100)); let mut initial_samples = Vec::new(); for path in &app.audio.config.sample_paths { @@ -177,7 +183,7 @@ pub fn init(args: InitArgs) -> Init { let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits())); let seq_config = SequencerConfig { - audio_sample_pos: Arc::clone(&audio_sample_pos), + audio_ref: Arc::clone(&audio_ref), sample_rate: Arc::clone(&sample_rate_shared), cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc), variables: Arc::clone(&app.variables), @@ -218,7 +224,7 @@ pub fn init(args: InitArgs) -> Init { Arc::clone(&spectrum_buffer), Arc::clone(&metrics), initial_samples, - Arc::clone(&audio_sample_pos), + Arc::clone(&audio_ref), stream_error_tx, &app.audio.config.sample_paths, Arc::clone(&device_lost), @@ -262,7 +268,7 @@ pub fn init(args: InitArgs) -> Init { metrics, scope_buffer, spectrum_buffer, - audio_sample_pos, + audio_ref, sample_rate_shared, stream, input_stream, diff --git a/src/main.rs b/src/main.rs index 8397b96..3a81b4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,7 +96,7 @@ fn main() -> io::Result<()> { let metrics = b.metrics; let scope_buffer = b.scope_buffer; let spectrum_buffer = b.spectrum_buffer; - let audio_sample_pos = b.audio_sample_pos; + let audio_ref = b.audio_ref; let sample_rate_shared = b.sample_rate_shared; let mut _stream = b.stream; let mut _input_stream = b.input_stream; @@ -148,7 +148,11 @@ fn main() -> io::Result<()> { } } - audio_sample_pos.store(0, Ordering::Relaxed); + audio_ref.store(Arc::new(engine::AudioRef { + sample_pos: 0, + timestamp: std::time::Instant::now(), + sample_rate: 44100.0, + })); let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples .iter() @@ -162,7 +166,7 @@ fn main() -> io::Result<()> { Arc::clone(&spectrum_buffer), Arc::clone(&metrics), restart_samples, - Arc::clone(&audio_sample_pos), + Arc::clone(&audio_ref), new_error_tx, &app.audio.config.sample_paths, Arc::clone(&device_lost),