Feat: fix sequencer precision regression
Some checks failed
Deploy Website / deploy (push) Failing after 4s

This commit is contained in:
2026-03-18 03:41:51 +01:00
parent 68bd62f57f
commit 260bc9dbdf
8 changed files with 78 additions and 41 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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,

View File

@@ -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<EngineMetrics>,
scope_buffer: Arc<ScopeBuffer>,
spectrum_buffer: Arc<SpectrumBuffer>,
audio_sample_pos: Arc<AtomicU64>,
audio_ref: Arc<ArcSwap<AudioRef>>,
sample_rate_shared: Arc<AtomicU32>,
_stream: Option<cpal::Stream>,
_input_stream: Option<cpal::Stream>,
@@ -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),

View File

@@ -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<SpectrumBuffer>,
metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sampling::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>,
audio_ref: Arc<ArcSwap<AudioRef>>,
error_tx: Sender<String>,
sample_paths: &[std::path::PathBuf],
device_lost: Arc<AtomicBool>,
@@ -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)

View File

@@ -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)]

View File

@@ -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<AtomicU64>,
pub audio_ref: Arc<ArcSwap<AudioRef>>,
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
pub cc_access: Option<Arc<dyn CcAccess>>,
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<ArcSwap<SharedSequencerState>>,
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
audio_sample_pos: Arc<AtomicU64>,
audio_ref: Arc<ArcSwap<AudioRef>>,
sample_rate: Arc<std::sync::atomic::AtomicU32>,
cc_access: Option<Arc<dyn CcAccess>>,
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,

View File

@@ -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<EngineMetrics>,
pub scope_buffer: Arc<ScopeBuffer>,
pub spectrum_buffer: Arc<SpectrumBuffer>,
pub audio_sample_pos: Arc<AtomicU64>,
pub audio_ref: Arc<ArcSwap<AudioRef>>,
pub sample_rate_shared: Arc<AtomicU32>,
pub stream: Option<cpal::Stream>,
pub input_stream: Option<cpal::Stream>,
@@ -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<dyn model::CcAccess>),
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,

View File

@@ -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),