From 2ebd1e6cd1f4676f30b0b38176727d0cd2ac22b9 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:51:28 +0300 Subject: [PATCH 01/51] feat(audio): adaptive queue depth estimator and RT allocation guard DepthEstimator sizes a queue from the jitter it measures (window mean minus window low, 99th percentile with ~45 s half-life forgetting), so the target no longer follows the level its own corrections move. Underruns raise it at once; unused depth is released over about a minute. rt_guard is a test-only counting allocator that proves a real-time path never touches the heap. --- src-tauri/src/audio/adaptive_depth.rs | 279 ++++++++++++++++++++++++++ src-tauri/src/audio/mod.rs | 3 + src-tauri/src/audio/rt_guard.rs | 82 ++++++++ 3 files changed, 364 insertions(+) create mode 100644 src-tauri/src/audio/adaptive_depth.rs create mode 100644 src-tauri/src/audio/rt_guard.rs diff --git a/src-tauri/src/audio/adaptive_depth.rs b/src-tauri/src/audio/adaptive_depth.rs new file mode 100644 index 00000000..676cee23 --- /dev/null +++ b/src-tauri/src/audio/adaptive_depth.rs @@ -0,0 +1,279 @@ +//! Adaptive queue depth for audio that arrives on one clock and is consumed on +//! another: captured input read by a speaker graph, network audio read by any +//! output. +//! +//! What a queue needs is headroom against its own jitter: how far it dips +//! below its typical level before the next delivery lands. Measuring that dip +//! rather than the level itself is the point. The level is what the caller's +//! corrections move, so a target derived from the level chases its own +//! corrections; the dip (window mean minus window low) is the same whatever +//! level the queue is held at. +//! +//! Dips are kept in a histogram with exponential forgetting and the depth is +//! a high quantile of it, the way WebRTC's NetEq sizes its jitter buffer. An +//! underrun is recorded as an outage large enough to move the quantile at +//! once (fast attack); forgetting hands unused depth back over about a minute +//! (slow release), so a single spike cannot pin the latency high and a +//! repeating one is not forgotten between repeats. +//! +//! Everything is fixed-size: `observe` and `underrun` are RT-safe. + +const BUCKETS: usize = 64; +/// First non-zero bucket edge in frames; each next edge is 20% wider. +const FIRST_EDGE: f64 = 16.0; +const GROWTH: f64 = 1.2; +/// Share of the dip history the depth has to cover. +const QUANTILE: f64 = 0.99; +/// How long an observation keeps half its weight. +const HALF_LIFE_MS: f64 = 45_000.0; +/// An outage takes this share of the whole history at once, well above +/// 1 - QUANTILE, so the very next depth covers it. +const OUTAGE_SHARE: f64 = 0.05; +/// Measured windows before the prior stops counting. +const PRIOR_WINDOWS: usize = 4; + +fn edge(i: usize) -> usize { + if i == 0 { + 0 + } else { + (FIRST_EDGE * GROWTH.powi(i as i32 - 1)).round() as usize + } +} + +fn bucket(frames: usize) -> usize { + (1..BUCKETS) + .find(|&i| frames < edge(i)) + .map_or(BUCKETS - 1, |i| i - 1) +} + +pub struct DepthEstimator { + weights: [f64; BUCKETS], + total: f64, + /// Depth assumed until `PRIOR_WINDOWS` windows have been measured. + prior: usize, + measured: usize, + forget: f64, + window_blocks: usize, + blocks: usize, + sum: f64, + low: usize, +} + +impl DepthEstimator { + /// `block_frames` are consumed per `observe`; `window_ms` is how much time + /// one dip measurement spans (it must cover the slowest delivery burst). + /// `prior_frames` is the dip assumed before anything has been measured. + pub fn new(sample_rate: u32, block_frames: usize, window_ms: f64, prior_frames: usize) -> Self { + let window_frames = sample_rate as f64 * window_ms / 1000.0; + let window_blocks = ((window_frames / block_frames.max(1) as f64).round() as usize).max(4); + let window_ms = window_blocks as f64 * block_frames as f64 * 1000.0 / sample_rate as f64; + Self { + weights: [0.0; BUCKETS], + total: 0.0, + prior: prior_frames, + measured: 0, + forget: 0.5_f64.powf(window_ms / HALF_LIFE_MS), + window_blocks, + blocks: 0, + sum: 0.0, + low: usize::MAX, + } + } + + /// Frames queued just before a block was taken. Closes a window every + /// `window_blocks` calls; returns true when it did. + pub fn observe(&mut self, queued: usize) -> bool { + self.sum += queued as f64; + self.low = self.low.min(queued); + self.blocks += 1; + if self.blocks < self.window_blocks { + return false; + } + let mean = self.sum / self.blocks as f64; + let dip = (mean - self.low as f64).max(0.0).round() as usize; + self.blocks = 0; + self.sum = 0.0; + self.low = usize::MAX; + self.measured += 1; + self.record(dip, 1.0); + true + } + + /// The queue ran out `deficit` frames short of what the consumer needed, + /// on top of whatever depth it was already holding. + pub fn underrun(&mut self, deficit: usize) { + let dip = self.depth() + deficit; + let weight = (self.total * OUTAGE_SHARE / (1.0 - OUTAGE_SHARE)).max(1.0); + self.record(dip, weight); + self.blocks = 0; + self.sum = 0.0; + self.low = usize::MAX; + } + + /// Headroom to keep above one block at the queue's mean level. + pub fn depth(&self) -> usize { + let measured = self.measured_depth(); + if self.measured < PRIOR_WINDOWS { + measured.max(self.prior) + } else { + measured + } + } + + fn measured_depth(&self) -> usize { + if self.total <= 0.0 { + return 0; + } + let mut above = self.total * (1.0 - QUANTILE); + for i in (0..BUCKETS).rev() { + above -= self.weights[i]; + if above < 0.0 { + return edge((i + 1).min(BUCKETS - 1)); + } + } + edge(1) + } + + fn record(&mut self, dip: usize, weight: f64) { + for w in self.weights.iter_mut() { + *w *= self.forget; + } + self.total *= self.forget; + self.weights[bucket(dip)] += weight; + self.total += weight; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SR: u32 = 48_000; + + fn est(prior: usize) -> DepthEstimator { + DepthEstimator::new(SR, 64, 250.0, prior) + } + + /// Feed `windows` windows of a sawtooth: `burst` frames arrive at once + /// and drain one 64-frame block at a time, starting from `floor`. + fn sawtooth(e: &mut DepthEstimator, floor: usize, burst: usize, windows: usize) { + let mut level = floor + burst; + let mut closed = 0; + while closed < windows { + if e.observe(level) { + closed += 1; + } + level = if level >= floor + 64 { + level - 64 + } else { + floor + burst + }; + } + } + + #[test] + fn buckets_are_monotonic_and_cover_a_second_at_high_rates() { + for i in 1..BUCKETS { + assert!(edge(i) > edge(i - 1)); + assert_eq!(bucket(edge(i)), i); + assert_eq!(bucket(edge(i) - 1), i - 1); + } + assert!(edge(BUCKETS - 1) > 384_000); + } + + #[test] + fn depth_covers_the_measured_jitter() { + let mut e = est(0); + sawtooth(&mut e, 200, 512, 200); + let d = e.depth(); + assert!( + (256..=512 + 64).contains(&d), + "sawtooth of 512 needs ~half: {d}" + ); + } + + // The flaw of the old targets: correcting the level moved the target. + #[test] + fn depth_does_not_follow_the_queue_level() { + let mut shallow = est(0); + let mut deep = est(0); + sawtooth(&mut shallow, 0, 512, 200); + sawtooth(&mut deep, 5_000, 512, 200); + assert_eq!(shallow.depth(), deep.depth()); + } + + #[test] + fn steady_jitter_gives_a_steady_depth() { + let mut e = est(0); + sawtooth(&mut e, 100, 480, 100); + let settled = e.depth(); + for _ in 0..50 { + sawtooth(&mut e, 100, 480, 10); + assert_eq!(e.depth(), settled, "depth wandered under unchanged jitter"); + } + } + + #[test] + fn an_underrun_raises_the_depth_at_once() { + let mut e = est(0); + sawtooth(&mut e, 100, 256, 100); + let before = e.depth(); + e.underrun(2_000); + assert!(e.depth() >= before + 2_000, "{} -> {}", before, e.depth()); + } + + #[test] + fn unused_depth_is_released_over_about_a_minute() { + let mut e = est(0); + sawtooth(&mut e, 100, 256, 40); + e.underrun(4_000); + let spiked = e.depth(); + // 10 s later the spike is still honoured. + sawtooth(&mut e, 100, 256, 40); + assert_eq!(e.depth(), spiked, "released too fast"); + // Four minutes of calm hand it back. + sawtooth(&mut e, 100, 256, 960); + assert!(e.depth() < 1_000, "never released: {}", e.depth()); + } + + #[test] + fn a_repeating_spike_keeps_its_depth() { + let mut e = est(0); + for _ in 0..10 { + sawtooth(&mut e, 100, 256, 80); + e.underrun(3_000); + } + let d = e.depth(); + sawtooth(&mut e, 100, 256, 80); + assert!( + e.depth() >= 3_000, + "forgot a spike that recurs every 20 s: {d}" + ); + } + + #[test] + fn prior_holds_until_measurements_replace_it() { + let mut e = est(2_880); + assert!(e.depth() >= 2_880); + sawtooth(&mut e, 100, 128, PRIOR_WINDOWS - 1); + assert!(e.depth() >= 2_880, "prior dropped before a second of data"); + sawtooth(&mut e, 100, 128, 1); + assert!( + e.depth() < 500, + "prior outlived real measurements: {}", + e.depth() + ); + } + + #[test] + fn observe_and_underrun_never_allocate() { + let mut e = est(512); + crate::audio::rt_guard::assert_no_alloc("depth estimator", || { + for i in 0..10_000 { + e.observe(i % 700); + } + e.underrun(300); + std::hint::black_box(e.depth()); + }); + } +} diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 5328ef5a..33b479fe 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -1,3 +1,4 @@ +pub mod adaptive_depth; pub mod capture; pub mod clock; pub mod device; @@ -18,6 +19,8 @@ pub mod plugins; #[cfg(target_os = "linux")] pub mod pw_enum; pub mod resample; +#[cfg(test)] +pub mod rt_guard; pub mod signaling; pub mod stream_recv; pub mod streams; diff --git a/src-tauri/src/audio/rt_guard.rs b/src-tauri/src/audio/rt_guard.rs new file mode 100644 index 00000000..d96d827e --- /dev/null +++ b/src-tauri/src/audio/rt_guard.rs @@ -0,0 +1,82 @@ +//! Test-only allocation guard for real-time code paths. +//! +//! The test binary's global allocator counts allocations made on a thread +//! while that thread is inside `assert_no_alloc`, so a test can prove an audio +//! callback path never touches the heap once it is running. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +struct CountingAlloc; + +thread_local! { + static ARMED: Cell = const { Cell::new(false) }; + static COUNT: Cell = const { Cell::new(0) }; +} + +fn note() { + // `try_with`: the allocator also runs while thread-locals are torn down. + let armed = ARMED.try_with(Cell::get).unwrap_or(false); + if armed { + let _ = COUNT.try_with(|c| c.set(c.get() + 1)); + } +} + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + note(); + System.alloc(layout) + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + note(); + System.alloc_zeroed(layout) + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + note(); + System.realloc(ptr, layout, new_size) + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + note(); + System.dealloc(ptr, layout) + } +} + +#[global_allocator] +static ALLOC: CountingAlloc = CountingAlloc; + +/// Runs `f` and panics if it allocated, reallocated or freed on this thread. +pub fn assert_no_alloc(what: &str, f: impl FnOnce() -> R) -> R { + COUNT.with(|c| c.set(0)); + ARMED.with(|a| a.set(true)); + let out = f(); + ARMED.with(|a| a.set(false)); + let n = COUNT.with(Cell::get); + assert_eq!(n, 0, "{what}: {n} heap operations on the real-time path"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catches_an_allocation() { + let caught = std::panic::catch_unwind(|| { + assert_no_alloc("vec", || { + let v: Vec = Vec::with_capacity(16); + std::hint::black_box(v); + }) + }); + assert!(caught.is_err()); + } + + #[test] + fn passes_allocation_free_code() { + let mut buf = [0.0_f32; 64]; + assert_no_alloc("fill", || buf.fill(1.0)); + assert_eq!(buf[63], 1.0); + } +} From 11c3054cc7747aa3242975f4f8b37749796ca555 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:51:35 +0300 Subject: [PATCH 02/51] feat(audio): render speakers in the device callback at a configurable buffer size - Speaker graphs render inside the device callback: no output ring, no sleeping worker. The engine block is a new buffer setting (32-2048 frames, default 256); macOS requests the same device IO buffer via HAL. - Live sources hold their queue at the measured headroom (cushion): startup backlog dropped, drift spliced out or stretched in with crossfades, an underrun re-primes once instead of clicking per block. - Network receive sizes its jitter buffer with the same estimator and follows the engine block instead of a fixed 1024. - Offload pad is max(block, effect floor): plugins 5 ms, noise suppressor one model hop. Offload threads run at real-time priority. - latency_report replaces output_latency_ms: device buffers and hardware latency both ways, input queue, processing, output adapter, DSP load, overloads and per-node working block. - GraphSpec reads camelCase, so sampleRate from the frontend is applied (it was silently ignored) and bufferFrames arrives. - Full-width effects process at their real channel count; a mono source into an analyzer panicked. --- docs/CONCEPT.md | 7 +- src-tauri/src/audio/clock.rs | 158 +--- src-tauri/src/audio/effects/mod.rs | 191 +++- .../src/audio/effects/noise_suppressor.rs | 66 +- src-tauri/src/audio/effects/offload.rs | 138 +-- src-tauri/src/audio/engine.rs | 21 +- src-tauri/src/audio/graph.rs | 111 +++ src-tauri/src/audio/health.rs | 11 +- src-tauri/src/audio/input_bridge.rs | 21 +- src-tauri/src/audio/macos_hal.rs | 198 ++++ src-tauri/src/audio/netaudio/receiver.rs | 12 +- src-tauri/src/audio/pipeline/cushion.rs | 359 +++++++ src-tauri/src/audio/pipeline/dag.rs | 893 ++++++++++++++---- src-tauri/src/audio/pipeline/input/linux.rs | 10 + src-tauri/src/audio/pipeline/input/macos.rs | 32 + src-tauri/src/audio/pipeline/input/mod.rs | 52 +- src-tauri/src/audio/pipeline/input/windows.rs | 10 + src-tauri/src/audio/pipeline/latency.rs | 220 +++++ src-tauri/src/audio/pipeline/meter.rs | 64 +- src-tauri/src/audio/pipeline/mod.rs | 169 +++- src-tauri/src/audio/pipeline/output/linux.rs | 37 +- src-tauri/src/audio/pipeline/output/macos.rs | 65 +- src-tauri/src/audio/pipeline/output/mod.rs | 435 +-------- src-tauri/src/audio/pipeline/output/render.rs | 505 ++++++++++ .../src/audio/pipeline/output/windows.rs | 30 +- src-tauri/src/audio/pipeline/sig.rs | 8 + src-tauri/src/audio/pipeline/worker.rs | 36 +- src-tauri/src/audio/playback.rs | 20 +- src-tauri/src/audio/plugins/host_api.rs | 32 +- src-tauri/src/audio/resample.rs | 42 + src-tauri/src/audio/stream_recv.rs | 319 +++++-- src-tauri/src/audio/streams/mod.rs | 33 +- src-tauri/src/audio/webrtc/session.rs | 10 +- src-tauri/src/commands.rs | 6 +- src-tauri/src/lib.rs | 2 +- .../pipeline/generated/LatencyBreakdown.ts | 28 + .../pipeline/generated/LatencyReport.ts | 26 + .../modules/pipeline/generated/NodeTiming.ts | 11 + 38 files changed, 3206 insertions(+), 1182 deletions(-) create mode 100644 src-tauri/src/audio/pipeline/cushion.rs create mode 100644 src-tauri/src/audio/pipeline/latency.rs create mode 100644 src-tauri/src/audio/pipeline/output/render.rs create mode 100644 src/lib/modules/pipeline/generated/LatencyBreakdown.ts create mode 100644 src/lib/modules/pipeline/generated/LatencyReport.ts create mode 100644 src/lib/modules/pipeline/generated/NodeTiming.ts diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 3f67ab50..12871c78 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -40,8 +40,11 @@ Without these, one chunk takes ~16 ms and the worker stalls. ## DspWorker pacing -- `Clock` — the single transport cadence for every worker (speaker, monitoring, wire sender, recording). Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source — a file source decodes faster than real time and would otherwise over-run the encoder. -- Stall: per-source `last_pop_at`; >150 ms silence → zero-fill and proceed. +- Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. +- `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. +- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. Excess is spliced out and shortfall stretched in, with crossfades; an underrun re-primes once instead of clicking per block. +- An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs at its own working block, declares it as latency for PDC, and the node shows it. +- Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. ## Effects diff --git a/src-tauri/src/audio/clock.rs b/src-tauri/src/audio/clock.rs index 0ad46195..1ed51b6a 100644 --- a/src-tauri/src/audio/clock.rs +++ b/src-tauri/src/audio/clock.rs @@ -1,7 +1,6 @@ //! Pacing source for the DSP worker. -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::time::{Duration, Instant}; @@ -60,12 +59,6 @@ impl SystemClockTicker { t.catchup_max = t.period * max_blocks; t } - - fn rate_limiter(sample_rate: u32, block_frames: usize) -> Self { - let mut ticker = Self::new(sample_rate, block_frames); - ticker.report_late = false; - ticker - } } impl ClockSource for SystemClockTicker { @@ -108,88 +101,6 @@ impl ClockSource for SystemClockTicker { } } -/// Cap on the sleep between ring-level checks, so `stop` stays responsive -/// even when the ring is far above target. -const FILL_CLOCK_MAX_SLEEP: Duration = Duration::from_millis(5); - -/// Paces the speaker DSP worker off the speaker ring's own fill level rather -/// than a wall-clock deadline. A late block just means the ring is below -/// target, so the worker produces the next block immediately and never loses -/// the notion of "how far behind" the way a deadline reset would. -pub struct DeviceFillClock { - pipeline_sample_rate: u32, - device_sample_rate: Arc, - engine_block_frames: usize, - level: Arc, - /// Fill target, sized to the device's own buffer by the audio callback - /// (see `speaker_ring`). Read here every tick so the ring always bridges - /// one full callback whatever buffer the device negotiated. - target: Arc, - /// The startup fill budget has been produced. Until then an empty ring is - /// startup prefill, not a worker that fell behind. - primed: bool, - startup_frames: usize, - /// Prevents a sink that drains immediately (for example a PipeWire null - /// sink) from turning the real-time worker into an unbounded busy loop. - wall_clock: SystemClockTicker, -} - -impl DeviceFillClock { - pub fn new( - pipeline_sample_rate: u32, - device_sample_rate: Arc, - engine_block_frames: usize, - level: Arc, - target: Arc, - ) -> Self { - Self { - pipeline_sample_rate, - device_sample_rate, - engine_block_frames, - level, - target, - primed: false, - startup_frames: 0, - wall_clock: SystemClockTicker::rate_limiter(pipeline_sample_rate, engine_block_frames), - } - } -} - -impl ClockSource for DeviceFillClock { - fn wait_for_tick(&mut self, stop: &AtomicBool) -> bool { - loop { - if stop.load(Ordering::SeqCst) { - return false; - } - let target_frames = self.target.load(Ordering::Relaxed).max(0) as usize; - let queued = self.level.load(Ordering::Relaxed).max(0) as usize; - let dev_sr = self.device_sample_rate.load(Ordering::Relaxed).max(1); - let pipe_sr = self.pipeline_sample_rate.max(1) as u64; - let block_frames = ((self.engine_block_frames as u64 * dev_sr as u64 + pipe_sr / 2) - / pipe_sr) as usize; - if !self.primed { - self.startup_frames = self.startup_frames.saturating_add(block_frames); - self.primed = self.startup_frames >= target_frames; - return true; - } - if queued + block_frames <= target_frames { - return self.wall_clock.wait_for_tick(stop); - } - let overshoot = queued + block_frames - target_frames; - let drain = Duration::from_nanos((overshoot as u64 * 1_000_000_000) / dev_sr as u64); - thread::sleep(drain.min(FILL_CLOCK_MAX_SLEEP)); - } - } - - fn sample_rate(&self) -> u32 { - self.device_sample_rate.load(Ordering::Relaxed) - } - - fn realtime_ready(&self) -> bool { - self.primed - } -} - #[cfg(test)] mod tests { use super::*; @@ -254,71 +165,4 @@ mod tests { assert!(reset >= before + t.period); assert!(reset > stale + t.period); } - - #[test] - fn rate_limiter_disables_late_reporting() { - // Global health counters are intentionally shared by all workers, so - // their exact value is not a race-safe unit-test oracle. - let t = SystemClockTicker::rate_limiter(48_000, 480); - assert!(!t.report_late); - assert_eq!(t.catchup_max, Duration::ZERO); - assert_eq!(t.period, Duration::from_millis(10)); - } - - #[test] - fn fill_clock_primes_until_the_startup_budget_is_met() { - let dev_sr = Arc::new(AtomicU32::new(48_000)); - let level = Arc::new(AtomicI64::new(0)); - let target = Arc::new(AtomicI64::new(2 * 1024)); // two engine blocks - let mut clock = - DeviceFillClock::new(48_000, dev_sr.clone(), 1024, level.clone(), target.clone()); - let stop = AtomicBool::new(false); - assert!(!clock.realtime_ready(), "fresh clock is unprimed"); - assert!(clock.wait_for_tick(&stop)); - assert!(!clock.realtime_ready(), "one block is not the full budget"); - assert!(clock.wait_for_tick(&stop)); - assert!(clock.realtime_ready(), "budget met after the second block"); - // Primed clock paces off the ring: below target → wall-clock tick. - assert!(clock.wait_for_tick(&stop)); - // sample_rate follows the device atom. - assert_eq!(clock.sample_rate(), 48_000); - dev_sr.store(96_000, Ordering::Relaxed); - assert_eq!(clock.sample_rate(), 96_000); - } - - #[test] - fn fill_clock_waits_when_the_ring_is_over_target() { - let dev_sr = Arc::new(AtomicU32::new(48_000)); - let level = Arc::new(AtomicI64::new(1024)); - let target = Arc::new(AtomicI64::new(1024)); // already at target - let mut clock = DeviceFillClock::new(48_000, dev_sr, 1024, level.clone(), target.clone()); - let stop = AtomicBool::new(false); - // First call primes. Stop the otherwise blocking second call after it - // has demonstrably waited instead of returning immediately. - assert!(clock.wait_for_tick(&stop)); - let stop = Arc::new(AtomicBool::new(false)); - let stop_setter = stop.clone(); - let join = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(15)); - stop_setter.store(true, Ordering::SeqCst); - }); - let start = Instant::now(); - assert!(!clock.wait_for_tick(&stop)); - join.join().unwrap(); - assert!(start.elapsed() >= Duration::from_millis(10)); - } - - #[test] - fn fill_clock_honours_stop() { - let dev_sr = Arc::new(AtomicU32::new(48_000)); - let mut clock = DeviceFillClock::new( - 48_000, - dev_sr, - 1024, - Arc::new(AtomicI64::new(0)), - Arc::new(AtomicI64::new(1024)), - ); - let stop = AtomicBool::new(true); - assert!(!clock.wait_for_tick(&stop)); - } } diff --git a/src-tauri/src/audio/effects/mod.rs b/src-tauri/src/audio/effects/mod.rs index 6e0181be..4f3924ef 100644 --- a/src-tauri/src/audio/effects/mod.rs +++ b/src-tauri/src/audio/effects/mod.rs @@ -13,9 +13,9 @@ use serde_json::Value; use crate::audio::graph::EffectSpec; use crate::audio::plugins::host_api::HostedEffect; -/// Fixed DSP block size; hosted-plugin scratch buffers are sized to it. Must -/// stay >= the pipeline's `DSP_BLOCK_FRAMES`, or a block would overrun them. -const PLUGIN_MAX_BLOCK: usize = 1024; +/// Most frames a hosted plugin is ever handed at once: the largest engine +/// block, since an offload worker forwards at most that much per call. +const PLUGIN_MAX_BLOCK: usize = crate::audio::graph::MAX_BUFFER_FRAMES; pub mod biquad; pub mod channel_balance; @@ -98,6 +98,17 @@ pub enum RuntimeEffect { } impl RuntimeEffect { + /// Frames this effect gathers before it can answer, when that is more + /// than the engine block it was built for; `None` when it keeps up with + /// any block. Drives the node's "runs at a larger block" badge. + pub fn working_block(&self) -> Option { + match self { + RuntimeEffect::NoiseSuppressor(e) => e.working_block(), + RuntimeEffect::HostedPlugin(e) => e.working_block(), + _ => None, + } + } + #[inline] pub fn latency_frames(&self) -> usize { match self { @@ -128,7 +139,11 @@ impl RuntimeEffect { sidechain: Option<&[f32]>, frames: usize, ) { - let active = &mut main[..frames * 2]; + // Whatever width the node runs at: a stereo pair for most effects, the + // node's whole width (mono included) for analyzers and plugins that + // take every channel. + let width = main.len() / frames.max(1); + let active = &mut main[..frames * width]; for sample in active.iter_mut() { if !sample.is_finite() { *sample = 0.0; @@ -489,6 +504,8 @@ pub fn instantiate_effect( spec: &EffectSpec, node_id: &str, sample_rate: u32, + // Engine block the node runs in. Offloaded effects size their pad from it. + block_frames: usize, // False for file-recording outputs: an offline render outruns real time, // so an expensive effect must process in place instead of on a worker. realtime: bool, @@ -937,6 +954,7 @@ pub fn instantiate_effect( RuntimeEffect::NoiseSuppressor(NoiseSuppressorEffect::from_state( controls.clone(), sample_rate, + block_frames, realtime, )), None, @@ -946,7 +964,7 @@ pub fn instantiate_effect( None, ), _ => { - let (e, c) = NoiseSuppressorEffect::new(d, sample_rate, realtime); + let (e, c) = NoiseSuppressorEffect::new(d, sample_rate, block_frames, realtime); registry.controls.insert(node_id.to_string(), c.clone()); mk( RuntimeEffect::NoiseSuppressor(e), @@ -1074,7 +1092,12 @@ pub fn instantiate_effect( // splitting it into pairs and hand it every channel. let full_width = node.channels() == channels; let mut build = mk( - RuntimeEffect::HostedPlugin(HostedEffect::new(node, realtime)), + RuntimeEffect::HostedPlugin(HostedEffect::new( + node, + realtime, + sample_rate, + block_frames, + )), control, None, None, @@ -1408,17 +1431,17 @@ mod tests { gain_db: -6.0, bypassed: true, }); - let first = instantiate_effect(&spec, "n1", 48_000, true, true, 2, &mut reg); + let first = instantiate_effect(&spec, "n1", 48_000, 1024, true, true, 2, &mut reg); assert!(first.bypass_is_new); assert!(first.control.is_some()); assert!( first.bypass.load(Ordering::Relaxed), "bypassed spec latches" ); - let second = instantiate_effect(&spec, "n1", 48_000, true, true, 2, &mut reg); + let second = instantiate_effect(&spec, "n1", 48_000, 1024, true, true, 2, &mut reg); assert!(!second.bypass_is_new); assert!(second.control.is_none()); - let other = instantiate_effect(&spec, "n2", 48_000, true, true, 2, &mut reg); + let other = instantiate_effect(&spec, "n2", 48_000, 1024, true, true, 2, &mut reg); assert!(other.bypass_is_new); assert!(other.control.is_some()); } @@ -1432,9 +1455,9 @@ mod tests { release_ms: 50.0, bypassed: false, }); - let first = instantiate_effect(&spec, "lim", 48_000, true, true, 2, &mut reg); + let first = instantiate_effect(&spec, "lim", 48_000, 1024, true, true, 2, &mut reg); let gr1 = first.gr.clone().expect("first build publishes GR"); - let second = instantiate_effect(&spec, "lim", 48_000, true, true, 2, &mut reg); + let second = instantiate_effect(&spec, "lim", 48_000, 1024, true, true, 2, &mut reg); let gr2 = second.gr.clone().expect("rebuild republishes GR"); assert_eq!(gr1.node_id, gr2.node_id); assert!(Arc::ptr_eq(&gr1.gr_lin, &gr2.gr_lin), "same atom reused"); @@ -1456,6 +1479,7 @@ mod tests { }), "c", 48_000, + 1024, true, true, 2, @@ -1473,6 +1497,7 @@ mod tests { }), "g", 48_000, + 1024, true, true, 2, @@ -1488,6 +1513,7 @@ mod tests { &EffectSpec::LevelMeter(LevelMeterData {}), "m", 48_000, + 1024, true, true, 2, @@ -1498,6 +1524,7 @@ mod tests { &EffectSpec::LevelMeter(LevelMeterData {}), "m", 48_000, + 1024, true, true, 2, @@ -1509,6 +1536,7 @@ mod tests { &EffectSpec::LufsMeter(LufsMeterData {}), "l", 48_000, + 1024, true, true, 2, @@ -1519,6 +1547,7 @@ mod tests { &EffectSpec::Waveform(WaveformData {}), "w", 48_000, + 1024, true, true, 2, @@ -1529,6 +1558,7 @@ mod tests { &EffectSpec::Spectrum(SpectrumData {}), "s", 48_000, + 1024, true, true, 2, @@ -1539,6 +1569,7 @@ mod tests { &EffectSpec::Spectrum(SpectrumData {}), "s", 48_000, + 1024, true, true, 2, @@ -1558,7 +1589,7 @@ mod tests { bypassed: false, state: None, }; - let mut build = instantiate_effect(&spec, "p", 48_000, true, true, 2, &mut reg); + let mut build = instantiate_effect(&spec, "p", 48_000, 1024, true, true, 2, &mut reg); assert!(build.control.is_none()); assert!(!build.full_width); let mut buf = vec![0.3f32, -0.4]; @@ -1577,7 +1608,7 @@ mod tests { bypassed: false, state: None, }; - let mut build = instantiate_effect(&spec, "p2", 48_000, true, true, 2, &mut reg); + let mut build = instantiate_effect(&spec, "p2", 48_000, 1024, true, true, 2, &mut reg); let mut buf = vec![0.3f32, -0.4]; build.effect.process_with_sidechain(&mut buf, None, 1); assert_eq!(buf, vec![0.0, 0.0]); @@ -1594,7 +1625,7 @@ mod tests { bypassed: false, state: None, }; - let mut build = instantiate_effect(&spec, "p3", 48_000, true, true, 2, &mut reg); + let mut build = instantiate_effect(&spec, "p3", 48_000, 1024, true, true, 2, &mut reg); let mut buf = vec![0.3f32, -0.4]; build.effect.process_with_sidechain(&mut buf, None, 1); assert_eq!(buf, vec![0.0, 0.0]); @@ -1609,11 +1640,9 @@ mod tests { assert!(!reg.plugin_primary_claimed.contains("x")); } - #[test] - fn runtime_effect_dispatch_latency_and_processing() { - let mut reg = EffectRegistry::new(); - let sr = 48_000; - let specs: Vec = vec![ + /// One of every built-in effect with neutral-ish settings. + fn builtin_specs() -> Vec { + vec![ EffectSpec::Gain(GainData { gain_db: -6.0, bypassed: false, @@ -1687,9 +1716,122 @@ mod tests { ratio: 4.0, bypassed: false, }), - ]; + ] + } + + /// A stereo signal with transients, level changes and silence, so + /// dynamics, detectors and filters all have something to react to. + fn program(frames: usize) -> Vec { + (0..frames) + .flat_map(|f| { + let t = f as f32 / 48_000.0; + let env = if (f / 3000) % 2 == 0 { 0.9 } else { 0.05 }; + let click = if f % 1711 == 0 { 0.8 } else { 0.0 }; + let l = env * (t * 440.0 * std::f32::consts::TAU).sin() + click; + let r = env * (t * 6_500.0 * std::f32::consts::TAU).sin() * 0.7; + [l, r] + }) + .collect() + } + + fn run_in_blocks(spec: &EffectSpec, input: &[f32], block: usize) -> Vec { + let mut reg = EffectRegistry::new(); + let mut build = instantiate_effect(spec, "inv", 48_000, block, false, true, 2, &mut reg); + let mut out = input.to_vec(); + for chunk in out.chunks_mut(block * 2) { + let frames = chunk.len() / 2; + build.effect.process_with_sidechain(chunk, None, frames); + } + out + } + + // The engine block is a user setting now, so an effect's output must not + // depend on how its input was cut into blocks. + #[test] + fn every_builtin_effect_is_block_size_invariant() { + let input = program(12_288); + let mut shaping = 0; + for spec in builtin_specs() { + let reference = run_in_blocks(&spec, &input, 1024); + if reference + .iter() + .zip(&input) + .any(|(a, b)| (a - b).abs() > 1e-3) + { + shaping += 1; + } + for block in crate::audio::graph::BUFFER_FRAME_OPTIONS.map(|n| n as usize) { + let got = run_in_blocks(&spec, &input, block); + let worst = got + .iter() + .zip(&reference) + .map(|(a, b)| (a - b).abs()) + .fold(0.0_f32, f32::max); + assert!( + worst < 1e-4, + "{spec:?} at {block}-frame blocks differs by {worst}" + ); + } + } + assert!(shaping >= 8, "only {shaping} effects changed the signal"); + } + + #[test] + fn every_builtin_effect_processes_without_allocating() { + for spec in builtin_specs() { + for block in crate::audio::graph::BUFFER_FRAME_OPTIONS.map(|n| n as usize) { + let mut reg = EffectRegistry::new(); + let mut build = + instantiate_effect(&spec, "rt", 48_000, block, true, true, 2, &mut reg); + let mut buf = program(block); + // First blocks may size lazily; the steady state must not. + for _ in 0..4 { + build.effect.process_with_sidechain(&mut buf, None, block); + } + crate::audio::rt_guard::assert_no_alloc(&format!("{spec:?} @ {block}"), || { + for _ in 0..16 { + build.effect.process_with_sidechain(&mut buf, None, block); + } + }); + } + } + } + + // Analyzers take a node's whole width, which is not always stereo: a mono + // mic feeds them one channel, a multichannel interface four or more. + #[test] + fn full_width_analyzers_see_every_channel_at_any_width() { + for width in [1usize, 2, 4, 6] { + let mut reg = EffectRegistry::new(); + let spec = EffectSpec::LevelMeter(LevelMeterData {}); + let mut build = + instantiate_effect(&spec, "lm", 48_000, 1024, true, true, width, &mut reg); + let meter = build.meter.clone().expect("level meter publishes a handle"); + let frames = 1024; + // Channel c carries a constant 0.1 * (c + 1). + let mut buf: Vec = (0..frames * width) + .map(|i| 0.1 * ((i % width) + 1) as f32) + .collect(); + build.effect.process_with_sidechain(&mut buf, None, frames); + let snap = meter.snapshot_and_decay(); + assert_eq!(snap.peaks.len(), width, "{width} channels metered"); + for (c, peak) in snap.peaks.iter().enumerate() { + let want = 0.1 * (c + 1) as f32; + assert!( + (peak - want).abs() < 1e-4, + "{width}ch: channel {c} peak {peak}" + ); + } + } + } + + #[test] + fn runtime_effect_dispatch_latency_and_processing() { + let mut reg = EffectRegistry::new(); + let sr = 48_000; + let specs = builtin_specs(); for spec in &specs { - let mut build = instantiate_effect(spec, "dispatch", sr, true, true, 2, &mut reg); + let mut build = instantiate_effect(spec, "dispatch", sr, 1024, true, true, 2, &mut reg); assert!(build.effect.latency_frames() < 100_000); let mut buf = vec![0.25f32; 96]; let side = vec![0.5f32; 96]; @@ -1744,7 +1886,8 @@ mod tests { }), ]; for spec in &specs { - let mut build = instantiate_effect(spec, "finite", 48_000, true, true, 2, &mut reg); + let mut build = + instantiate_effect(spec, "finite", 48_000, 1024, true, true, 2, &mut reg); let mut poisoned = vec![0.25; 96]; poisoned[10] = f32::NAN; poisoned[11] = f32::INFINITY; @@ -1851,7 +1994,7 @@ mod tests { ]; for (i, spec) in specs.iter().enumerate() { let node = format!("reuse-{i}"); - let first = instantiate_effect(spec, &node, sr, false, true, 2, &mut reg); + let first = instantiate_effect(spec, &node, sr, 1024, false, true, 2, &mut reg); assert!( first.control.is_some() || first.meter.is_some() @@ -1860,7 +2003,7 @@ mod tests { ); // Same node id → the registry already holds the state; the rebuild // must go through from_state and stay functional. - let mut second = instantiate_effect(spec, &node, sr, false, true, 2, &mut reg); + let mut second = instantiate_effect(spec, &node, sr, 1024, false, true, 2, &mut reg); assert!(second.control.is_none()); assert!(second.meter.is_none() && second.lufs.is_none() && second.scope.is_none()); let mut buf = vec![0.3f32; 96]; diff --git a/src-tauri/src/audio/effects/noise_suppressor.rs b/src-tauri/src/audio/effects/noise_suppressor.rs index 194b17e2..502de1f9 100644 --- a/src-tauri/src/audio/effects/noise_suppressor.rs +++ b/src-tauri/src/audio/effects/noise_suppressor.rs @@ -72,10 +72,10 @@ use model::{DfParams, DfTract, RuntimeParams}; use ndarray::Array2; use crate::audio::graph::NoiseSuppressorData; -use crate::audio::pipeline::dag::DSP_BLOCK_FRAMES; +use crate::audio::graph::MAX_BUFFER_FRAMES; use crate::audio::resample::MultiResampler; -use super::offload::{BlockProcessor, Offload}; +use super::offload::{pad_frames, BlockProcessor, Offload}; use super::util::load_f32; use super::{Effect, EffectControl}; @@ -98,6 +98,8 @@ pub struct NoiseSuppressorControls { pub struct NoiseSuppressorEffect { backend: Option, latency: usize, + /// The model's hop in output frames, when it exceeds the engine block. + working_block: Option, } enum Backend { @@ -189,7 +191,12 @@ struct ModelState { } impl NoiseSuppressorEffect { - pub fn new(d: NoiseSuppressorData, sample_rate: u32, realtime: bool) -> (Self, EffectControl) { + pub fn new( + d: NoiseSuppressorData, + sample_rate: u32, + block_frames: usize, + realtime: bool, + ) -> (Self, EffectControl) { let ctl = NoiseSuppressorControls { atten_lim_db: Arc::new(AtomicU32::new(d.attenuation_limit_db.to_bits())), pf_beta: Arc::new(AtomicU32::new(d.post_filter_beta.max(0.0).to_bits())), @@ -200,47 +207,68 @@ impl NoiseSuppressorEffect { let control = EffectControl::NoiseSuppressor { controls: ctl.clone(), }; - (Self::from_state(ctl, sample_rate, realtime), control) + ( + Self::from_state(ctl, sample_rate, block_frames, realtime), + control, + ) } - pub fn from_state(ctl: NoiseSuppressorControls, sample_rate: u32, realtime: bool) -> Self { + pub fn from_state( + ctl: NoiseSuppressorControls, + sample_rate: u32, + block_frames: usize, + realtime: bool, + ) -> Self { let initial = Params::load(&ctl); let Some(state) = ModelState::build(initial, sample_rate) else { return Self { backend: None, latency: 0, + working_block: None, }; }; let model_latency = state.latency; + // The model steps in whole hops at 48 kHz: that is its working block. + let hop = (state.hop * sample_rate as usize).div_ceil(MODEL_SR as usize); + let working_block = (hop > block_frames).then_some(hop); let worker = ModelWorker { ctl, state, last: initial, }; if !realtime { - let out = Vec::with_capacity(DSP_BLOCK_FRAMES * 2); + let out = Vec::with_capacity(MAX_BUFFER_FRAMES * 2); return Self { backend: Some(Backend::Inline { worker, out }), latency: model_latency, + working_block, }; } - match Offload::spawn("noise_suppressor", worker, 2) { - Ok(offload) => { - let latency = model_latency + offload.latency_frames(); - Self { - backend: Some(Backend::Offloaded(offload)), - latency, - } - } + // A worker that computes a hop at a time needs a hop of runway to do + // it in, so the pad never drops below one. + let pad = pad_frames(sample_rate, block_frames, hop); + match Offload::spawn("noise_suppressor", worker, 2, pad, sample_rate) { + Ok(offload) => Self { + latency: model_latency + offload.latency_frames(), + backend: Some(Backend::Offloaded(offload)), + working_block, + }, Err(worker) => { - let out = Vec::with_capacity(DSP_BLOCK_FRAMES * 2); + let out = Vec::with_capacity(MAX_BUFFER_FRAMES * 2); Self { backend: Some(Backend::Inline { worker, out }), latency: model_latency, + working_block, } } } } + + /// The model's hop, the smallest step it can answer in, when larger + /// than the engine block. + pub fn working_block(&self) -> Option { + self.working_block + } } impl ModelState { @@ -399,7 +427,7 @@ mod tests { #[test] fn inline_backend_processes_at_48k() { - let (mut e, _) = NoiseSuppressorEffect::new(data(), 48_000, false); + let (mut e, _) = NoiseSuppressorEffect::new(data(), 48_000, 1024, false); assert!(e.latency_frames() > 0, "model latency must be reported"); for k in 0..4 { let mut buf: Vec = (0..512 * 2) @@ -420,7 +448,7 @@ mod tests { #[test] fn inline_backend_resamples_at_44k1() { // Output rate ≠ 48 kHz exercises both resamplers. - let (mut e, _) = NoiseSuppressorEffect::new(data(), 44_100, false); + let (mut e, _) = NoiseSuppressorEffect::new(data(), 44_100, 1024, false); assert!(e.latency_frames() > 0); for _k in 0..4 { let mut buf = vec![0.2f32; 512 * 2]; @@ -431,7 +459,7 @@ mod tests { #[test] fn live_param_changes_update_the_model() { - let (mut e, c) = NoiseSuppressorEffect::new(data(), 48_000, false); + let (mut e, c) = NoiseSuppressorEffect::new(data(), 48_000, 1024, false); let EffectControl::NoiseSuppressor { controls } = &c else { panic!("variant") }; @@ -452,7 +480,7 @@ mod tests { #[test] fn zero_frames_is_noop() { - let (mut e, _) = NoiseSuppressorEffect::new(data(), 48_000, false); + let (mut e, _) = NoiseSuppressorEffect::new(data(), 48_000, 1024, false); let mut buf = vec![0.5f32; 32]; e.process(&mut buf, 0); assert_eq!(buf, vec![0.5; 32]); diff --git a/src-tauri/src/audio/effects/offload.rs b/src-tauri/src/audio/effects/offload.rs index 24b910f7..43e888a9 100644 --- a/src-tauri/src/audio/effects/offload.rs +++ b/src-tauri/src/audio/effects/offload.rs @@ -1,7 +1,11 @@ //! Runs an expensive effect on its own thread so a processing spike cannot -//! cost the DSP worker its block deadline. The RT side only bulk-pushes into -//! and bulk-pops out of a pair of SPSC rings; the return ring's prefill is -//! declared as latency so PDC aligns parallel branches against it. +//! cost the audio callback its deadline. The RT side only bulk-pushes into +//! and bulk-pops out of a pair of SPSC rings; the return ring's prefill (the +//! pad) is declared as latency so PDC aligns parallel branches against it. +//! +//! The pad is the effect's working block: it is how long the worker thread +//! may take to hand a block back. It is never below the engine block (the RT +//! side reads a whole block at once) and never below the effect's own floor. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -11,14 +15,23 @@ use std::time::Duration; use rtrb::{Consumer, Producer, RingBuffer}; use tracing::warn; +use crate::audio::graph::MAX_BUFFER_FRAMES; use crate::audio::health; -use crate::audio::pipeline::dag::DSP_BLOCK_FRAMES; -// The RT side pops a block immediately after pushing it, so the return ring -// must already hold a full block: that prefill is the offload's latency. -const PAD_FRAMES: usize = DSP_BLOCK_FRAMES; -const RING_FRAMES: usize = DSP_BLOCK_FRAMES * 16; const POLL_INTERVAL: Duration = Duration::from_millis(1); +/// Floor for a worker's turnaround: its 1 ms poll plus scheduling slack plus +/// the processing itself. +const MIN_TURNAROUND_MS: f64 = 5.0; + +/// Pad for an offloaded effect at `sample_rate` driven in `block_frames` +/// blocks, given the effect's own processing floor. A power of two, so it +/// reads as a buffer size. +pub fn pad_frames(sample_rate: u32, block_frames: usize, floor_frames: usize) -> usize { + let turnaround = (sample_rate as f64 * MIN_TURNAROUND_MS / 1000.0).ceil() as usize; + block_frames + .max(turnaround.next_power_of_two()) + .max(floor_frames) +} /// Interleaved block processing, run on the offload thread. pub trait BlockProcessor: Send { @@ -32,6 +45,7 @@ pub struct Offload { stop: Arc, join: Option>, width: usize, + pad_frames: usize, } // A partial write must not split a frame: a short tail would shift every later @@ -64,20 +78,26 @@ fn push_aligned(prod: &mut Producer, samples: &[f32], width: usize) -> usiz } impl Offload { + /// `pad_frames` comes from [`pad_frames`]; `sample_rate` sizes the + /// worker's real-time scheduling. pub fn spawn( name: &'static str, processor: P, width: usize, + pad_frames: usize, + sample_rate: u32, ) -> Result { if width == 0 { tracing::error!(name, "offload: width must be at least 1"); return Err(processor); } - let (to_worker, mut worker_in) = RingBuffer::::new(RING_FRAMES * width); - let (mut worker_out, from_worker) = RingBuffer::::new(RING_FRAMES * width); + // Room for the pad, a block in flight each way, and a stall's worth. + let ring_frames = (pad_frames + MAX_BUFFER_FRAMES) * 4; + let (to_worker, mut worker_in) = RingBuffer::::new(ring_frames * width); + let (mut worker_out, from_worker) = RingBuffer::::new(ring_frames * width); - match worker_out.write_chunk(PAD_FRAMES * width) { + match worker_out.write_chunk(pad_frames * width) { Ok(mut chunk) => { let (first, second) = chunk.as_mut_slices(); first.fill(0.0); @@ -105,8 +125,15 @@ impl Offload { .unwrap() .take() .expect("processor handed off"); - let mut scratch = vec![0.0f32; DSP_BLOCK_FRAMES * width]; - let mut out = Vec::with_capacity(DSP_BLOCK_FRAMES * width); + // The pad leaves this thread a few milliseconds per block, so + // it must not wait behind ordinary threads. + let _rt = crate::audio::pipeline::RtThread::promote( + "offload", + pad_frames as u32, + sample_rate, + ); + let mut scratch = vec![0.0f32; MAX_BUFFER_FRAMES * width]; + let mut out = Vec::with_capacity(MAX_BUFFER_FRAMES * width); while !stop_thread.load(Ordering::Relaxed) { let avail = worker_in.slots(); let avail = avail - avail % width; // whole frames only @@ -149,6 +176,7 @@ impl Offload { stop, join: Some(join), width, + pad_frames, }) } @@ -162,7 +190,7 @@ impl Offload { // A starve leaves the return ring permanently deeper than the pad; trim // back so the declared latency stays true. - let pad = PAD_FRAMES * self.width; + let pad = self.pad_frames * self.width; let avail = self.from_worker.slots(); if avail > want + pad { let excess = avail - want - pad; @@ -196,7 +224,7 @@ impl Offload { } pub fn latency_frames(&self) -> usize { - PAD_FRAMES + self.pad_frames } } @@ -236,69 +264,53 @@ mod tests { } } - #[test] - fn offload_roundtrip_delays_by_pad() { - let Ok(mut offload) = Offload::spawn("test", Passthrough, 2) else { + fn roundtrip(width: usize, block: usize, pad: usize) { + let Ok(mut offload) = Offload::spawn("test", Passthrough, width, pad, 48_000) else { panic!("spawn offload") }; + assert_eq!(offload.latency_frames(), pad); let mut fed = Vec::new(); let mut got = Vec::new(); - let mut counter = 0.0f32; - - for _ in 0..8 { - let mut block = vec![0.0f32; DSP_BLOCK_FRAMES * 2]; - for f in 0..DSP_BLOCK_FRAMES { - block[f * 2] = counter; - block[f * 2 + 1] = counter; + let mut counter = 1.0f32; + let blocks = (4 * pad / block).max(8); + for _ in 0..blocks { + let mut data = vec![0.0f32; block * width]; + for frame in data.chunks_exact_mut(width) { + frame.fill(counter); counter += 1.0; } - fed.extend_from_slice(&block); - offload.process(&mut block); - got.extend_from_slice(&block); - wait_for_return(&offload, block.len()); + fed.extend_from_slice(&data); + offload.process(&mut data); + got.extend_from_slice(&data); + wait_for_return(&offload, data.len()); } - let pad = PAD_FRAMES * 2; - for (i, &v) in got.iter().enumerate().take(pad) { - assert_eq!(v, 0.0, "expected zero pad at index {i}"); + let shift = pad * width; + assert!(got[..shift].iter().all(|&v| v == 0.0), "pad is silence"); + for i in shift..got.len() { + assert_eq!(got[i], fed[i - shift], "{block}/{pad}: mismatch at {i}"); } - for i in pad..got.len() { - assert_eq!(got[i], fed[i - pad], "mismatch at index {i}"); + } + + #[test] + fn offload_delays_by_exactly_its_pad_at_every_block_size() { + for block in crate::audio::graph::BUFFER_FRAME_OPTIONS.map(|n| n as usize) { + roundtrip(2, block, pad_frames(48_000, block, 0)); } } #[test] fn offload_roundtrip_handles_wide_blocks() { - const WIDTH: usize = 6; - let Ok(mut offload) = Offload::spawn("test-wide", Passthrough, WIDTH) else { - panic!("spawn offload") - }; - - let mut fed = Vec::new(); - let mut got = Vec::new(); - let mut counter = 0.0f32; - - for _ in 0..8 { - let mut block = vec![0.0f32; DSP_BLOCK_FRAMES * WIDTH]; - for f in 0..DSP_BLOCK_FRAMES { - for c in 0..WIDTH { - block[f * WIDTH + c] = counter; - } - counter += 1.0; - } - fed.extend_from_slice(&block); - offload.process(&mut block); - got.extend_from_slice(&block); - wait_for_return(&offload, block.len()); - } + roundtrip(6, 1024, 1024); + } - let pad = PAD_FRAMES * WIDTH; - for (i, &v) in got.iter().enumerate().take(pad) { - assert_eq!(v, 0.0, "expected zero pad at index {i}"); - } - for i in pad..got.len() { - assert_eq!(got[i], fed[i - pad], "mismatch at index {i}"); - } + #[test] + fn pad_never_drops_below_the_block_or_the_floor() { + assert_eq!(pad_frames(48_000, 32, 0), 256, "5 ms turnaround at 48k"); + assert_eq!(pad_frames(96_000, 32, 0), 512); + assert_eq!(pad_frames(44_100, 64, 0), 256); + assert_eq!(pad_frames(48_000, 2048, 0), 2048, "never below the block"); + assert_eq!(pad_frames(48_000, 64, 480), 480, "effect's own floor"); } } diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index bee17120..a0983bf4 100644 --- a/src-tauri/src/audio/engine.rs +++ b/src-tauri/src/audio/engine.rs @@ -68,9 +68,9 @@ pub enum Command { IsRunning { reply: Sender, }, - /// Current speaker output buffering latency in milliseconds (0 when idle). - OutputLatencyMs { - reply: Sender, + /// Round-trip latency, DSP load and node timings (empty when idle). + LatencyReport { + reply: Sender, }, } @@ -173,9 +173,12 @@ pub fn run(rx: Receiver) { Command::IsRunning { reply } => { let _ = reply.send(active.is_some()); } - Command::OutputLatencyMs { reply } => { - let ms = active.as_ref().map(|p| p.output_latency_ms()).unwrap_or(0); - let _ = reply.send(ms); + Command::LatencyReport { reply } => { + let report = active + .as_mut() + .map(|p| p.latency_report()) + .unwrap_or_default(); + let _ = reply.send(report); } } } @@ -218,12 +221,12 @@ mod tests { rx.recv_timeout(Duration::from_secs(5)).expect("reply") } - fn output_latency(&self) -> u32 { + fn latency_report(&self) -> crate::audio::pipeline::LatencyReport { let (reply, rx) = channel(); self.tx .as_ref() .expect("live sender") - .send(Command::OutputLatencyMs { reply }) + .send(Command::LatencyReport { reply }) .expect("send"); rx.recv_timeout(Duration::from_secs(5)).expect("reply") } @@ -242,7 +245,7 @@ mod tests { fn idle_engine_reports_not_running_and_zero_latency() { let harness = EngineHarness::spawn(); assert!(!harness.is_running()); - assert_eq!(harness.output_latency(), 0); + assert_eq!(harness.latency_report().path, None); } #[test] diff --git a/src-tauri/src/audio/graph.rs b/src-tauri/src/audio/graph.rs index ccb70058..a88e01f5 100644 --- a/src-tauri/src/audio/graph.rs +++ b/src-tauri/src/audio/graph.rs @@ -7,13 +7,25 @@ use ts_rs::TS; use crate::error::{AppError, AppResult}; #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GraphSpec { pub nodes: Vec, pub edges: Vec, #[serde(default)] pub sample_rate: Option, + /// Engine buffer in frames: the device IO size and the block a speaker + /// graph renders per callback. + #[serde(default)] + pub buffer_frames: Option, } +/// Engine buffer sizes the settings offer. Powers of two, so device buffers, +/// FFT-based stages and plugin hosts all accept them. +pub const BUFFER_FRAME_OPTIONS: [u32; 7] = [32, 64, 128, 256, 512, 1024, 2048]; +pub const DEFAULT_BUFFER_FRAMES: u32 = 256; +/// Largest engine block; buffers that must hold any block are sized from it. +pub const MAX_BUFFER_FRAMES: usize = 2048; + #[derive(Debug, Deserialize)] pub struct NodeSpec { pub id: String, @@ -714,6 +726,7 @@ pub struct ValidGraph { pub effects: Vec, pub edges: Vec, pub sample_rate: u32, + pub buffer_frames: u32, } /// One node of the expanded graph. A dual-role UI node appears once per role it @@ -927,12 +940,23 @@ impl GraphSpec { None => 48_000, }; + let buffer_frames = match self.buffer_frames { + Some(n) if !BUFFER_FRAME_OPTIONS.contains(&n) => { + return Err(AppError::Validation(format!( + "buffer size {n} is not one of {BUFFER_FRAME_OPTIONS:?}" + ))); + } + Some(n) => n, + None => DEFAULT_BUFFER_FRAMES, + }; + Ok(ValidGraph { inputs, outputs, effects, edges, sample_rate, + buffer_frames, }) } } @@ -1370,6 +1394,7 @@ mod tests { fn send_only_collaborator_is_an_output() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), collab("w")], edges: vec![edge("e", "m", None, "w", Some("ch1"))], }; @@ -1393,6 +1418,7 @@ mod tests { fn recv_only_collaborator_is_an_input() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![collab("w"), speaker("s")], edges: vec![edge("e", "w", Some("peer:p:0"), "s", None)], }; @@ -1414,6 +1440,7 @@ mod tests { fn duplex_collaborator_is_both() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), collab("w"), speaker("s")], edges: vec![ edge("e1", "m", None, "w", Some("ch1")), @@ -1431,6 +1458,7 @@ mod tests { fn unwired_collaborator_is_not_a_routing_error() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![collab("w")], edges: vec![], }; @@ -1445,6 +1473,7 @@ mod tests { fn unrouted_output_is_valid_and_streams_silence() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![speaker("s")], edges: vec![], }; @@ -1462,6 +1491,7 @@ mod tests { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![gain("g"), speaker("s")], edges: vec![edge("e", "g", None, "s", None)], }; @@ -1480,6 +1510,7 @@ mod tests { fn default_sample_rate_is_48000() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![speaker("s")], edges: vec![], }; @@ -1487,11 +1518,67 @@ mod tests { assert_eq!(v.sample_rate, 48_000); } + // The frontend sends the engine format camelCased next to the graph. + #[test] + fn engine_format_arrives_from_the_frontend_payload() { + let g: GraphSpec = serde_json::from_value(serde_json::json!({ + "nodes": [], + "edges": [], + "sampleRate": 96_000, + "bufferFrames": 64 + })) + .expect("payload parses"); + assert_eq!(g.sample_rate, Some(96_000)); + assert_eq!(g.buffer_frames, Some(64)); + } + + #[test] + fn default_buffer_is_256_frames() { + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![speaker("s")], + edges: vec![], + }; + assert_eq!(g.validate().expect("graph valid").buffer_frames, 256); + } + + #[test] + fn every_offered_buffer_size_is_accepted() { + for n in BUFFER_FRAME_OPTIONS { + let g = GraphSpec { + sample_rate: None, + buffer_frames: Some(n), + nodes: vec![speaker("s")], + edges: vec![], + }; + assert_eq!(g.validate().expect("graph valid").buffer_frames, n); + } + assert_eq!( + *BUFFER_FRAME_OPTIONS.last().unwrap() as usize, + MAX_BUFFER_FRAMES + ); + } + + #[test] + fn unsupported_buffer_size_is_a_validation_error() { + for n in [0, 1, 48, 100, 4096] { + let g = GraphSpec { + sample_rate: None, + buffer_frames: Some(n), + nodes: vec![speaker("s")], + edges: vec![], + }; + assert!(g.validate().is_err(), "{n} frames must be rejected"); + } + } + #[test] fn custom_sample_rate_is_preserved() { for sr in [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 384_000] { let g = GraphSpec { sample_rate: Some(sr), + buffer_frames: None, nodes: vec![speaker("s")], edges: vec![], }; @@ -1505,6 +1592,7 @@ mod tests { for sr in [0, 4_000, 7_999, 384_001, 1_000_000] { let g = GraphSpec { sample_rate: Some(sr), + buffer_frames: None, nodes: vec![speaker("s")], edges: vec![], }; @@ -1516,6 +1604,7 @@ mod tests { fn edge_to_unknown_node_is_rejected() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m")], edges: vec![edge("e", "m", None, "ghost", None)], }; @@ -1526,6 +1615,7 @@ mod tests { fn duplicate_graph_ids_are_rejected_before_routing() { let duplicate_nodes = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("same"), speaker("same")], edges: vec![], }; @@ -1534,6 +1624,7 @@ mod tests { let duplicate_edges = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), speaker("s")], edges: vec![ edge("same", "m", None, "s", Some("ch1")), @@ -1548,6 +1639,7 @@ mod tests { fn edge_into_input_is_rejected() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), mic("m2")], edges: vec![edge("e", "m", None, "m2", None)], }; @@ -1559,6 +1651,7 @@ mod tests { fn edge_from_output_is_rejected() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![speaker("s"), speaker("s2")], edges: vec![edge("e", "s", None, "s2", None)], }; @@ -1572,6 +1665,7 @@ mod tests { fn no_destination_at_all_is_rejected() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g")], edges: vec![edge("e", "m", None, "g", None)], }; @@ -1583,6 +1677,7 @@ mod tests { fn cycle_between_effects_is_rejected() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g1"), gain_node("g2"), speaker("s")], edges: vec![ edge("e1", "m", None, "g1", None), @@ -1601,6 +1696,7 @@ mod tests { mic_node.data = serde_json::json!({}); // no deviceId let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic_node, speaker("s")], edges: vec![edge("e", "m", None, "s", None)], }; @@ -1614,6 +1710,7 @@ mod tests { mic_node.data = serde_json::json!({}); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic_node, mic("m2"), speaker("s")], edges: vec![edge("e1", "m2", None, "s", None)], }; @@ -1628,6 +1725,7 @@ mod tests { sp.data = serde_json::json!({}); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), sp, speaker("s2")], edges: vec![edge("e2", "m", None, "s2", None)], }; @@ -1642,6 +1740,7 @@ mod tests { sp.data = serde_json::json!({}); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), sp], edges: vec![edge("e", "m", None, "s", None)], }; @@ -1654,6 +1753,7 @@ mod tests { let sys = node("sys", NodeKind::SystemAudio, serde_json::json!({})); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![sys, speaker("s")], edges: vec![edge("e", "sys", None, "s", None)], }; @@ -1681,6 +1781,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), rec], edges: vec![edge("e", "m", None, "rec", None)], }; @@ -1703,6 +1804,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), rec], edges: vec![edge("e", "m", None, "rec", None)], }; @@ -1724,6 +1826,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), rec], edges: vec![edge("e", "m", None, "rec", None)], }; @@ -1747,6 +1850,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), rec], edges: vec![edge("e", "m", None, "rec", None)], }; @@ -1765,6 +1869,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), rec], edges: vec![edge("e", "m", None, "rec", None)], }; @@ -1787,6 +1892,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), rec], edges: vec![edge("e", "m", None, "rec", None)], }; @@ -1809,6 +1915,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), bad], edges: vec![edge("e", "m", None, "net", Some("ch1"))], }; @@ -1829,6 +1936,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), good], edges: vec![edge("e", "m", None, "net", Some("ch1"))], }; @@ -1856,6 +1964,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), good], edges: vec![edge("e", "m", None, "net", Some("ch1"))], }; @@ -1874,6 +1983,7 @@ mod tests { let bad_gain = node("g", NodeKind::Gain, serde_json::json!({ "gainDb": "loud" })); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![bad_gain, speaker("s")], edges: vec![edge("e", "g", None, "s", None)], }; @@ -1895,6 +2005,7 @@ mod tests { ); let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![plugin, speaker("s")], edges: vec![edge("e", "p", None, "s", None)], }; diff --git a/src-tauri/src/audio/health.rs b/src-tauri/src/audio/health.rs index e344476c..2e939375 100644 --- a/src-tauri/src/audio/health.rs +++ b/src-tauri/src/audio/health.rs @@ -19,8 +19,6 @@ macro_rules! counters { } counters! { - /// Samples zero-filled into a device output because the ring ran dry. - OUTPUT_UNDERRUN_SAMPLES, /// Samples dropped by the capture broadcast into a per-output input ring /// (input_bridge.rs) -- a DSP source fell behind the capture callback. CAPTURE_RING_OVERRUN_SAMPLES, @@ -29,9 +27,9 @@ counters! { /// Samples dropped by a DAG fan-out tap or wire-sender push (dag.rs) -- /// another output or a wire consumer fell behind this node's block rate. TAP_RING_OVERRUN_SAMPLES, - /// Samples dropped pushing into the speaker worker's output ring - /// (pipeline/output/mod.rs) -- the cpal callback fell behind the worker. - SPEAKER_RING_OVERRUN_SAMPLES, + /// Speaker blocks the output resampler failed to convert; the device + /// played silence for them (pipeline/output/render.rs). + SPEAKER_RENDER_FAILED_BLOCKS, /// Input samples discarded by a source's backlog trim (SourceState::fill_block). SOURCE_TRIM_DROPPED_SAMPLES, /// Samples dropped by a StagingRing overrun (producer outran the drain). @@ -88,11 +86,10 @@ mod tests { assert_eq!( names, [ - "OUTPUT_UNDERRUN_SAMPLES", "CAPTURE_RING_OVERRUN_SAMPLES", "NET_RING_OVERRUN_SAMPLES", "TAP_RING_OVERRUN_SAMPLES", - "SPEAKER_RING_OVERRUN_SAMPLES", + "SPEAKER_RENDER_FAILED_BLOCKS", "SOURCE_TRIM_DROPPED_SAMPLES", "STAGING_OVERRUN_SAMPLES", "CLOCK_LATE_BLOCKS", diff --git a/src-tauri/src/audio/input_bridge.rs b/src-tauri/src/audio/input_bridge.rs index f382a3b5..1939adb9 100644 --- a/src-tauri/src/audio/input_bridge.rs +++ b/src-tauri/src/audio/input_bridge.rs @@ -284,6 +284,19 @@ impl BroadcastRx { mod tests { use super::*; + /// Drains up to `dst.len()` samples, zero-filling the rest. + fn pop_into(cons: &mut Consumer, dst: &mut [f32]) -> usize { + let n = dst.len().min(cons.slots()); + if let Ok(chunk) = cons.read_chunk(n) { + let (a, b) = chunk.as_slices(); + dst[..a.len()].copy_from_slice(a); + dst[a.len()..n].copy_from_slice(b); + chunk.commit_all(); + } + dst[n..].fill(0.0); + n + } + #[test] fn add_remove_roundtrip_bridges_audio() { let (mut tx, mut rx) = broadcast_channel(); @@ -299,7 +312,7 @@ mod tests { assert_eq!(stats.fed.load(Ordering::Relaxed), 512); assert_eq!(stats.dropped.load(Ordering::Relaxed), 0); let mut out = vec![0.0f32; 512]; - let n = crate::audio::streams::bulk_pop(&mut cons, &mut out); + let n = pop_into(&mut cons, &mut out); assert_eq!(n, 512); assert_eq!(out, block); @@ -323,7 +336,7 @@ mod tests { assert_eq!(stats.fed.load(Ordering::Relaxed), 8); assert_eq!(stats.dropped.load(Ordering::Relaxed), 56); let mut out = vec![0.0f32; 8]; - assert_eq!(crate::audio::streams::bulk_pop(&mut cons, &mut out), 8); + assert_eq!(pop_into(&mut cons, &mut out), 8); assert_eq!(out, vec![1.0; 8]); } @@ -382,7 +395,7 @@ mod tests { let block = vec![0.5f32; 512]; rx.broadcast_blocking(&block, &stop, &paused, Duration::from_micros(100)); let mut out = vec![0.0f32; 512]; - let n = crate::audio::streams::bulk_pop(&mut cons, &mut out); + let n = pop_into(&mut cons, &mut out); assert_eq!(n, 512); assert_eq!(out, block); @@ -395,7 +408,7 @@ mod tests { Duration::from_millis(1), ); let mut stopped = vec![0.0f32; 512]; - assert_eq!(crate::audio::streams::bulk_pop(&mut cons, &mut stopped), 0); + assert_eq!(pop_into(&mut cons, &mut stopped), 0); } #[test] diff --git a/src-tauri/src/audio/macos_hal.rs b/src-tauri/src/audio/macos_hal.rs index deddf485..79b1fc74 100644 --- a/src-tauri/src/audio/macos_hal.rs +++ b/src-tauri/src/audio/macos_hal.rs @@ -792,3 +792,201 @@ pub fn set_device_volume(kind: crate::audio::device::DeviceKind, name: &str, sca l || r } } + +const K_AUDIO_DEVICE_PROPERTY_BUFFER_FRAME_SIZE: AudioObjectPropertySelector = fourcc(b"fsiz"); +const K_AUDIO_DEVICE_PROPERTY_BUFFER_FRAME_SIZE_RANGE: AudioObjectPropertySelector = + fourcc(b"fsz#"); +const K_AUDIO_DEVICE_PROPERTY_SAFETY_OFFSET: AudioObjectPropertySelector = fourcc(b"saft"); +/// Shared by devices and streams (`kAudioDevicePropertyLatency`, +/// `kAudioStreamPropertyLatency`). +const K_AUDIO_PROPERTY_LATENCY: AudioObjectPropertySelector = fourcc(b"ltnc"); + +#[repr(C)] +#[derive(Copy, Clone, Default)] +struct AudioValueRange { + minimum: f64, + maximum: f64, +} + +unsafe fn read_u32( + object: AudioObjectID, + selector: AudioObjectPropertySelector, + scope: AudioObjectPropertyScope, +) -> Option { + let addr = AudioObjectPropertyAddress { + selector, + scope, + element: K_AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN, + }; + if AudioObjectHasProperty(object, &addr) == 0 { + return None; + } + let mut value: u32 = 0; + let mut size = mem::size_of::() as u32; + (AudioObjectGetPropertyData( + object, + &addr, + 0, + ptr::null(), + &mut size, + &mut value as *mut _ as *mut c_void, + ) == 0) + .then_some(value) +} + +unsafe fn first_stream(device_id: AudioObjectID, scope: AudioObjectPropertyScope) -> Option { + let addr = AudioObjectPropertyAddress { + selector: K_AUDIO_DEVICE_PROPERTY_STREAMS, + scope, + element: K_AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN, + }; + let mut size: u32 = 0; + if AudioObjectGetPropertyDataSize(device_id, &addr, 0, ptr::null(), &mut size) != 0 { + return None; + } + let count = size as usize / mem::size_of::(); + if count == 0 { + return None; + } + let mut ids = vec![0u32; count]; + let mut io_size = size; + (AudioObjectGetPropertyData( + device_id, + &addr, + 0, + ptr::null(), + &mut io_size, + ids.as_mut_ptr() as *mut c_void, + ) == 0) + .then(|| ids[0]) +} + +/// What CoreAudio adds between the IO callback and the converter, one +/// direction of one device, in device frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IoLatency { + /// The IO buffer the callback fills or drains. + pub buffer_frames: u32, + /// Safety offset, device latency and stream latency: everything past the + /// buffer that the hardware and driver add. + pub hardware_frames: u32, +} + +/// Asks the device to run its IO in `frames`-frame buffers, clamped to the +/// range it supports, and returns the size it actually runs at. The request +/// holds for this process only; other clients keep their own. +pub fn set_buffer_frames( + kind: crate::audio::device::DeviceKind, + name: &str, + frames: u32, +) -> Option { + let scope = scope_for(kind); + let id = find_device_id(name, scope)?; + unsafe { + let range_addr = AudioObjectPropertyAddress { + selector: K_AUDIO_DEVICE_PROPERTY_BUFFER_FRAME_SIZE_RANGE, + scope: K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL, + element: K_AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN, + }; + let mut range = AudioValueRange::default(); + let mut size = mem::size_of::() as u32; + let want = if AudioObjectGetPropertyData( + id, + &range_addr, + 0, + ptr::null(), + &mut size, + &mut range as *mut _ as *mut c_void, + ) == 0 + && range.maximum >= range.minimum + && range.minimum > 0.0 + { + (frames as f64).clamp(range.minimum, range.maximum) as u32 + } else { + frames + }; + let addr = AudioObjectPropertyAddress { + selector: K_AUDIO_DEVICE_PROPERTY_BUFFER_FRAME_SIZE, + scope: K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL, + element: K_AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN, + }; + let mut settable: u8 = 0; + if AudioObjectIsPropertySettable(id, &addr, &mut settable) == 0 && settable != 0 { + let status = AudioObjectSetPropertyData( + id, + &addr, + 0, + ptr::null(), + mem::size_of::() as u32, + &want as *const _ as *const c_void, + ); + if status != 0 { + tracing::warn!( + device = name, + frames = want, + status, + "device refused buffer size" + ); + } + } + read_u32( + id, + K_AUDIO_DEVICE_PROPERTY_BUFFER_FRAME_SIZE, + K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL, + ) + } +} + +/// The device's current buffer plus everything it adds past it, for one +/// direction. +pub fn io_latency(kind: crate::audio::device::DeviceKind, name: &str) -> Option { + let scope = scope_for(kind); + let id = find_device_id(name, scope)?; + unsafe { + let buffer_frames = read_u32( + id, + K_AUDIO_DEVICE_PROPERTY_BUFFER_FRAME_SIZE, + K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL, + )?; + let safety = read_u32(id, K_AUDIO_DEVICE_PROPERTY_SAFETY_OFFSET, scope).unwrap_or(0); + let device = read_u32(id, K_AUDIO_PROPERTY_LATENCY, scope).unwrap_or(0); + let stream = first_stream(id, scope) + .and_then(|s| { + read_u32( + s, + K_AUDIO_PROPERTY_LATENCY, + K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL, + ) + }) + .unwrap_or(0); + Some(IoLatency { + buffer_frames, + hardware_frames: safety.saturating_add(device).saturating_add(stream), + }) + } +} + +#[cfg(test)] +mod io_tests { + use super::*; + use crate::audio::device::DeviceKind; + + #[test] + #[ignore = "needs a real output device"] + fn buffer_size_is_set_and_read_back() { + let dev = list_output_devices() + .into_iter() + .next() + .expect("an output device"); + let before = io_latency(DeviceKind::Output, &dev.name).expect("latency readable"); + println!("{}: {before:?}", dev.name); + let got = set_buffer_frames(DeviceKind::Output, &dev.name, 64).expect("buffer readable"); + println!("asked 64, runs {got}"); + assert!(got <= before.buffer_frames.max(64), "request ignored"); + let after = io_latency(DeviceKind::Output, &dev.name).unwrap(); + assert_eq!(after.buffer_frames, got); + let huge = set_buffer_frames(DeviceKind::Output, &dev.name, 1_000_000).unwrap(); + println!("asked 1e6, runs {huge}"); + assert!(huge < 1_000_000, "clamped to the device range"); + } +} diff --git a/src-tauri/src/audio/netaudio/receiver.rs b/src-tauri/src/audio/netaudio/receiver.rs index 21272dc1..ed86b073 100644 --- a/src-tauri/src/audio/netaudio/receiver.rs +++ b/src-tauri/src/audio/netaudio/receiver.rs @@ -149,8 +149,14 @@ pub fn get_or_create(node_id: &str, port: u16) -> Arc { impl NetReceiver { /// New output subgraph consumer at `output_sr`; wired to every live channel. - pub fn register_consumer(&self, output_sr: u32, realtime: bool) -> ConsumerHandle { - self.fanout.register_consumer(output_sr, realtime) + pub fn register_consumer( + &self, + output_sr: u32, + block_frames: usize, + realtime: bool, + ) -> ConsumerHandle { + self.fanout + .register_consumer(output_sr, block_frames, realtime) } fn stop(&self) { @@ -315,7 +321,7 @@ mod tests { fn datagram_scenario(node: &str) -> Result<(), String> { let port = free_port()?; let rx = get_or_create(node, port); - let consumer = rx.register_consumer(48_000, true); + let consumer = rx.register_consumer(48_000, 1024, true); let taps = consumer.taps.clone(); let recv = crate::audio::stream_recv::ChannelReceiver::new(consumer); diff --git a/src-tauri/src/audio/pipeline/cushion.rs b/src-tauri/src/audio/pipeline/cushion.rs new file mode 100644 index 00000000..92f9659c --- /dev/null +++ b/src-tauri/src/audio/pipeline/cushion.rs @@ -0,0 +1,359 @@ +//! How much captured audio a live source keeps queued ahead of the graph. +//! +//! A capture device hands audio over in bursts on its own clock while the +//! graph takes one block per output callback, so the queue saws up and down. +//! `DepthEstimator` sizes the headroom from that saw; `Cushion` holds the +//! queue's mean there by splicing a few frames out (capture clock faster, or +//! startup backlog) or stretching a few in (capture clock slower), sparsely +//! and with crossfades, so the correction is inaudible and never adds latency +//! of its own. + +use crate::audio::adaptive_depth::DepthEstimator; + +/// Correction the source should apply before reading its next block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Adjust { + None, + /// Splice this many frames out of the queue. + Drop(usize), + /// Stretch the queue by repeating this many frames. + Insert(usize), +} + +/// Largest single splice. Bigger corrections are spread over several. +pub(super) const MAX_SPLICE_FRAMES: usize = 64; + +/// One dip measurement spans this long; it has to cover the slowest capture +/// burst (Bluetooth and ScreenCaptureKit deliver every ~20 ms). +const WINDOW_MS: f64 = 250.0; +/// Headroom assumed before anything is measured: a typical 512-frame capture +/// buffer at 48 kHz. +const PRIOR_MS: f64 = 10.0; +/// Kept above the measured dip at all times. +const SAFETY_MS: f64 = 1.0; +/// One splice per this much audio at most, so time is never compressed or +/// stretched by more than ~6% while a correction runs. +const SPLICE_EVERY_MS: f64 = 21.0; + +pub(super) struct Cushion { + need: usize, + safety: usize, + depth: DepthEstimator, + sum: f64, + blocks: usize, + low: usize, + /// Correction still owed: negative drops, positive inserts. + owed: i64, + cooldown: usize, + cooldown_blocks: usize, + primed: bool, + /// Frames missed since the queue last ran dry, while it refills. + outage: Option, +} + +impl Cushion { + /// `need` is the frames the graph takes per block, in the queue's rate. + pub(super) fn new(need: usize, sample_rate: u32) -> Self { + let frames = |ms: f64| ((sample_rate as f64 * ms / 1000.0).round() as usize).max(1); + Self { + need, + safety: frames(SAFETY_MS).max(16), + depth: DepthEstimator::new(sample_rate, need, WINDOW_MS, frames(PRIOR_MS)), + sum: 0.0, + blocks: 0, + low: usize::MAX, + owed: 0, + cooldown: 0, + cooldown_blocks: (frames(SPLICE_EVERY_MS) / need.max(1)).max(1), + primed: false, + outage: None, + } + } + + /// Lowest the queue may sit just before a read. One block is the read; + /// a second covers phase: the queue is only sampled at reads, so as the + /// two clocks slide past each other its sampled low jumps by up to a + /// block from one capture burst to the next. + fn floor(&self) -> usize { + 2 * self.need + self.safety + } + + /// Mean queue depth to hold: the floor plus the measured dip. + pub(super) fn target(&self) -> usize { + self.floor() + self.depth.depth() + } + + /// Before the source's first read, and again after it ran dry. `None` + /// while the queue is still filling: play silence for this block. Once it + /// reaches the target, everything beyond it is backlog nobody has heard, + /// so it can go at once: returns the frames to discard. + /// + /// A refill after running dry is one outage, however many blocks it + /// spans; it is charged to the depth once, sized by everything missed. + pub(super) fn prime(&mut self, queued: usize) -> Option { + if queued < self.target() { + if let Some(missed) = self.outage.as_mut() { + *missed += self.need; + } + return None; + } + if let Some(missed) = self.outage.take() { + self.depth.underrun(missed); + } + self.primed = true; + self.owed = 0; + self.sum = 0.0; + self.blocks = 0; + self.low = usize::MAX; + Some(queued.saturating_sub(self.target())) + } + + pub(super) fn is_primed(&self) -> bool { + self.primed + } + + /// The graph ran `missing` frames dry mid-block: refill before playing + /// on, rather than limp along a starved queue one click per block. + pub(super) fn underrun(&mut self, missing: usize) { + self.primed = false; + self.outage = Some(self.outage.unwrap_or(0) + missing); + } + + /// Asymmetric on purpose. Running short is an audible dropout, so any + /// window whose emptiest point ate into half the safety is topped up. + /// Running long only costs latency, so the mean may overshoot by a quarter + /// of the headroom before frames are dropped; the saw never triggers it. + fn correction(&self, mean: f64, low: usize) -> i64 { + let floor = self.floor(); + if low + self.safety / 2 < floor { + return (floor - low) as i64; + } + let over = mean - self.target() as f64; + let slack = (self.depth.depth() / 4).max(self.safety) as f64; + if over > slack { + // Never cut below the floor at the window's emptiest point. + return -(over.min((low - floor) as f64).round() as i64); + } + 0 + } + + /// Call once per block with the frames queued before the read. Returns the + /// splice to perform now, if one is due and the queue can afford it. + pub(super) fn observe(&mut self, queued: usize) -> Adjust { + self.sum += queued as f64; + self.blocks += 1; + self.low = self.low.min(queued); + if self.depth.observe(queued) { + let mean = self.sum / self.blocks as f64; + let low = self.low; + self.sum = 0.0; + self.blocks = 0; + self.low = usize::MAX; + if self.owed == 0 { + self.owed = self.correction(mean, low); + } + } + if self.cooldown > 0 { + self.cooldown -= 1; + return Adjust::None; + } + let fade = super::dag::SPLICE_FADE_FRAMES; + if self.owed < 0 { + let n = ((-self.owed) as usize).min(MAX_SPLICE_FRAMES); + // A drop reads a fade on each side of the cut and must leave the + // block itself behind. + if queued >= n + 2 * fade + self.need { + self.owed += n as i64; + self.cooldown = self.cooldown_blocks; + return Adjust::Drop(n); + } + } else if self.owed > 0 { + let n = (self.owed as usize).min(MAX_SPLICE_FRAMES); + if queued >= n + fade { + self.owed -= n as i64; + self.cooldown = self.cooldown_blocks; + return Adjust::Insert(n); + } + } + Adjust::None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SR: u32 = 48_000; + + struct Run { + late_underruns: usize, + mean_queued: f64, + splices: usize, + max_queued: usize, + } + + /// Event-driven model of a capture device feeding the queue in bursts on + /// its own clock while an output device drains one block at a time. + /// `jitter` delays each capture burst by a pseudo-random share of it. + fn simulate( + block: usize, + burst: usize, + capture_ppm: f64, + jitter: f64, + initial_backlog: usize, + seconds: f64, + settle_s: f64, + ) -> Run { + let mut c = Cushion::new(block, SR); + let out_period = block as f64 / SR as f64; + let in_period = burst as f64 / (SR as f64 * (1.0 + capture_ppm * 1e-6)); + let mut queued = initial_backlog; + let mut next_in = in_period * 0.37; + let mut late = 0.0; + let mut t_out = 0.0; + let mut seed: u32 = 0x2545_f491; + let mut run = Run { + late_underruns: 0, + mean_queued: 0.0, + splices: 0, + max_queued: 0, + }; + let mut samples = 0usize; + while t_out < seconds { + if next_in + late <= t_out { + queued += burst; + next_in += in_period; + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + late = jitter * in_period * (seed % 1000) as f64 / 1000.0; + continue; + } + if !c.is_primed() { + match c.prime(queued) { + Some(excess) => queued -= excess, + None => { + t_out += out_period; + continue; + } + } + } + match c.observe(queued) { + Adjust::Drop(n) => { + queued -= n; + run.splices += 1; + } + Adjust::Insert(n) => { + queued += n; + run.splices += 1; + } + Adjust::None => {} + } + if queued < block { + if t_out > settle_s { + run.late_underruns += 1; + } + c.underrun(block - queued); + queued = 0; + } else { + queued -= block; + } + if t_out > settle_s { + run.mean_queued += queued as f64; + run.max_queued = run.max_queued.max(queued); + samples += 1; + } + t_out += out_period; + } + run.mean_queued /= samples.max(1) as f64; + run + } + + #[test] + fn startup_backlog_is_dropped_before_anything_plays() { + let mut c = Cushion::new(64, SR); + assert_eq!(c.prime(10), None, "still filling"); + assert!(!c.is_primed()); + let excess = c.prime(20_000).expect("primed"); + assert_eq!(20_000 - excess, c.target()); + assert!(c.is_primed()); + } + + #[test] + fn same_clock_settles_near_one_capture_burst() { + for (block, burst) in [(32, 512), (64, 512), (128, 256), (256, 480), (1024, 512)] { + let r = simulate(block, burst, 0.0, 0.0, 4_800, 30.0, 10.0); + assert_eq!( + r.late_underruns, 0, + "{block}/{burst}: underruns once settled" + ); + let bound = (burst + 2 * block + 2 * 48) as f64; + assert!( + r.mean_queued < bound, + "{block}/{burst}: mean queue {} frames, bound {bound}", + r.mean_queued + ); + } + } + + #[test] + fn drifting_capture_clock_never_starves_or_accumulates() { + // 200 ppm is far beyond real crystal error; 100 s drifts ~1000 frames. + for block in [32, 64, 256, 1024] { + for burst in [256, 512, 960] { + for ppm in [-200.0, -50.0, 50.0, 200.0] { + let r = simulate(block, burst, ppm, 0.0, 0, 100.0, 10.0); + let case = format!("{block}/{burst} @ {ppm} ppm"); + assert_eq!(r.late_underruns, 0, "{case}: underran"); + let bound = burst + 3 * block + 1_024; + assert!(r.max_queued < bound, "{case}: max queue {}", r.max_queued); + assert!(r.splices < 200, "{case}: {} splices", r.splices); + } + } + } + } + + #[test] + fn jittery_capture_buys_headroom_instead_of_glitching() { + // A loaded VM: every burst lands up to 80% of its period late. + let r = simulate(64, 480, 0.0, 0.8, 0, 120.0, 30.0); + assert_eq!(r.late_underruns, 0); + assert!(r.mean_queued < 3.0 * 480.0, "mean queue {}", r.mean_queued); + } + + #[test] + fn corrections_stay_sparse() { + let r = simulate(64, 512, 0.0, 0.0, 0, 60.0, 10.0); + assert!( + r.splices < 10, + "{} splices under matching clocks", + r.splices + ); + } + + #[test] + fn a_run_of_empty_blocks_is_one_outage() { + let mut c = Cushion::new(32, SR); + c.prime(c.target()).expect("primed"); + let before = c.target(); + c.underrun(32); + // 20 ms of silence while the capture refills. + for _ in 0..30 { + assert_eq!(c.prime(0), None); + } + let excess = c.prime(100_000).expect("refilled"); + let grown = c.target() - before; + let missed = 32 + 30 * 32; + assert!(grown >= missed, "depth must cover the outage: +{grown}"); + assert!(grown < 2 * missed, "one outage, not thirty: +{grown}"); + assert_eq!(100_000 - excess, c.target()); + } + + #[test] + fn a_splice_waits_until_the_queue_can_afford_it() { + let mut c = Cushion::new(64, SR); + c.owed = -64; + assert_eq!(c.observe(64), Adjust::None, "not enough queued to cut"); + assert_eq!(c.observe(64 + 64 + 2 * 32), Adjust::Drop(64)); + } +} diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index fcb3b60e..36a6fdbf 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -4,8 +4,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use rtrb::{Consumer, Producer, RingBuffer}; -use tracing::{info, warn}; +use super::cushion::{Adjust, Cushion, MAX_SPLICE_FRAMES}; +use super::latency::NodeTiming; use crate::audio::effects::{ instantiate_effect, update_meter, EffectControl, EffectRegistry, GrHandle, LufsHandle, MeterHandle, RuntimeEffect, WaveformHandle, @@ -27,7 +28,10 @@ pub(super) fn ring_capacity_frames(sample_rate: u32) -> usize { /// Block size used by the resampler. 256 frames @ 48 kHz ~ 5.3 ms. pub(super) const RESAMPLE_CHUNK: usize = 256; -pub const DSP_BLOCK_FRAMES: usize = 1024; +/// Block of the timer-paced workers (recording, monitoring, wire senders). +/// Nobody hears their latency, and a timer cannot pace small blocks reliably. +/// Speaker graphs run at the engine buffer size instead. +pub const TIMER_BLOCK_FRAMES: usize = 1024; const MAX_NET_CH: u32 = crate::audio::netaudio::MAX_CHANNELS as u32; @@ -38,14 +42,20 @@ const MAX_NET_CH: u32 = crate::audio::netaudio::MAX_CHANNELS as u32; /// ring buffer. const STALL_THRESHOLD: Duration = Duration::from_millis(150); -const SOURCE_BACKLOG_HIGH_BLOCKS: usize = 4; -const SOURCE_BACKLOG_LOW_BLOCKS: usize = 2; -/// Ceiling on one block's trim. Backlog drains over a few seconds instead of -/// vanishing in a single splice, which is what makes it inaudible. -const TRIM_MAX_FRAMES_PER_BLOCK: usize = 64; /// Crossfade length across a trim's cut. Long enough to kill the step, short /// enough that the replayed audio reads as texture rather than an echo. -const SPLICE_FADE_FRAMES: usize = 32; +pub(super) const SPLICE_FADE_FRAMES: usize = 32; + +/// A splice reads its span plus a fade on each side. +fn splice_samples(channels: usize) -> usize { + (MAX_SPLICE_FRAMES + 2 * SPLICE_FADE_FRAMES) * channels +} + +/// Holds a resampler chunk being gathered, or a splice's join, plus the rest +/// of the chunk the splice landed in. +fn staging_samples(channels: usize) -> usize { + (RESAMPLE_CHUNK + MAX_SPLICE_FRAMES + 2 * SPLICE_FADE_FRAMES) * channels + 8 +} /// Fixed-capacity FIFO; allocates once. Overrun clamps and counts drops -- /// wrapping the write head past the read head would corrupt subsequent pops. @@ -129,7 +139,7 @@ impl StagingRing { /// One node in an output's DAG. `Source` reads from a ring + resamples, /// `Effect` sums its upstreams' buffers and runs DSP, `Producer` emits /// network-received audio on named channel handles. Each exposes an -/// interleaved `out_buf` of `DSP_BLOCK_FRAMES * node_channels` that downstream +/// interleaved `out_buf` of `block_frames * node_channels` that downstream /// nodes consume. enum DagNode { Source(SourceState), @@ -184,6 +194,15 @@ pub(super) struct SourceStats { /// Ring occupancy (samples) at the end of the last `fill_block`. A gauge, /// not a counter -- plain `store`, no accumulation. pub level: Arc, + /// Smoothed frames queued ahead of the graph: the latency this source + /// adds. A gauge. + pub queue_frames: Arc, + /// Samples the cushion stretched in to cover a slower capture clock. + pub stretched: Arc, + /// Resampler chunks that failed and were dropped. + pub failed: Arc, + /// Set once the source has delivered audio; the tick thread logs it. + pub online: Arc, } impl SourceStats { @@ -194,6 +213,10 @@ impl SourceStats { trimmed: Arc::new(AtomicU64::new(0)), consumed: Arc::new(AtomicU64::new(0)), level: Arc::new(AtomicU64::new(0)), + queue_frames: Arc::new(AtomicU64::new(0)), + stretched: Arc::new(AtomicU64::new(0)), + failed: Arc::new(AtomicU64::new(0)), + online: Arc::new(AtomicBool::new(false)), } } } @@ -234,6 +257,7 @@ pub(super) struct OutputMeta { pub label: String, pub blocks: Arc, pub sample_rate: u32, + pub block_frames: usize, pub channels: usize, pub io: Option, } @@ -241,19 +265,23 @@ pub(super) struct OutputMeta { struct SourceState { label: String, channels: usize, + frames: usize, consumer: Consumer, resampler: Option, input_staging: Vec, - /// Holds a trim's crossfaded join until the refill path picks it up. + /// Holds a splice's crossfaded join until the refill path picks it up. splice_tmp: Vec, out_pending: StagingRing, chunk_tmp: Vec, out_buf: Vec, input_samples_per_block: usize, - realtime: bool, + /// Keeps a live source's queue at its measured headroom. `None` for file + /// sources, which are paced by backpressure and must not lose audio. + cushion: Option, + /// Smoothed frames left queued after each read: this source's latency. + queue_avg: f64, /// >STALL_THRESHOLD since last pop => zero-fill and stop waiting on this source. last_pop_at: Instant, - first_data_logged: bool, volume: Arc, paused: Option>, // u64 generation (not AtomicBool) so every output's SourceState detects the @@ -311,29 +339,22 @@ impl SourceState { return; } } - // Trim input backlog toward LOW so latency stays bounded, a slice per - // block and spliced rather than cut: drift needs a trickle, and one - // discard of hundreds of milliseconds is an audible tear. - if self.realtime { - let have = self.consumer.slots(); - let high = self.input_samples_per_block * SOURCE_BACKLOG_HIGH_BLOCKS; - if have > high { - let low = self.input_samples_per_block * SOURCE_BACKLOG_LOW_BLOCKS; - let fade = SPLICE_FADE_FRAMES * self.channels; - let budget = TRIM_MAX_FRAMES_PER_BLOCK * self.channels; - let excess = (have - low).min(budget); - let drop = excess - excess % self.channels; - // The splice reads a fade-out and a fade-in around the cut, so - // the ring has to hold both on top of what it discards. - if drop > 0 && have >= drop + 2 * fade { - self.splice_trim(drop, fade); - } + if self.cushion.is_some() && !self.regulate() { + // Filling before the first read, or refilling after running dry: + // silence that is not an underrun. + if self.is_stalled() { + self.stats + .stalled + .fetch_add(self.out_buf.len() as u64, Ordering::Relaxed); } + self.silence(); + self.publish_queue(); + return; } let need = self.out_buf.len(); let mut written = self.out_pending.pop_into(&mut self.out_buf[..]); while written < need { - self.try_refill_one_chunk(); + self.try_refill_one_chunk(need - written); if self.out_pending.len() == 0 { // Ring empty too -- zero-fill the rest (real underrun). for s in &mut self.out_buf[written..] { @@ -341,12 +362,17 @@ impl SourceState { } // A stalled/paused source silences by design; only a source that // is actively streaming and ran dry mid-block is a real xrun. - let counter = if self.is_stalled() { - &self.stats.stalled + let missing = need - written; + if self.is_stalled() { + self.stats + .stalled + .fetch_add(missing as u64, Ordering::Relaxed); } else { - &self.stats.xrun - }; - counter.fetch_add((need - written) as u64, Ordering::Relaxed); + self.stats.xrun.fetch_add(missing as u64, Ordering::Relaxed); + if let Some(c) = &mut self.cushion { + c.underrun(missing / self.channels); + } + } break; } let n = self.out_pending.pop_into(&mut self.out_buf[written..]); @@ -369,13 +395,13 @@ impl SourceState { if let Some(a) = parse_stereo(h) { let c0 = (a - 1).min(w - 1); let c1 = a.min(w - 1); - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..self.frames { buf[f * 2] = self.out_buf[f * w + c0]; buf[f * 2 + 1] = self.out_buf[f * w + c1]; } } else { let c = parse_ch(h).map(|k| (k - 1).min(w - 1)).unwrap_or(0); - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..self.frames { buf[f] = self.out_buf[f * w + c]; } } @@ -384,6 +410,99 @@ impl SourceState { self.stats .level .store(self.consumer.slots() as u64, Ordering::Relaxed); + self.publish_queue(); + } + + /// Frames waiting ahead of the graph: the ring plus anything already + /// pulled out of it but not yet played (in the ring's own rate). + fn queued_frames(&self) -> usize { + (self.consumer.slots() + self.input_staging.len() + self.out_pending.len()) / self.channels + } + + fn publish_queue(&mut self) { + let queued = self.queued_frames() as f64; + self.queue_avg += (queued - self.queue_avg) * 0.02; + self.stats + .queue_frames + .store(self.queue_avg.round() as u64, Ordering::Relaxed); + } + + /// Holds the queue at the cushion's target. False while priming: the + /// caller plays silence this block. + fn regulate(&mut self) -> bool { + let Some(c) = self.cushion.as_mut() else { + return true; + }; + let queued = (self.consumer.slots() + self.input_staging.len() + self.out_pending.len()) + / self.channels; + if !c.is_primed() { + match c.prime(queued) { + None => return false, + Some(excess) => self.discard(excess), + } + } + let queued = self.queued_frames(); + let fade = SPLICE_FADE_FRAMES * self.channels; + match self.cushion.as_mut().map(|c| c.observe(queued)) { + Some(Adjust::Drop(n)) => self.splice_trim(n * self.channels, fade), + Some(Adjust::Insert(n)) => self.splice_stretch(n * self.channels, fade), + _ => {} + } + true + } + + /// Drops `frames` of backlog outright. Only for audio nobody has heard: + /// the startup backlog and what piled up while a source was refilling. + fn discard(&mut self, frames: usize) { + let samples = (frames * self.channels).min(self.consumer.slots()); + let samples = samples - samples % self.channels; + if samples == 0 { + return; + } + if let Ok(chunk) = self.consumer.read_chunk(samples) { + chunk.commit_all(); + self.stats + .consumed + .fetch_add(samples as u64, Ordering::Relaxed); + self.stats + .trimmed + .fetch_add(samples as u64, Ordering::Relaxed); + self.last_pop_at = Instant::now(); + } + } + + /// Lengthens the stream by `insert` samples without a step: the next + /// `insert` samples play, then the stream replays from the start of that + /// stretch, the `fade` samples after the stretch crossfading into the + /// replay's first `fade`. Only the `fade` samples replayed are consumed. + fn splice_stretch(&mut self, insert: usize, fade: usize) { + self.splice_tmp.clear(); + { + let Ok(ahead) = self.consumer.read_chunk(insert + fade) else { + return; + }; + let (first, second) = ahead.as_slices(); + self.splice_tmp.extend_from_slice(first); + self.splice_tmp.extend_from_slice(second); + // Dropped uncommitted: peeked, not consumed. + } + let span = (SPLICE_FADE_FRAMES - 1).max(1) as f32; + for i in 0..fade { + let w = ((i / self.channels) as f32 / span).min(1.0); + let out = self.splice_tmp[insert + i]; + self.splice_tmp[insert + i] = out * (1.0 - w) + self.splice_tmp[i] * w; + } + if let Ok(replayed) = self.consumer.read_chunk(fade) { + replayed.commit_all(); + } + self.input_staging.extend_from_slice(&self.splice_tmp); + self.stats + .consumed + .fetch_add(fade as u64, Ordering::Relaxed); + self.stats + .stretched + .fetch_add(insert as u64, Ordering::Relaxed); + self.last_pop_at = Instant::now(); } /// Removes `drop` samples from the input ring, crossfading the `fade` @@ -421,7 +540,9 @@ impl SourceState { self.last_pop_at = Instant::now(); } - fn try_refill_one_chunk(&mut self) { + /// `want` is how many samples the block still needs. Without a resampler + /// exactly that is taken, so nothing sits pulled out of the ring unplayed. + fn try_refill_one_chunk(&mut self, want: usize) { if let Some(rs) = &mut self.resampler { let needed = rs.chunk_in() * self.channels; // Bulk read what we still need (one rtrb reservation instead of @@ -444,15 +565,18 @@ impl SourceState { return; } self.chunk_tmp.clear(); - if let Err(e) = rs.process_chunk(&self.input_staging[..needed], &mut self.chunk_tmp) { - warn!(source = %self.label, error = %e, "resampler chunk failed"); + if rs + .process_chunk(&self.input_staging[..needed], &mut self.chunk_tmp) + .is_err() + { + self.stats.failed.fetch_add(1, Ordering::Relaxed); self.input_staging.drain(..needed); return; } self.input_staging.drain(..needed); } else { self.chunk_tmp.clear(); - let mut want = RESAMPLE_CHUNK * self.channels; + let mut want = want.min(RESAMPLE_CHUNK * self.channels); // A splice staged its joined frames ahead of the ring. if !self.input_staging.is_empty() { let n = self.input_staging.len().min(want); @@ -478,10 +602,7 @@ impl SourceState { let frames = self.chunk_tmp.len() / self.channels; self.chunk_tmp.truncate(frames * self.channels); if !self.chunk_tmp.is_empty() { - if !self.first_data_logged { - info!(source = %self.label, "source online"); - self.first_data_logged = true; - } + self.stats.online.store(true, Ordering::Relaxed); self.out_pending.extend_from_slice(&self.chunk_tmp); } } @@ -645,10 +766,11 @@ fn edge_channels( node_channels: &[usize], idx: usize, source_handle: Option<&str>, + frames: usize, ) -> usize { match source_handle { Some(h) if tap_handle_width(h).is_some() => { - nodes[idx].out_buf_for_handle(Some(h)).len() / DSP_BLOCK_FRAMES + nodes[idx].out_buf_for_handle(Some(h)).len() / frames } _ => node_channels[idx], } @@ -681,11 +803,11 @@ fn target_route(handle: &str) -> Option<(usize, usize)> { /// Sum `src` into `dst` mapping channel-for-channel when the two have different /// widths (min of the two; extra source channels dropped, extra dest channels -/// left untouched). Widths are inferred from length: `len / DSP_BLOCK_FRAMES`. +/// left untouched). Widths are inferred from length: `len / frames`. #[inline] -fn add_mapped(src: &[f32], dst: &mut [f32]) { - let src_ch = src.len() / DSP_BLOCK_FRAMES; - let dst_ch = dst.len() / DSP_BLOCK_FRAMES; +fn add_mapped(src: &[f32], dst: &mut [f32], frames: usize) { + let src_ch = src.len() / frames; + let dst_ch = dst.len() / frames; if src_ch == 0 || dst_ch == 0 { return; } @@ -697,7 +819,7 @@ fn add_mapped(src: &[f32], dst: &mut [f32]) { } if dst_ch == 1 { let g = 1.0 / src_ch as f32; - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..frames { let sb = f * src_ch; let mut acc = 0.0; for c in 0..src_ch { @@ -709,7 +831,7 @@ fn add_mapped(src: &[f32], dst: &mut [f32]) { } if src_ch == 1 { // Mono upmix: a single-channel source feeds every destination channel. - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..frames { let v = src[f]; let db = f * dst_ch; for c in 0..dst_ch { @@ -719,7 +841,7 @@ fn add_mapped(src: &[f32], dst: &mut [f32]) { return; } let n = src_ch.min(dst_ch); - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..frames { let sb = f * src_ch; let db = f * dst_ch; for c in 0..n { @@ -730,14 +852,14 @@ fn add_mapped(src: &[f32], dst: &mut [f32]) { /// Add `src` (downmixed to mono) into a single physical channel `ch` of `dst`. #[inline] -fn add_to_channel(src: &[f32], dst: &mut [f32], ch: usize) { - let src_ch = src.len() / DSP_BLOCK_FRAMES; - let dst_ch = dst.len() / DSP_BLOCK_FRAMES; +fn add_to_channel(src: &[f32], dst: &mut [f32], ch: usize, frames: usize) { + let src_ch = src.len() / frames; + let dst_ch = dst.len() / frames; if src_ch == 0 || ch >= dst_ch { return; } let g = 1.0 / src_ch as f32; - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..frames { let sb = f * src_ch; let mut acc = 0.0; for c in 0..src_ch { @@ -750,14 +872,14 @@ fn add_to_channel(src: &[f32], dst: &mut [f32], ch: usize) { /// Place `src`'s channels into `dst` starting at channel `off`. Distinct offsets /// leave inputs side by side; `dst` is zeroed each block so this is a copy. #[inline] -fn add_block_at(src: &[f32], dst: &mut [f32], off: usize) { - let src_ch = src.len() / DSP_BLOCK_FRAMES; - let dst_ch = dst.len() / DSP_BLOCK_FRAMES; +fn add_block_at(src: &[f32], dst: &mut [f32], off: usize, frames: usize) { + let src_ch = src.len() / frames; + let dst_ch = dst.len() / frames; if src_ch == 0 || off >= dst_ch { return; } let n = src_ch.min(dst_ch - off); - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..frames { let sb = f * src_ch; let db = f * dst_ch + off; for c in 0..n { @@ -790,10 +912,10 @@ struct DelayLine { } impl DelayLine { - fn new(delay_frames: usize, channels: usize) -> Self { + fn new(delay_frames: usize, channels: usize, block_frames: usize) -> Self { Self { buf: vec![0.0; delay_frames * channels].into_boxed_slice(), - scratch: vec![0.0; DSP_BLOCK_FRAMES * channels].into_boxed_slice(), + scratch: vec![0.0; block_frames * channels].into_boxed_slice(), pos: 0, } } @@ -819,6 +941,8 @@ impl DelayLine { /// terminal edges whose buffers get summed into the final output. pub(super) struct OutputGraph { sample_rate: u32, + /// Frames `process_block` renders per call; every node buffer holds one. + block_frames: usize, /// Interleaved channel width of `process_block`'s output. Stereo unless a /// speaker sets it to the device's channel count. out_channels: usize, @@ -830,7 +954,7 @@ pub(super) struct OutputGraph { latency_frames: usize, /// Blocks produced by `process_block`. A clone lives in this build's /// `BuiltOutputGraph::output` so the non-RT tick thread can compare this - /// worker's real block rate against `sample_rate / DSP_BLOCK_FRAMES`. + /// worker's real block rate against `sample_rate / block_frames`. blocks: Arc, } @@ -839,6 +963,10 @@ impl OutputGraph { self.sample_rate } + pub(super) fn block_frames(&self) -> usize { + self.block_frames + } + pub(super) fn out_channels(&self) -> usize { self.out_channels } @@ -860,7 +988,7 @@ impl OutputGraph { self.nodes[terminal.src_idx] .out_buf_for_handle(terminal.source_handle.as_deref()) .len() - / DSP_BLOCK_FRAMES + / self.block_frames } }) .max() @@ -876,9 +1004,10 @@ impl OutputGraph { } } - /// Fill `output` (`DSP_BLOCK_FRAMES * out_channels` long) with one block of + /// Fill `output` (`block_frames * out_channels` long) with one block of /// mixed audio at `sample_rate`. pub(super) fn process_block(&mut self, output: &mut [f32]) { + let frames = self.block_frames; self.blocks.fetch_add(1, Ordering::Relaxed); for node in &mut self.nodes { match node { @@ -911,7 +1040,7 @@ impl OutputGraph { else { continue; }; - add_mapped(src, buf); + add_mapped(src, buf, frames); } for (i, (_, buf)) in cons.channel_bufs.iter().enumerate() { if let Some(prod) = cons.send_producers.get_mut(i) { @@ -932,9 +1061,9 @@ impl OutputGraph { None => src, }; match route { - Some((off, 1)) => add_to_channel(src, &mut eff.out_buf, off), - Some((off, _)) => add_block_at(src, &mut eff.out_buf, off), - None => add_mapped(src, &mut eff.out_buf), + Some((off, 1)) => add_to_channel(src, &mut eff.out_buf, off, frames), + Some((off, _)) => add_block_at(src, &mut eff.out_buf, off, frames), + None => add_mapped(src, &mut eff.out_buf, frames), } } if let Some(sc_buf) = eff.sidechain_buf.as_mut() { @@ -948,24 +1077,24 @@ impl OutputGraph { Some(d) => d.delayed(src), None => src, }; - add_mapped(src, sc_buf); + add_mapped(src, sc_buf, frames); } } if !eff.bypass.load(Ordering::Relaxed) { - eff.run(DSP_BLOCK_FRAMES); + eff.run(frames); } - let w = eff.out_buf.len() / DSP_BLOCK_FRAMES; + let w = eff.out_buf.len() / frames; for (h, buf) in eff.handle_bufs.iter_mut() { if let Some(a) = parse_stereo(h) { let c0 = (a - 1).min(w - 1); let c1 = a.min(w - 1); - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..frames { buf[f * 2] = eff.out_buf[f * w + c0]; buf[f * 2 + 1] = eff.out_buf[f * w + c1]; } } else if let Some(k) = parse_ch(h) { let c = (k - 1).min(w - 1); - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..frames { buf[f] = eff.out_buf[f * w + c]; } } @@ -987,9 +1116,9 @@ impl OutputGraph { None => src, }; match terminal.route { - Some((off, 1)) => add_to_channel(src, output, off), - Some((off, _)) => add_block_at(src, output, off), - None => add_mapped(src, output), + Some((off, 1)) => add_to_channel(src, output, off, frames), + Some((off, _)) => add_block_at(src, output, off, frames), + None => add_mapped(src, output, frames), } } } @@ -1008,6 +1137,8 @@ pub(super) struct BuiltOutputGraph { /// Effect node id -> (node index, channel width). Used to attach publish /// taps to nodes that fan out to other outputs. pub node_meta: HashMap, + /// Latency and working block of every effect built here. + pub node_timings: Vec, } /// Build the per-output DAG: walk backward from `output_id`, topo-sort the @@ -1023,6 +1154,7 @@ pub(super) struct BuiltOutputGraph { pub(super) fn build_output_graph( output_id: Option<&str>, output_sr: u32, + block_frames: usize, realtime: bool, valid: &ValidGraph, input_native_sr: &HashMap, @@ -1107,6 +1239,7 @@ pub(super) fn build_output_graph( let mut scopes: Vec = Vec::new(); let mut sources: Vec = Vec::new(); let mut node_latencies: Vec = Vec::with_capacity(topo.len()); + let mut node_timings: Vec = Vec::new(); // Per-node channel width; effects inherit the max width of their upstreams. let mut node_channels: Vec = Vec::with_capacity(topo.len()); @@ -1114,7 +1247,16 @@ pub(super) fn build_output_graph( // A fan-out node owned by an earlier output: read its published block // from the ring instead of rebuilding the whole upstream chain. if let Some((consumer, owner_sr, width)) = cut_leaves.remove(id) { - let source = ring_source(id, consumer, owner_sr, output_sr, width, realtime, valid)?; + let source = ring_source( + id, + consumer, + owner_sr, + output_sr, + block_frames, + width, + realtime, + valid, + )?; sources.push(SourceMeta { label: format!("{} out={}", source.label, output_id.unwrap_or("monitor")), stats: source.stats.clone(), @@ -1139,9 +1281,11 @@ pub(super) fn build_output_graph( let network = match &input.spec { InputSpec::NetReceiver { port } => { let receiver = crate::audio::netaudio::receiver::get_or_create(id, *port); - Some(ChannelReceiver::new( - receiver.register_consumer(output_sr, realtime), - )) + Some(ChannelReceiver::new(receiver.register_consumer( + output_sr, + block_frames, + realtime, + ))) } InputSpec::WebRtcRecv { node_id, @@ -1153,9 +1297,11 @@ pub(super) fn build_output_graph( *opus_bitrate, *opus_application, ); - Some(ChannelReceiver::new( - session.register_bridge(output_sr, realtime), - )) + Some(ChannelReceiver::new(session.register_bridge( + output_sr, + block_frames, + realtime, + ))) } _ => None, }; @@ -1174,12 +1320,12 @@ pub(super) fn build_output_graph( for h in handles { let Some(key) = tap_key(&h) else { continue }; wire_keys.push(key); - handle_bufs.push((h, vec![0.0; DSP_BLOCK_FRAMES])); + handle_bufs.push((h, vec![0.0; block_frames])); } id_to_index.insert(id.clone(), nodes.len()); nodes.push(DagNode::Producer(ProducerState { receiver, - out_buf: vec![0.0; DSP_BLOCK_FRAMES * pw], + out_buf: vec![0.0; block_frames * pw], handle_bufs, wire_keys, })); @@ -1211,7 +1357,7 @@ pub(super) fn build_output_graph( .into_iter() .map(|h| { let w = tap_handle_width(&h).unwrap_or(1); - (h, vec![0.0; DSP_BLOCK_FRAMES * w]) + (h, vec![0.0; block_frames * w]) }) .collect(); let resampler = if input_sr == output_sr { @@ -1229,10 +1375,9 @@ pub(super) fn build_output_graph( .map(|r| r.out_max()) .unwrap_or(RESAMPLE_CHUNK); // x4 headroom: one chunk draining + one in-flight + alignment slack. - let staging_cap = (out_max * 4 + DSP_BLOCK_FRAMES) * source_channels; + let staging_cap = (out_max * 4 + block_frames) * source_channels; let input_frames_per_block = - (DSP_BLOCK_FRAMES as u64 * input_sr as u64 + output_sr as u64 - 1) - / output_sr as u64; + (block_frames as u64 * input_sr as u64 + output_sr as u64 - 1) / output_sr as u64; let input_samples_per_block = (input_frames_per_block as usize) * source_channels; let kind = match &input.spec { @@ -1262,19 +1407,19 @@ pub(super) fn build_output_graph( let source = SourceState { label, channels: source_channels, + frames: block_frames, consumer, resampler, - input_staging: Vec::with_capacity( - (RESAMPLE_CHUNK + SPLICE_FADE_FRAMES) * source_channels + 8, - ), - splice_tmp: Vec::with_capacity(SPLICE_FADE_FRAMES * source_channels), + input_staging: Vec::with_capacity(staging_samples(source_channels)), + splice_tmp: Vec::with_capacity(splice_samples(source_channels)), out_pending: StagingRing::with_capacity(staging_cap), chunk_tmp: Vec::with_capacity(out_max * source_channels), - out_buf: vec![0.0; DSP_BLOCK_FRAMES * source_channels], + out_buf: vec![0.0; block_frames * source_channels], input_samples_per_block, - realtime: source_realtime, + cushion: source_realtime + .then(|| Cushion::new(input_frames_per_block as usize, input_sr)), + queue_avg: 0.0, last_pop_at: Instant::now(), - first_data_logged: false, volume: input_volumes .get(id) .cloned() @@ -1319,7 +1464,9 @@ pub(super) fn build_output_graph( // the edge width is the tap buffer's, not the source node's full width. let upstream_w = main_upstream .iter() - .map(|(i, sh, _)| edge_channels(&nodes, &node_channels, *i, sh.as_deref())) + .map(|(i, sh, _)| { + edge_channels(&nodes, &node_channels, *i, sh.as_deref(), block_frames) + }) .max() .unwrap_or(2); let target_w = main_upstream @@ -1344,6 +1491,7 @@ pub(super) fn build_output_graph( &effect.spec, id, output_sr, + block_frames, realtime, true, eff_channels, @@ -1371,14 +1519,19 @@ pub(super) fn build_output_graph( let make_edge = |src_idx: usize, source_handle: Option, target_handle: Option| { let pad = max_upstream - node_latencies[src_idx]; - let width = - edge_channels(&nodes, &node_channels, src_idx, source_handle.as_deref()); + let width = edge_channels( + &nodes, + &node_channels, + src_idx, + source_handle.as_deref(), + block_frames, + ); IncomingEdge { src_idx, source_handle, target_handle, delay: if pad > 0 { - Some(DelayLine::new(pad, width)) + Some(DelayLine::new(pad, width, block_frames)) } else { None }, @@ -1395,7 +1548,7 @@ pub(super) fn build_output_graph( let sidechain_buf = if sidechain.is_empty() { None } else { - Some(vec![0.0; DSP_BLOCK_FRAMES * eff_channels]) + Some(vec![0.0; block_frames * eff_channels]) }; // Generic `chK` per-channel taps drawn off this effect. A stale // handle just yields silence. @@ -1412,7 +1565,7 @@ pub(super) fn build_output_graph( .into_iter() .map(|h| { let w = tap_handle_width(&h).unwrap_or(2); - (h, vec![0.0; DSP_BLOCK_FRAMES * w]) + (h, vec![0.0; block_frames * w]) }) .collect(); // Analyzers read all channels at once, and so does a plugin that @@ -1430,14 +1583,27 @@ pub(super) fn build_output_graph( }; let mut effects = Vec::with_capacity(pairs); let own = build.effect.latency_frames(); + node_timings.push(NodeTiming { + node_id: id.clone(), + latency_frames: own as u32, + working_block: build.effect.working_block().map(|w| w as u32), + }); effects.push(build.effect); for _ in 1..pairs { // Extra stereo pairs are separate instances for wider audio, // never the editor target. // Extra pairs exist only when the node is driven pairwise, so // each is asked for stereo rather than the node's full width. - let extra = - instantiate_effect(&effect.spec, id, output_sr, realtime, false, 2, registry); + let extra = instantiate_effect( + &effect.spec, + id, + output_sr, + block_frames, + realtime, + false, + 2, + registry, + ); effects.push(extra.effect); } id_to_index.insert(id.clone(), nodes.len()); @@ -1447,10 +1613,10 @@ pub(super) fn build_output_graph( bypass, incoming, sidechain, - out_buf: vec![0.0; DSP_BLOCK_FRAMES * eff_channels], + out_buf: vec![0.0; block_frames * eff_channels], sidechain_buf, - pair_main: vec![0.0; DSP_BLOCK_FRAMES * 2], - pair_side: vec![0.0; DSP_BLOCK_FRAMES * 2], + pair_main: vec![0.0; block_frames * 2], + pair_side: vec![0.0; block_frames * 2], handle_bufs, taps: Vec::new(), })); @@ -1493,13 +1659,19 @@ pub(super) fn build_output_graph( .into_iter() .map(|(idx, source_handle, target_handle)| { let pad = max_up - node_latencies[idx]; - let width = edge_channels(&nodes, &node_channels, idx, source_handle.as_deref()); + let width = edge_channels( + &nodes, + &node_channels, + idx, + source_handle.as_deref(), + block_frames, + ); IncomingEdge { src_idx: idx, source_handle, target_handle, delay: if pad > 0 { - Some(DelayLine::new(pad, width)) + Some(DelayLine::new(pad, width, block_frames)) } else { None }, @@ -1518,7 +1690,7 @@ pub(super) fn build_output_graph( let mut send_producers: Vec> = Vec::with_capacity(n); let mut send_consumers: Vec> = Vec::with_capacity(n); for c in 1..=n { - channel_bufs.push((format!("ch{c}"), vec![0.0; DSP_BLOCK_FRAMES])); + channel_bufs.push((format!("ch{c}"), vec![0.0; block_frames])); let (prod, cons) = RingBuffer::::new(crate::audio::netaudio::SEND_RING); send_producers.push(prod); send_consumers.push(cons); @@ -1571,6 +1743,7 @@ pub(super) fn build_output_graph( return Ok(BuiltOutputGraph { graph: OutputGraph { sample_rate: output_sr, + block_frames, out_channels: 2, nodes, terminals: Vec::new(), @@ -1588,10 +1761,12 @@ pub(super) fn build_output_graph( label: out_label, blocks, sample_rate: output_sr, + block_frames, channels: 2, io: None, }, node_meta, + node_timings, }); } @@ -1617,14 +1792,19 @@ pub(super) fn build_output_graph( .into_iter() .map(|(src_idx, source_handle, route)| { let pad = max_upstream - node_latencies[src_idx]; - let width = - edge_channels(&nodes, &node_channels, src_idx, source_handle.as_deref()); + let width = edge_channels( + &nodes, + &node_channels, + src_idx, + source_handle.as_deref(), + block_frames, + ); TerminalEdge { src_idx, source_handle, route, delay: if pad > 0 { - Some(DelayLine::new(pad, width)) + Some(DelayLine::new(pad, width, block_frames)) } else { None }, @@ -1638,6 +1818,7 @@ pub(super) fn build_output_graph( Ok(BuiltOutputGraph { graph: OutputGraph { sample_rate: output_sr, + block_frames, out_channels: 2, nodes, terminals, @@ -1655,10 +1836,12 @@ pub(super) fn build_output_graph( label: out_label, blocks, sample_rate: output_sr, + block_frames, channels: 2, io: None, }, node_meta, + node_timings, }) } @@ -1672,6 +1855,7 @@ fn ring_source( consumer: Consumer, owner_sr: u32, output_sr: u32, + block_frames: usize, channels: usize, realtime: bool, valid: &ValidGraph, @@ -1690,9 +1874,9 @@ fn ring_source( .as_ref() .map(|r| r.out_max()) .unwrap_or(RESAMPLE_CHUNK); - let staging_cap = (out_max * 4 + DSP_BLOCK_FRAMES) * channels; + let staging_cap = (out_max * 4 + block_frames) * channels; let input_frames_per_block = - (DSP_BLOCK_FRAMES as u64 * owner_sr as u64 + output_sr as u64 - 1) / output_sr as u64; + (block_frames as u64 * owner_sr as u64 + output_sr as u64 - 1) / output_sr as u64; let input_samples_per_block = input_frames_per_block as usize * channels; let mut ch_handles: Vec = valid @@ -1708,24 +1892,25 @@ fn ring_source( .into_iter() .map(|h| { let w = tap_handle_width(&h).unwrap_or(1); - (h, vec![0.0; DSP_BLOCK_FRAMES * w]) + (h, vec![0.0; block_frames * w]) }) .collect(); Ok(SourceState { label: format!("cut:{id}"), channels, + frames: block_frames, consumer, resampler, - input_staging: Vec::with_capacity((RESAMPLE_CHUNK + SPLICE_FADE_FRAMES) * channels + 8), - splice_tmp: Vec::with_capacity(SPLICE_FADE_FRAMES * channels), + input_staging: Vec::with_capacity(staging_samples(channels)), + splice_tmp: Vec::with_capacity(splice_samples(channels)), out_pending: StagingRing::with_capacity(staging_cap), chunk_tmp: Vec::with_capacity(out_max * channels), - out_buf: vec![0.0; DSP_BLOCK_FRAMES * channels], + out_buf: vec![0.0; block_frames * channels], input_samples_per_block, - realtime, + cushion: realtime.then(|| Cushion::new(input_frames_per_block as usize, owner_sr)), + queue_avg: 0.0, last_pop_at: Instant::now(), - first_data_logged: false, volume: Arc::new(AtomicU32::new(0x3F80_0000)), paused: None, drain: None, @@ -1801,7 +1986,7 @@ pub(super) fn plan_cuts(valid: &ValidGraph, monitor_key: Option<&str>) -> CutPla } }; - for out in &valid.outputs { + for out in owner_order(valid) { let starts = valid .edges .iter() @@ -1828,6 +2013,16 @@ pub(super) fn plan_cuts(valid: &ValidGraph, monitor_key: Option<&str>) -> CutPla CutPlan { owner, consumers } } +/// Order in which outputs claim shared nodes, and so the order they must be +/// built in (an owner wires the rings its consumers read). Speakers go first: +/// an owner computes the node at its own block, and a device-paced graph must +/// not read it late through a ring filled at a timer worker's larger block. +pub(super) fn owner_order(valid: &ValidGraph) -> Vec<&crate::audio::graph::ValidOutput> { + let mut outputs: Vec<_> = valid.outputs.iter().collect(); + outputs.sort_by_key(|o| !matches!(o.spec, OutputSpec::Speaker { .. })); + outputs +} + /// Analyzer effects are monitor-graph roots: they render telemetry and have no /// audio successor, so the monitor sub-graph is everything that feeds one. fn is_analyzer(spec: &EffectSpec) -> bool { @@ -1916,26 +2111,26 @@ pub(super) fn inputs_feeding_output<'a>(output_id: &str, valid: &'a ValidGraph) #[cfg(test)] mod tests { - use super::{add_mapped, crossfade_into, DelayLine, DSP_BLOCK_FRAMES, SPLICE_FADE_FRAMES}; + use super::{add_mapped, crossfade_into, DelayLine, SPLICE_FADE_FRAMES, TIMER_BLOCK_FRAMES}; // Latency compensation on a branch that bypasses a latent effect must be a // pure delay: same samples, same order, only shifted. #[test] fn delay_line_shifts_without_losing_samples() { const PAD_FRAMES: usize = 482; - let mut line = DelayLine::new(PAD_FRAMES, 2); + let mut line = DelayLine::new(PAD_FRAMES, 2, TIMER_BLOCK_FRAMES); let mut fed: Vec = Vec::new(); let mut got: Vec = Vec::new(); for b in 0..4 { - let mut input = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; - for f in 0..DSP_BLOCK_FRAMES { - let v = (b * DSP_BLOCK_FRAMES + f) as f32; + let mut input = vec![0.0_f32; TIMER_BLOCK_FRAMES * 2]; + for f in 0..TIMER_BLOCK_FRAMES { + let v = (b * TIMER_BLOCK_FRAMES + f) as f32; input[f * 2] = v; input[f * 2 + 1] = -v; } fed.extend_from_slice(&input); - let mut dst = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; - add_mapped(line.delayed(&input), &mut dst); + let mut dst = vec![0.0_f32; TIMER_BLOCK_FRAMES * 2]; + add_mapped(line.delayed(&input), &mut dst, TIMER_BLOCK_FRAMES); got.extend_from_slice(&dst); } let shift = PAD_FRAMES * 2; @@ -1950,23 +2145,23 @@ mod tests { #[test] fn delay_line_fills_a_whole_mono_block() { const PAD_FRAMES: usize = 482; - let mut line = DelayLine::new(PAD_FRAMES, 1); + let mut line = DelayLine::new(PAD_FRAMES, 1, TIMER_BLOCK_FRAMES); let mut fed: Vec = Vec::new(); let mut got: Vec = Vec::new(); for b in 0..4 { - let mut input = vec![0.0_f32; DSP_BLOCK_FRAMES]; + let mut input = vec![0.0_f32; TIMER_BLOCK_FRAMES]; for (f, s) in input.iter_mut().enumerate() { - *s = (b * DSP_BLOCK_FRAMES + f) as f32 + 1.0; + *s = (b * TIMER_BLOCK_FRAMES + f) as f32 + 1.0; } fed.extend_from_slice(&input); - let mut dst = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; - add_mapped(line.delayed(&input), &mut dst); + let mut dst = vec![0.0_f32; TIMER_BLOCK_FRAMES * 2]; + add_mapped(line.delayed(&input), &mut dst, TIMER_BLOCK_FRAMES); got.extend_from_slice(&dst); } // Mono upmixes to both channels, and no frame of any block stays silent. for b in 1..4 { - for f in 0..DSP_BLOCK_FRAMES { - let i = b * DSP_BLOCK_FRAMES * 2 + f * 2; + for f in 0..TIMER_BLOCK_FRAMES { + let i = b * TIMER_BLOCK_FRAMES * 2 + f * 2; assert_ne!(got[i], 0.0, "left silent at block {b} frame {f}"); assert_eq!(got[i], got[i + 1], "channels differ at block {b} frame {f}"); } @@ -2000,31 +2195,31 @@ mod tests { #[test] fn add_mapped_maps_by_channel() { // 4->2: first two channels pass, rest dropped. - let mut src = vec![0.0; DSP_BLOCK_FRAMES * 4]; - for f in 0..DSP_BLOCK_FRAMES { + let mut src = vec![0.0; TIMER_BLOCK_FRAMES * 4]; + for f in 0..TIMER_BLOCK_FRAMES { for c in 0..4 { src[f * 4 + c] = c as f32 + 1.0; } } - let mut dst = vec![0.0; DSP_BLOCK_FRAMES * 2]; - add_mapped(&src, &mut dst); + let mut dst = vec![0.0; TIMER_BLOCK_FRAMES * 2]; + add_mapped(&src, &mut dst, TIMER_BLOCK_FRAMES); assert_eq!(dst[0], 1.0); assert_eq!(dst[1], 2.0); // 2->1: mono downmix is the mean. - let mut stereo = vec![0.0; DSP_BLOCK_FRAMES * 2]; - for f in 0..DSP_BLOCK_FRAMES { + let mut stereo = vec![0.0; TIMER_BLOCK_FRAMES * 2]; + for f in 0..TIMER_BLOCK_FRAMES { stereo[f * 2] = 1.0; stereo[f * 2 + 1] = 3.0; } - let mut mono = vec![0.0; DSP_BLOCK_FRAMES]; - add_mapped(&stereo, &mut mono); + let mut mono = vec![0.0; TIMER_BLOCK_FRAMES]; + add_mapped(&stereo, &mut mono, TIMER_BLOCK_FRAMES); assert!((mono[0] - 2.0).abs() < 1e-6); // equal width: straight sum-in. - let src = vec![0.5; DSP_BLOCK_FRAMES * 3]; - let mut dst = vec![0.25; DSP_BLOCK_FRAMES * 3]; - add_mapped(&src, &mut dst); + let src = vec![0.5; TIMER_BLOCK_FRAMES * 3]; + let mut dst = vec![0.25; TIMER_BLOCK_FRAMES * 3]; + add_mapped(&src, &mut dst, TIMER_BLOCK_FRAMES); assert!((dst[0] - 0.75).abs() < 1e-6); } } @@ -2078,6 +2273,7 @@ pub(super) mod graph_tests { pub(in crate::audio::pipeline) fn passthrough_graph() -> (ValidGraph, String) { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g", 0.0), speaker("s")], edges: vec![ edge("e1", "m", None, "g", None), @@ -2108,6 +2304,24 @@ pub(super) mod graph_tests { valid: &ValidGraph, input_sr: u32, realtime: bool, + ) -> (BuiltOutputGraph, HashMap>) { + build_with_block( + output_id, + output_sr, + TIMER_BLOCK_FRAMES, + valid, + input_sr, + realtime, + ) + } + + pub(in crate::audio::pipeline) fn build_with_block( + output_id: Option<&str>, + output_sr: u32, + block_frames: usize, + valid: &ValidGraph, + input_sr: u32, + realtime: bool, ) -> (BuiltOutputGraph, HashMap>) { let mut producer_pairs = Vec::new(); let native = valid @@ -2120,6 +2334,7 @@ pub(super) mod graph_tests { let built = build_output_graph( output_id, output_sr, + block_frames, realtime, valid, &native, @@ -2157,9 +2372,9 @@ pub(super) mod graph_tests { let pushed = push_all(producers.get_mut("m").unwrap(), &fed); assert_eq!(pushed, fed.len()); - let mut out1 = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out1 = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out1); - let mut out2 = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out2 = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out2); assert_eq!( built @@ -2171,8 +2386,8 @@ pub(super) mod graph_tests { // With zero effect latency and equal rates the ramp streams straight // through: block 1 is its first 1024 frames, block 2 the next span. - assert_eq!(out1, fed[..DSP_BLOCK_FRAMES * 2]); - assert_eq!(out2, fed[DSP_BLOCK_FRAMES * 2..DSP_BLOCK_FRAMES * 4]); + assert_eq!(out1, fed[..TIMER_BLOCK_FRAMES * 2]); + assert_eq!(out2, fed[TIMER_BLOCK_FRAMES * 2..TIMER_BLOCK_FRAMES * 4]); } #[test] @@ -2187,6 +2402,7 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, false, &valid, &native, @@ -2204,7 +2420,7 @@ pub(super) mod graph_tests { let fed = stereo_ramp(4096, 0.0); push_all(prod, &fed); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); built.graph.process_block(&mut out); built.graph.process_block(&mut out); @@ -2212,7 +2428,7 @@ pub(super) mod graph_tests { // Drop the volume to 0.5 and confirm the next block scales. volumes["m"].store(0.5f32.to_bits(), std::sync::atomic::Ordering::Relaxed); built.graph.process_block(&mut out); - let want = &fed[DSP_BLOCK_FRAMES * 6..DSP_BLOCK_FRAMES * 8]; + let want = &fed[TIMER_BLOCK_FRAMES * 6..TIMER_BLOCK_FRAMES * 8]; for (o, w) in out.iter().zip(want) { assert!((o - 0.5 * w).abs() < 1e-5, "{o} vs {w}"); } @@ -2230,6 +2446,7 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, false, &valid, &native, @@ -2245,9 +2462,9 @@ pub(super) mod graph_tests { .expect("build"); let prod = &mut producer_pairs[0].1; push_all(prod, &stereo_ramp(4096, 0.0)); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); - assert_eq!(out, vec![0.0; DSP_BLOCK_FRAMES * 2]); + assert_eq!(out, vec![0.0; TIMER_BLOCK_FRAMES * 2]); // The ring was drained: no backlog left. let meta = &built.sources[0]; assert_eq!( @@ -2268,6 +2485,7 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, false, &valid, &native, @@ -2284,17 +2502,17 @@ pub(super) mod graph_tests { let prod = &mut producer_pairs[0].1; push_all(prod, &stereo_ramp(4096, 0.0)); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); built.graph.process_block(&mut out); // A seek rewinds the capture: generation bumps, everything is dropped. drain["m"].fetch_add(1, std::sync::atomic::Ordering::Relaxed); built.graph.process_block(&mut out); - assert_eq!(out, vec![0.0; DSP_BLOCK_FRAMES * 2]); + assert_eq!(out, vec![0.0; TIMER_BLOCK_FRAMES * 2]); // The flushed ring stays empty: the next block is silence too. - let mut out2 = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out2 = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out2); - assert_eq!(out2, vec![0.0; DSP_BLOCK_FRAMES * 2]); + assert_eq!(out2, vec![0.0; TIMER_BLOCK_FRAMES * 2]); } #[test] @@ -2303,6 +2521,7 @@ pub(super) mod graph_tests { // lookahead. The graph must report the lookahead as its latency. let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), gain_node("g", 0.0), @@ -2334,10 +2553,223 @@ pub(super) mod graph_tests { assert_eq!(built.graph.active_output_channels(), 2); } + fn quiet_ramp(frames: usize) -> Vec { + (0..frames * 2) + .map(|i| ((i / 2) % 997) as f32 * 1e-4) + .collect() + } + + fn render(graph: &mut OutputGraph, blocks: usize) -> Vec { + let mut got = Vec::new(); + let mut out = vec![0.0; graph.block_frames() * 2]; + for _ in 0..blocks { + graph.process_block(&mut out); + got.extend_from_slice(&out); + } + got + } + + #[test] + fn every_buffer_size_streams_the_source_sample_exact() { + let (valid, _) = passthrough_graph(); + for block in crate::audio::graph::BUFFER_FRAME_OPTIONS.map(|n| n as usize) { + let (mut built, mut producers) = + build_with_block(Some("s"), SR, block, &valid, SR, false); + assert_eq!(built.graph.block_frames(), block); + let fed = stereo_ramp(8192, 0.0); + push_all(producers.get_mut("m").unwrap(), &fed); + let blocks = 8192 / block; + let got = render(&mut built.graph, blocks); + assert_eq!(got.len(), blocks * block * 2); + assert_eq!(got, fed[..got.len()], "{block}-frame blocks"); + } + } + + #[test] + fn delay_compensation_spans_several_small_blocks() { + // Limiter lookahead (96 frames) is three 32-frame blocks: the dry branch + // must be held back across block boundaries, not just within one. + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + gain_node("g", 0.0), + gain_node("l", 0.0), + speaker("s"), + ], + edges: vec![ + edge("e1", "m", None, "g", None), + edge("e2", "m", None, "l", None), + edge("e3", "g", None, "s", None), + edge("e4", "l", None, "s", None), + ], + }; + let mut valid = g.validate().expect("valid"); + for e in &mut valid.effects { + if e.id == "l" { + e.spec = EffectSpec::Limiter(crate::audio::graph::LimiterData { + ceiling_db: 0.0, + lookahead_ms: 2.0, + release_ms: 50.0, + bypassed: false, + }); + } + } + let (mut built, mut producers) = build_with_block(Some("s"), SR, 32, &valid, SR, false); + let pad = built.graph.latency_frames(); + assert_eq!(pad, 96); + let fed = quiet_ramp(4096); + push_all(producers.get_mut("m").unwrap(), &fed); + let got = render(&mut built.graph, 4096 / 32); + for f in pad..4096 { + let want = 2.0 * fed[(f - pad) * 2]; + assert!( + (got[f * 2] - want).abs() < 1e-5, + "frame {f}: got {} want {want}", + got[f * 2] + ); + } + } + + /// Drives a live passthrough source the way a capture device and an output + /// device do, each on its own clock: `burst`-frame deliveries against + /// `block`-frame reads. Returns the output after `settle` seconds. + fn live_capture( + block: usize, + burst: usize, + capture_ppm: f64, + seconds: f64, + settle: f64, + signal: impl Fn(usize) -> f32, + ) -> (Vec, SourceStats) { + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, true); + let stats = built.sources[0].stats.clone(); + let input = producers.get_mut("m").unwrap(); + let out_period = block as f64 / SR as f64; + let in_period = burst as f64 / (SR as f64 * (1.0 + capture_ppm * 1e-6)); + let (mut t_in, mut t_out, mut fed) = (in_period * 0.37, 0.0, 0usize); + let mut out = vec![0.0; block * 2]; + let mut kept = Vec::new(); + let mut chunk = vec![0.0; burst * 2]; + while t_out < seconds { + if t_in <= t_out { + for f in 0..burst { + let v = signal(fed + f); + chunk[f * 2] = v; + chunk[f * 2 + 1] = v; + } + assert_eq!(push_all(input, &chunk), chunk.len()); + fed += burst; + t_in += in_period; + continue; + } + built.graph.process_block(&mut out); + if t_out >= settle { + kept.extend(out.iter().step_by(2)); + } + t_out += out_period; + } + (kept, stats) + } + + #[test] + fn live_source_runs_at_capture_latency_without_glitches() { + for ppm in [-200.0, 0.0, 200.0] { + let xruns_before; + let (out, stats) = { + let r = live_capture(64, 512, ppm, 40.0, 5.0, |_| 0.5); + xruns_before = r.1.xrun.load(Ordering::Relaxed); + r + }; + assert!( + out.iter().all(|&s| (s - 0.5).abs() < 1e-6), + "{ppm} ppm: gap in a constant signal" + ); + let queue = stats.queue_frames.load(Ordering::Relaxed) as usize; + assert!( + queue < 512 + 3 * 64 + 256, + "{ppm} ppm: {queue} frames queued" + ); + assert_eq!(stats.xrun.load(Ordering::Relaxed), xruns_before); + } + } + + #[test] + fn drift_corrections_are_seamless() { + // A 60 Hz sine moves < 0.007 per frame. A splice shifts it by up to 96 + // frames (~0.66); spread across the 32-frame crossfade that is ~0.02 + // per frame, while a hard cut would jump the whole 0.66 in one. + let sine = |f: usize| (f as f32 * std::f32::consts::TAU * 60.0 / SR as f32).sin() * 0.9; + let hard_cut = (0..SR as usize) + .map(|f| (sine(f + 96) - sine(f)).abs()) + .fold(0.0_f32, f32::max); + assert!( + hard_cut > 0.5, + "the threshold below must tell a cut from a fade" + ); + for ppm in [-300.0, 300.0] { + let (out, stats) = live_capture(64, 480, ppm, 60.0, 5.0, sine); + let corrected = + stats.trimmed.load(Ordering::Relaxed) + stats.stretched.load(Ordering::Relaxed); + assert!(corrected > 0, "{ppm} ppm: drift never corrected"); + let worst = out + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max); + assert!(worst < 0.06, "{ppm} ppm: step of {worst} at a splice"); + } + } + + #[test] + fn mono_mic_into_an_analyzer_renders() { + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + node("lm", NodeKind::LevelMeter, serde_json::json!({})), + ], + edges: vec![edge("e1", "m", None, "lm", None)], + }; + let valid = g.validate().expect("valid"); + let native = HashMap::from([("m".to_string(), SR)]); + let native_ch = HashMap::from([("m".to_string(), 1u32)]); + let mut pairs = Vec::new(); + let mut reg = fresh_registry(); + let mut built = build_output_graph( + None, + SR, + TIMER_BLOCK_FRAMES, + true, + &valid, + &native, + &native_ch, + &mut pairs, + &mut reg, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + HashMap::new(), + ) + .expect("build"); + push_all(&mut pairs[0].1, &vec![0.5; 4 * TIMER_BLOCK_FRAMES]); + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; + for _ in 0..4 { + built.graph.process_block(&mut out); + } + let peaks = built.meters[0].snapshot_and_decay().peaks; + assert_eq!(peaks.len(), 1, "one channel metered"); + assert!(peaks[0] > 0.4, "mono signal reached the meter: {peaks:?}"); + } + #[test] fn missing_sample_rate_is_a_validation_error() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), speaker("s")], edges: vec![edge("e1", "m", None, "s", None)], }; @@ -2349,6 +2781,7 @@ pub(super) mod graph_tests { let err = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, false, &valid, &native, @@ -2368,6 +2801,7 @@ pub(super) mod graph_tests { fn monitor_graph_has_no_terminals_and_tracks_blocks() { let mut g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), speaker("s")], edges: vec![edge("e1", "m", None, "s", None)], }; @@ -2379,7 +2813,7 @@ pub(super) mod graph_tests { let (mut built, mut producers) = build(None, SR, &valid, SR, false); let prod = producers.get_mut("m").unwrap(); push_all(prod, &stereo_ramp(4096, 0.0)); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); assert_eq!( built @@ -2397,6 +2831,7 @@ pub(super) mod graph_tests { // mic → gain → spkrA, and gain → spkrB: the gain node is shared. let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g", 0.0), speaker("a"), speaker("b")], edges: vec![ edge("e1", "m", None, "g", None), @@ -2432,6 +2867,7 @@ pub(super) mod graph_tests { let mut built_b = build_output_graph( Some("b"), SR, + TIMER_BLOCK_FRAMES, false, &valid, &native, @@ -2452,9 +2888,9 @@ pub(super) mod graph_tests { built_a.graph.attach_tap(node_idx, prod); let published = stereo_ramp(1024, 0.0); push_all(producers_a.get_mut("m").unwrap(), &published); - let mut out_a = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out_a = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built_a.graph.process_block(&mut out_a); - let mut out_b = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out_b = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built_b.graph.process_block(&mut out_b); assert_eq!(out_b, out_a, "consumer reads the owner's published block"); } @@ -2463,6 +2899,7 @@ pub(super) mod graph_tests { fn net_sender_output_builds_a_consumer_node() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), node( @@ -2485,7 +2922,7 @@ pub(super) mod graph_tests { let (mut built, _) = build(Some("net"), SR, &valid, SR, false); assert_eq!(built.graph.out_channels(), 2); // Send-side graph is driven like any output. - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); } @@ -2493,6 +2930,7 @@ pub(super) mod graph_tests { fn file_recording_output_builds_terminals() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), node( @@ -2511,15 +2949,16 @@ pub(super) mod graph_tests { }; let valid = g.validate().expect("recording graph valid"); let (mut built, _) = build(Some("rec"), SR, &valid, SR, false); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); - assert_eq!(out, vec![0.0; DSP_BLOCK_FRAMES * 2]); + assert_eq!(out, vec![0.0; TIMER_BLOCK_FRAMES * 2]); } #[test] fn multi_input_sums_into_the_mix() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m1"), mic("m2"), speaker("s")], edges: vec![ edge("e1", "m1", None, "s", None), @@ -2534,6 +2973,7 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, false, &valid, &native, @@ -2551,7 +2991,7 @@ pub(super) mod graph_tests { let mut b = stereo_ramp(4096, 0.5); push_all(&mut producer_pairs[0].1, &a); push_all(&mut producer_pairs[1].1, &b); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); built.graph.process_block(&mut out); built.graph.process_block(&mut out); @@ -2672,50 +3112,50 @@ pub(super) mod graph_tests { #[test] fn add_to_channel_downmixes_into_one_physical_channel() { - let mut src = vec![0.0; DSP_BLOCK_FRAMES * 2]; - for f in 0..DSP_BLOCK_FRAMES { + let mut src = vec![0.0; TIMER_BLOCK_FRAMES * 2]; + for f in 0..TIMER_BLOCK_FRAMES { src[f * 2] = 1.0; src[f * 2 + 1] = 3.0; } - let mut dst = vec![0.0; DSP_BLOCK_FRAMES * 4]; - add_to_channel(&src, &mut dst, 2); - for f in 0..DSP_BLOCK_FRAMES { + let mut dst = vec![0.0; TIMER_BLOCK_FRAMES * 4]; + add_to_channel(&src, &mut dst, 2, TIMER_BLOCK_FRAMES); + for f in 0..TIMER_BLOCK_FRAMES { assert_eq!(dst[f * 4 + 2], 2.0, "mono mean into channel 2"); assert_eq!(dst[f * 4], 0.0, "other channels untouched"); } // Out of range is a no-op. - let mut small = vec![0.0; DSP_BLOCK_FRAMES]; - add_to_channel(&src, &mut small, 5); - assert_eq!(small, vec![0.0; DSP_BLOCK_FRAMES]); + let mut small = vec![0.0; TIMER_BLOCK_FRAMES]; + add_to_channel(&src, &mut small, 5, TIMER_BLOCK_FRAMES); + assert_eq!(small, vec![0.0; TIMER_BLOCK_FRAMES]); } #[test] fn add_block_at_places_a_stereo_pair() { - let src = vec![0.5; DSP_BLOCK_FRAMES * 2]; - let mut dst = vec![0.0; DSP_BLOCK_FRAMES * 4]; - add_block_at(&src, &mut dst, 2); - for f in 0..DSP_BLOCK_FRAMES { + let src = vec![0.5; TIMER_BLOCK_FRAMES * 2]; + let mut dst = vec![0.0; TIMER_BLOCK_FRAMES * 4]; + add_block_at(&src, &mut dst, 2, TIMER_BLOCK_FRAMES); + for f in 0..TIMER_BLOCK_FRAMES { assert_eq!(dst[f * 4 + 2], 0.5); assert_eq!(dst[f * 4 + 3], 0.5); assert_eq!(dst[f * 4], 0.0); } // Offset past the end is a no-op. - let mut small = vec![0.0; DSP_BLOCK_FRAMES]; - add_block_at(&src, &mut small, 3); - assert_eq!(small, vec![0.0; DSP_BLOCK_FRAMES]); + let mut small = vec![0.0; TIMER_BLOCK_FRAMES]; + add_block_at(&src, &mut small, 3, TIMER_BLOCK_FRAMES); + assert_eq!(small, vec![0.0; TIMER_BLOCK_FRAMES]); } #[test] fn add_mapped_monotostereo_upmixes_every_channel() { - let src = vec![0.5; DSP_BLOCK_FRAMES]; - let mut dst = vec![0.0; DSP_BLOCK_FRAMES * 4]; - add_mapped(&src, &mut dst); + let src = vec![0.5; TIMER_BLOCK_FRAMES]; + let mut dst = vec![0.0; TIMER_BLOCK_FRAMES * 4]; + add_mapped(&src, &mut dst, TIMER_BLOCK_FRAMES); for s in &dst { assert_eq!(*s, 0.5); } // Zero-width guards. let mut empty_dst: Vec = Vec::new(); - add_mapped(&[], &mut empty_dst); + add_mapped(&[], &mut empty_dst, TIMER_BLOCK_FRAMES); } #[test] @@ -2740,7 +3180,7 @@ pub(super) mod graph_tests { #[test] fn delay_line_zero_capacity_passes_through() { - let mut line = DelayLine::new(0, 2); + let mut line = DelayLine::new(0, 2, TIMER_BLOCK_FRAMES); let input = vec![0.7f32; 16]; assert_eq!(line.delayed(&input), input); } @@ -2766,24 +3206,24 @@ pub(super) mod graph_tests { let (valid, _) = passthrough_graph(); let (mut built, _) = build(Some("s"), SR, &valid, SR, false); let meta = built.sources[0].stats.clone(); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; // Fresh source: not stalled yet → genuine xrun. built.graph.process_block(&mut out); - assert_eq!(out, vec![0.0; DSP_BLOCK_FRAMES * 2]); + assert_eq!(out, vec![0.0; TIMER_BLOCK_FRAMES * 2]); assert_eq!( meta.xrun.load(std::sync::atomic::Ordering::Relaxed), - (DSP_BLOCK_FRAMES * 2) as u64 + (TIMER_BLOCK_FRAMES * 2) as u64 ); // After the stall window the same silence counts as "stalled". std::thread::sleep(STALL_THRESHOLD + std::time::Duration::from_millis(20)); built.graph.process_block(&mut out); assert_eq!( meta.stalled.load(std::sync::atomic::Ordering::Relaxed), - (DSP_BLOCK_FRAMES * 2) as u64 + (TIMER_BLOCK_FRAMES * 2) as u64 ); assert_eq!( meta.xrun.load(std::sync::atomic::Ordering::Relaxed), - (DSP_BLOCK_FRAMES * 2) as u64, + (TIMER_BLOCK_FRAMES * 2) as u64, "stall must not double-count as xrun" ); } @@ -2800,7 +3240,7 @@ pub(super) mod graph_tests { producers.get_mut("m").unwrap(), &stereo_ramp(12 * 1024, 0.0), ); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; for _ in 0..6 { built.graph.process_block(&mut out); } @@ -2820,6 +3260,7 @@ pub(super) mod graph_tests { // mic --ch1--> speaker: the terminal taps only the left channel. let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), speaker("s")], edges: vec![edge("e1", "m", Some("ch1"), "s", None)], }; @@ -2832,11 +3273,11 @@ pub(super) mod graph_tests { fed[f * 2 + 1] = -10.0; } push_all(producers.get_mut("m").unwrap(), &fed); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); // The stereo terminal gets a mono source: add_mapped upmixes the // tapped left channel to both physical channels. - for f in 0..DSP_BLOCK_FRAMES { + for f in 0..TIMER_BLOCK_FRAMES { assert_eq!(out[f * 2], 10.0, "left"); assert_eq!(out[f * 2 + 1], 10.0, "mono upmix of the tapped channel"); } @@ -2847,6 +3288,7 @@ pub(super) mod graph_tests { // gain --st3--> speaker: the output lands on physical channels 2-3. let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g", 0.0), speaker("s")], edges: vec![ edge("e1", "m", None, "g", None), @@ -2856,17 +3298,18 @@ pub(super) mod graph_tests { let valid = g.validate().expect("valid"); let (mut built, mut producers) = build(Some("s"), SR, &valid, SR, false); push_all(producers.get_mut("m").unwrap(), &stereo_ramp(4096, 0.0)); - let mut out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built.graph.process_block(&mut out); built.graph.process_block(&mut out); // st3 asks for channels 2-3 of a 2-wide output → clamped to nothing. - assert_eq!(out, vec![0.0; DSP_BLOCK_FRAMES * 2]); + assert_eq!(out, vec![0.0; TIMER_BLOCK_FRAMES * 2]); } #[test] fn sidechain_edge_feeds_the_detector_not_the_mix() { let graph = |with_sidechain: bool| GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("main"), mic("key"), @@ -2898,22 +3341,22 @@ pub(super) mod graph_tests { let (mut keyed, mut keyed_producers) = build(Some("s"), SR, &valid, SR, false); push_all( keyed_producers.get_mut("main").unwrap(), - &vec![0.1; 8 * DSP_BLOCK_FRAMES * 2], + &vec![0.1; 8 * TIMER_BLOCK_FRAMES * 2], ); push_all( keyed_producers.get_mut("key").unwrap(), - &vec![1.0; 8 * DSP_BLOCK_FRAMES * 2], + &vec![1.0; 8 * TIMER_BLOCK_FRAMES * 2], ); let valid = graph(false).validate().expect("main-only graph"); let (mut unkeyed, mut unkeyed_producers) = build(Some("s"), SR, &valid, SR, false); push_all( unkeyed_producers.get_mut("main").unwrap(), - &vec![0.1; 8 * DSP_BLOCK_FRAMES * 2], + &vec![0.1; 8 * TIMER_BLOCK_FRAMES * 2], ); - let mut keyed_out = vec![0.0; DSP_BLOCK_FRAMES * 2]; - let mut unkeyed_out = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut keyed_out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; + let mut unkeyed_out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; for _ in 0..4 { keyed.graph.process_block(&mut keyed_out); unkeyed.graph.process_block(&mut unkeyed_out); @@ -2931,10 +3374,47 @@ pub(super) mod graph_tests { ); } + #[test] + fn plan_cuts_gives_shared_nodes_to_the_speaker() { + // The recorder is listed first, yet the speaker computes the shared + // gain at its small block and the recorder reads it back. + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + gain_node("g", 0.0), + node( + "rec", + NodeKind::FileRecording, + serde_json::json!({ + "filePath": "/tmp/test.wav", + "format": { "kind": "wav", "bitDepth": "f32" }, + "channels": 2, + "mode": "overwrite", + "sampleRate": 48000 + }), + ), + speaker("s"), + ], + edges: vec![ + edge("e1", "m", None, "g", None), + edge("e2", "g", None, "rec", None), + edge("e3", "g", None, "s", None), + ], + }; + let valid = g.validate().expect("valid"); + assert_eq!(valid.outputs[0].id, "rec", "recorder listed first"); + let plan = plan_cuts(&valid, None); + assert_eq!(plan.owner.get("g"), Some(&"s".to_string())); + assert_eq!(plan.consumers.get("g"), Some(&vec!["rec".to_string()])); + } + #[test] fn plan_cuts_monitor_owns_analyzer_only_nodes() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), gain_node("g", 0.0), @@ -2963,6 +3443,7 @@ pub(super) mod graph_tests { fn plan_cuts_participants_exclude_ownerless_nodes() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), speaker("s")], edges: vec![edge("e1", "m", None, "s", None)], }; @@ -2989,6 +3470,7 @@ pub(super) mod graph_tests { // gets a resampler and the published block arrives resampled. let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g", 0.0), speaker("a"), speaker("b")], edges: vec![ edge("e1", "m", None, "g", None), @@ -3014,6 +3496,7 @@ pub(super) mod graph_tests { let mut built_b = build_output_graph( Some("b"), SR, + TIMER_BLOCK_FRAMES, false, &valid, &native, @@ -3032,12 +3515,12 @@ pub(super) mod graph_tests { // Feed a constant-amplitude ramp at 44.1k; publish into the tap ring // exactly what A's effect produced. push_all(producers_a.get_mut("m").unwrap(), &stereo_ramp(4096, 0.0)); - let mut out_a = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out_a = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built_a.graph.attach_tap(node_idx, prod); built_a.graph.process_block(&mut out_a); // A block published at 44.1k resampled to 48k: the value must land // in B's output, finite and near the source level. - let mut out_b = vec![0.0; DSP_BLOCK_FRAMES * 2]; + let mut out_b = vec![0.0; TIMER_BLOCK_FRAMES * 2]; built_b.graph.process_block(&mut out_b); let peak = out_b.iter().fold(0.0f32, |m, s| m.max(s.abs())); let consumed = built_b.sources[0] diff --git a/src-tauri/src/audio/pipeline/input/linux.rs b/src-tauri/src/audio/pipeline/input/linux.rs index da9654a5..93a93d97 100644 --- a/src-tauri/src/audio/pipeline/input/linux.rs +++ b/src-tauri/src/audio/pipeline/input/linux.rs @@ -43,6 +43,16 @@ pub(in crate::audio::pipeline) fn resolve_input( } } +/// The OS owns this capture's buffer size and does not report what the +/// hardware adds, so there is nothing to configure or read back. +pub(in crate::audio::pipeline) fn configure_io( + _resolved: &ResolvedInput, + _block_frames: usize, + _pipeline_rate: u32, +) -> Option { + None +} + pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs index 6fa48960..c9717cc1 100644 --- a/src-tauri/src/audio/pipeline/input/macos.rs +++ b/src-tauri/src/audio/pipeline/input/macos.rs @@ -13,8 +13,11 @@ use crate::audio::input_bridge::BroadcastRx; use crate::audio::streams; use crate::error::{AppError, AppResult}; +use super::super::latency::DeviceIo; use super::super::native::native_config; +use super::super::output::device_block; use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; +use crate::audio::macos_hal; /// The graph downstream is laid out from the format resolved before the capture /// started. A tap follows the default output device, so switching it between @@ -63,6 +66,35 @@ pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult Option { + use cpal::traits::DeviceTrait; + let ResolvedInput::Cpal { + device, + sample_rate, + .. + } = resolved + else { + return None; + }; + let name = device.name().ok()?; + let requested = device_block(block_frames, pipeline_rate, *sample_rate); + let granted = macos_hal::set_buffer_frames(DeviceKind::Input, &name, requested); + tracing::info!(device = %name, requested, granted, "microphone buffer size"); + let io = macos_hal::io_latency(DeviceKind::Input, &name)?; + Some(DeviceIo { + rate: *sample_rate, + buffer_frames: io.buffer_frames, + hardware_frames: Some(io.hardware_frames), + }) +} + pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index 4991ca0f..d6703d22 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -32,8 +32,8 @@ mod windows; #[cfg(target_os = "windows")] use windows as platform; -pub(super) use platform::resolve_input; use platform::start_input_stream as start_native_input_stream; +pub(super) use platform::{configure_io, resolve_input}; /// RAII handle held only for its `Drop` -- stops the cpal stream, tears /// down the capture, or signals + joins the file reader thread. @@ -198,6 +198,18 @@ pub(super) fn start_audio_file( Ok(InputHandle::AudioFile(reader)) } +/// Frames the capture normalizer holds back, at the device's rate: a chunk +/// being gathered plus the resampler's filter delay. Zero when it only +/// forwards (or is skipped entirely). +pub(super) fn normalizer_frames(resolved: &ResolvedInput, target_sample_rate: u32) -> u64 { + let native = resolved.sample_rate(); + if matches!(resolved, ResolvedInput::AudioFile { .. }) || native == target_sample_rate { + return 0; + } + MultiResampler::new(native, target_sample_rate, RESAMPLE_CHUNK, 1) + .map_or(0, |r| (RESAMPLE_CHUNK + r.delay_frames()) as u64) +} + fn input_resampler( native_rate: u32, target_sample_rate: u32, @@ -229,6 +241,15 @@ pub(super) fn start_input_stream( if matches!(resolved, ResolvedInput::AudioFile { .. }) { return start_native_input_stream(node_id, resolved, bridge, paused, meter, app); } + // A device already at the pipeline rate needs no normalizing: its callback + // feeds the graphs' rings directly, with no thread hop in between. Only a + // cpal device qualifies; captures may change rate while running. + #[cfg(any(target_os = "macos", target_os = "windows"))] + if matches!(resolved, ResolvedInput::Cpal { .. }) + && resolved.sample_rate() == target_sample_rate + { + return start_native_input_stream(node_id, resolved, bridge, paused, meter, app); + } let sample_rate = resolved.sample_rate(); let channels = resolved.native_channels() as usize; let (raw_producer, mut raw_consumer) = @@ -289,17 +310,32 @@ pub(super) fn start_input_stream( ); } } - if raw_consumer.slots() < input_buf.len() { - thread::sleep(Duration::from_millis(1)); - continue; - } - let Ok(chunk) = raw_consumer.read_chunk(input_buf.len()) else { + // The resampler takes fixed chunks; a passthrough forwards + // whatever whole frames have arrived, so it adds no latency + // beyond its wake-up. + let available = raw_consumer.slots(); + let take = if resampler.is_some() { + if available < input_buf.len() { + thread::sleep(Duration::from_millis(1)); + continue; + } + input_buf.len() + } else { + let whole = available.min(input_buf.len()); + let whole = whole - whole % channels.max(1); + if whole == 0 { + thread::sleep(Duration::from_millis(1)); + continue; + } + whole + }; + let Ok(chunk) = raw_consumer.read_chunk(take) else { continue; }; let (first, second) = chunk.as_slices(); let n = first.len(); input_buf[..n].copy_from_slice(first); - input_buf[n..].copy_from_slice(second); + input_buf[n..take].copy_from_slice(second); chunk.commit_all(); let normalized = if let Some(resampler) = &mut resampler { output_buf.clear(); @@ -311,7 +347,7 @@ pub(super) fn start_input_stream( } output_buf.as_slice() } else { - input_buf.as_slice() + &input_buf[..take] }; if let Some(meter) = &meter { update_meter(meter, normalized, channels); diff --git a/src-tauri/src/audio/pipeline/input/windows.rs b/src-tauri/src/audio/pipeline/input/windows.rs index ad2ccb82..f9c41828 100644 --- a/src-tauri/src/audio/pipeline/input/windows.rs +++ b/src-tauri/src/audio/pipeline/input/windows.rs @@ -50,6 +50,16 @@ pub(in crate::audio::pipeline) fn resolve_input( } } +/// The OS owns this capture's buffer size and does not report what the +/// hardware adds, so there is nothing to configure or read back. +pub(in crate::audio::pipeline) fn configure_io( + _resolved: &ResolvedInput, + _block_frames: usize, + _pipeline_rate: u32, +) -> Option { + None +} + pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, diff --git a/src-tauri/src/audio/pipeline/latency.rs b/src-tauri/src/audio/pipeline/latency.rs new file mode 100644 index 00000000..97d6fbb3 --- /dev/null +++ b/src-tauri/src/audio/pipeline/latency.rs @@ -0,0 +1,220 @@ +//! End-to-end latency of the running pipeline, the way a DAW reports round +//! trip: capture device (buffer plus what the hardware adds), the queue ahead +//! of the graph, the graph's own delay compensation, the output adapter, and +//! the playback device (buffer plus hardware). + +use serde::Serialize; +use ts_rs::TS; + +/// One direction of one device, in its own frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DeviceIo { + pub rate: u32, + pub buffer_frames: u32, + /// Past the buffer: safety offset, converters, transport. `None` where + /// the OS does not report it. + pub hardware_frames: Option, +} + +/// A source feeding a speaker. +#[derive(Debug, Clone, Copy)] +pub(super) struct PathInput { + pub device: Option, + /// Smoothed frames queued ahead of the graph, at `rate`. + pub queue_frames: u64, + /// Frames spent in the capture normalizer's resampler, at `rate`. + pub normalizer_frames: u64, + pub rate: u32, +} + +/// A speaker and the graph it renders. +#[derive(Debug, Clone, Copy)] +pub(super) struct PathOutput { + pub pipeline_rate: u32, + pub graph_latency_frames: u64, + pub device_rate: u32, + /// Measured from the device's own callbacks. + pub callback_frames: u64, + pub carried_frames: u64, + pub resampler_frames: u64, + pub hardware_frames: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct LatencyBreakdown { + pub total_ms: f32, + /// Capture device buffer and hardware. + pub input_device_ms: f32, + /// Audio queued ahead of the graph, including capture resampling. + pub input_queue_ms: f32, + /// Delay compensation for the slowest path through the graph. + pub processing_ms: f32, + /// Output resampling and block adapter. + pub output_adapter_ms: f32, + /// Playback device buffer and hardware. + pub output_device_ms: f32, + /// False when some device did not report what it adds past its buffer, + /// so the total is a lower bound. + pub hardware_included: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct NodeTiming { + pub node_id: String, + /// Delay this node adds, in pipeline frames. + pub latency_frames: u32, + /// Block the node actually works in, when larger than the engine buffer. + pub working_block: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct LatencyReport { + /// The slowest input-to-speaker path; `None` without a speaker. + pub path: Option, + /// Engine buffer the settings asked for. + pub buffer_frames: u32, + /// Buffer the slowest speaker actually runs at, in its own frames. + pub device_buffer_frames: Option, + /// Peak share of the audio period spent rendering since the last report + /// (1.0 = the device outran the graph). + pub dsp_load: f32, + /// Callbacks that ran past their period since the last report. + pub overloads: u32, + pub nodes: Vec, +} + +fn ms(frames: u64, rate: u32) -> f32 { + (frames as f64 * 1000.0 / rate.max(1) as f64) as f32 +} + +/// The slowest of the speaker's inputs sets its latency; parallel inputs do +/// not add up. A speaker with no live input still has its output side. +pub(super) fn breakdown(inputs: &[PathInput], out: &PathOutput) -> LatencyBreakdown { + let mut hardware_included = out.hardware_frames.is_some(); + let worst = inputs + .iter() + .map(|i| { + let device_ms = i.device.map_or(0.0, |d| { + ms( + d.buffer_frames as u64 + d.hardware_frames.unwrap_or(0) as u64, + d.rate, + ) + }); + let queue_ms = ms(i.queue_frames + i.normalizer_frames, i.rate); + ( + device_ms, + queue_ms, + i.device.and_then(|d| d.hardware_frames).is_some(), + ) + }) + .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1))); + let (input_device_ms, input_queue_ms) = match worst { + Some((device, queue, known)) => { + hardware_included &= known; + (device, queue) + } + None => (0.0, 0.0), + }; + let processing_ms = ms(out.graph_latency_frames, out.pipeline_rate); + let output_adapter_ms = ms(out.carried_frames + out.resampler_frames, out.device_rate); + let output_device_ms = ms( + out.callback_frames + out.hardware_frames.unwrap_or(0) as u64, + out.device_rate, + ); + LatencyBreakdown { + total_ms: input_device_ms + + input_queue_ms + + processing_ms + + output_adapter_ms + + output_device_ms, + input_device_ms, + input_queue_ms, + processing_ms, + output_adapter_ms, + output_device_ms, + hardware_included, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn out(callback: u64, hardware: Option) -> PathOutput { + PathOutput { + pipeline_rate: 48_000, + graph_latency_frames: 0, + device_rate: 48_000, + callback_frames: callback, + carried_frames: 0, + resampler_frames: 0, + hardware_frames: hardware, + } + } + + fn mic(buffer: u32, hardware: Option, queue: u64) -> PathInput { + PathInput { + device: Some(DeviceIo { + rate: 48_000, + buffer_frames: buffer, + hardware_frames: hardware, + }), + queue_frames: queue, + normalizer_frames: 0, + rate: 48_000, + } + } + + fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 + } + + #[test] + fn round_trip_adds_every_stage() { + // 64-frame buffers both ways, 1 ms of hardware each way, a 2 ms queue + // and a 2 ms limiter lookahead: 1.33+1+2+2+1.33+1 ms. + let mut o = out(64, Some(48)); + o.graph_latency_frames = 96; + let b = breakdown(&[mic(64, Some(48), 96)], &o); + assert!(close(b.input_device_ms, 2.333), "{b:?}"); + assert!(close(b.input_queue_ms, 2.0)); + assert!(close(b.processing_ms, 2.0)); + assert!(close(b.output_device_ms, 2.333)); + assert!(close(b.total_ms, 8.667)); + assert!(b.hardware_included); + } + + #[test] + fn slowest_input_sets_the_path() { + let b = breakdown( + &[mic(64, Some(0), 0), mic(512, Some(0), 480)], + &out(64, Some(0)), + ); + assert!(close(b.input_device_ms, 10.667)); + assert!(close(b.input_queue_ms, 10.0)); + } + + #[test] + fn unreported_hardware_marks_the_total_as_a_lower_bound() { + assert!(!breakdown(&[mic(64, None, 0)], &out(64, Some(0))).hardware_included); + assert!(!breakdown(&[mic(64, Some(0), 0)], &out(64, None)).hardware_included); + assert!(breakdown(&[], &out(64, Some(0))).hardware_included); + } + + #[test] + fn output_side_uses_the_device_rate() { + let mut o = out(441, Some(0)); + o.device_rate = 44_100; + o.carried_frames = 100; + o.resampler_frames = 73; + let b = breakdown(&[], &o); + assert!(close(b.output_device_ms, 10.0)); + assert!(close(b.output_adapter_ms, 173.0 / 44.1)); + } +} diff --git a/src-tauri/src/audio/pipeline/meter.rs b/src-tauri/src/audio/pipeline/meter.rs index f1554feb..02579552 100644 --- a/src-tauri/src/audio/pipeline/meter.rs +++ b/src-tauri/src/audio/pipeline/meter.rs @@ -5,12 +5,12 @@ use std::time::{Duration, Instant}; use serde_json::json; use tauri::{AppHandle, Emitter}; -use tracing::warn; +use tracing::{info, warn}; use crate::audio::effects::{GrHandle, LufsHandle, MeterHandle, WaveformHandle}; use crate::audio::health; -use super::dag::{OutputMeta, SourceMeta, DSP_BLOCK_FRAMES}; +use super::dag::{OutputMeta, SourceMeta}; use super::output::LIVE_SPEAKER_STREAMS; const METER_EVENT: &str = "audio://meter"; @@ -95,6 +95,8 @@ pub(super) fn spawn_xrun_thread( .map_or(0, |c| c.dropped.load(Ordering::Relaxed)) }) .collect(); + let mut announced: Vec = vec![false; sources.len()]; + let mut last_failed: Vec = vec![0; sources.len()]; let mut last_blocks: Vec = outputs .iter() .map(|o| o.blocks.load(Ordering::Relaxed)) @@ -106,11 +108,11 @@ pub(super) fn spawn_xrun_thread( .map_or(0, |io| io.requested.load(Ordering::Relaxed)) }) .collect(); - let mut last_read: Vec = outputs + let mut last_overloads: Vec = outputs .iter() .map(|o| { o.io.as_ref() - .map_or(0, |io| io.read.load(Ordering::Relaxed)) + .map_or(0, |io| io.overloads.load(Ordering::Relaxed)) }) .collect(); let mut last_callbacks: Vec = outputs @@ -138,6 +140,20 @@ pub(super) fn spawn_xrun_thread( let stalled_now = s.stats.stalled.load(Ordering::Relaxed); let trimmed_now = s.stats.trimmed.load(Ordering::Relaxed); let consumed_now = s.stats.consumed.load(Ordering::Relaxed); + // Logged here rather than on the audio thread that sees it. + if !announced[i] && s.stats.online.load(Ordering::Relaxed) { + announced[i] = true; + info!(source = %s.label, "source online"); + } + let failed_now = s.stats.failed.load(Ordering::Relaxed); + if failed_now > last_failed[i] { + warn!( + source = %s.label, + chunks = failed_now - last_failed[i], + "source resampler failed; chunks dropped" + ); + last_failed[i] = failed_now; + } let xrun_delta = xrun_now.saturating_sub(last_xrun[i]); let stalled_delta = stalled_now.saturating_sub(last_stalled[i]); let trimmed_delta = trimmed_now.saturating_sub(last_trimmed[i]); @@ -231,33 +247,31 @@ pub(super) fn spawn_xrun_thread( last_blocks[i] = blocks_now; let expected_blocks = - o.sample_rate as f64 / DSP_BLOCK_FRAMES as f64 * elapsed_secs; + o.sample_rate as f64 / o.block_frames as f64 * elapsed_secs; let blocks_off_rate = off_rate(blocks_delta as f64, expected_blocks, 1.0); - // Device-pull diagnostics: distinguishes "device asking for - // far more than real time implies" from "ring nobody fills", - // which the global OUTPUT_UNDERRUN_SAMPLES counter can't tell - // apart since it's summed across every output. + // Device-pull diagnostics: tells "device asking for far more + // than real time implies" apart from "graph too slow for the + // buffer", which shows as overloads. let io = o.io.as_ref().map(|io| { let requested_now = io.requested.load(Ordering::Relaxed); - let read_now = io.read.load(Ordering::Relaxed); + let overloads_now = io.overloads.load(Ordering::Relaxed); let callbacks_now = io.callbacks.load(Ordering::Relaxed); let requested_delta = requested_now.saturating_sub(last_requested[i]); - let read_delta = read_now.saturating_sub(last_read[i]); + let overloads_delta = overloads_now.saturating_sub(last_overloads[i]); let callbacks_delta = callbacks_now.saturating_sub(last_callbacks[i]); last_requested[i] = requested_now; - last_read[i] = read_now; + last_overloads[i] = overloads_now; last_callbacks[i] = callbacks_now; - (requested_delta, read_delta, callbacks_delta) + (requested_delta, overloads_delta, callbacks_delta) }); let io_off_rate = io.is_some_and(|(requested_delta, _, callbacks_delta)| { - let expected_samples = o.io.as_ref().map_or(o.sample_rate, |speaker| { - speaker.sample_rate.load(Ordering::Relaxed) - }) as f64 - * o.channels as f64 - * elapsed_secs; + let expected_samples = + o.io.as_ref().map_or(o.sample_rate, |s| s.sample_rate) as f64 + * o.channels as f64 + * elapsed_secs; // The device's own buffer size, measured rather than - // assumed: cpal opens with `BufferSize::Default`. + // assumed: the device may not grant the requested one. let quantum = if callbacks_delta > 0 { requested_delta as f64 / callbacks_delta as f64 } else { @@ -265,15 +279,16 @@ pub(super) fn spawn_xrun_thread( }; off_rate(requested_delta as f64, expected_samples, quantum) }); + let overloaded = io.is_some_and(|(_, overloads, _)| overloads > 0); - if !warmup && (blocks_off_rate || io_off_rate) { + if !warmup && (blocks_off_rate || io_off_rate || overloaded) { match io { - Some((requested_samples, read_samples, callbacks)) => warn!( + Some((requested_samples, overloads, callbacks)) => warn!( output = %o.label, blocks = blocks_delta, expected_blocks = expected_blocks.round() as u64, requested_samples, - read_samples, + overloads, callbacks, "output block rate anomaly" ), @@ -287,9 +302,8 @@ pub(super) fn spawn_xrun_thread( } } - // A stream that outlived its worker keeps calling back and - // draining a ring nobody fills, which shows up in the global - // underrun total but in no output's own counters. + // A stream that outlived its handle keeps calling back into a + // renderer nobody owns, which shows in no output's own counters. let live_streams = LIVE_SPEAKER_STREAMS.load(Ordering::Relaxed); if live_streams != expected_speaker_streams { warn!( diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index a71811bd..106a9536 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -30,21 +30,24 @@ use crate::audio::input_bridge::{broadcast_channel, BroadcastTx, CaptureStats}; use crate::error::{AppError, AppResult}; mod cue; +mod cushion; pub use cue::play as play_cue; pub(crate) mod dag; mod file_reader; mod input; +mod latency; +pub use latency::LatencyReport; mod meter; #[cfg(any(target_os = "macos", target_os = "windows"))] mod native; mod output; -#[cfg(target_os = "linux")] pub(crate) use worker::RtThread; mod sig; mod worker; use dag::{build_output_graph, ring_capacity_frames, OutputGraph, OutputMeta, SourceMeta}; -use input::{resolve_input, start_input_stream, InputHandle, ResolvedInput}; +use input::{configure_io, resolve_input, start_input_stream, InputHandle, ResolvedInput}; +use latency::{breakdown, DeviceIo, NodeTiming, PathInput, PathOutput}; use meter::{spawn_meter_thread, spawn_xrun_thread, MeterTickThread, XrunTickThread}; use output::{ resolve_output, start_monitor_worker, start_recorder_worker, start_speaker_stream, @@ -90,12 +93,20 @@ pub struct ActivePipeline { /// `(input_id, slot)` bridges of hot-swapping outputs, kept feeding the old /// sub-graph while the new one's rings prefill. Removed after the swap. stale_bridges: Vec<(String, usize)>, + /// Latency and working block per effect node, for the report. + node_timings: HashMap, + /// Each speaker's overload count at the last report. + reported_overloads: HashMap, } struct InputState { _handle: InputHandle, sample_rate: u32, channels: u32, + /// The capture device's buffer and hardware latency, where known. + io: Option, + /// Frames the capture normalizer holds back, at the device's rate. + normalizer_frames: u64, bridge_tx: BroadcastTx, bridges_by_output: HashMap>, volume: Arc, @@ -163,6 +174,8 @@ impl ActivePipeline { output_stats: Vec::new(), xrun_thread: None, stale_bridges: Vec::new(), + node_timings: HashMap::new(), + reported_overloads: HashMap::new(), } } @@ -270,36 +283,62 @@ impl ActivePipeline { } } - /// Deepest speaker output latency in milliseconds, end to end: the input - /// side's deepest source ring backlog, the graph's own lookahead (delay - /// compensation aligned every path to it), and the adaptive output ring - /// buffer. The ring tracks the device's buffer, so a large-buffer device - /// runs at a higher (reported) latency than a low-latency one. Zero when - /// idle. - pub fn output_latency_ms(&self) -> u32 { - self.speakers - .iter() - .map(|(id, s)| { - let sr = s.sample_rate.max(1) as u64; - let buffered = s.io.target_frames.load(Ordering::Relaxed).max(0) as u64; - let out_ms = (buffered + s.io.graph_latency_frames as u64) * 1000 / sr; - // Parallel inputs don't sum: the worst source is the path that - // dominates the delay to this output. - let in_ms = self - .source_stats - .iter() - .filter(|m| m.output_id == *id) - .map(|m| { - let frames = - m.stats.level.load(Ordering::Relaxed) / m.channels.max(1) as u64; - frames * 1000 / m.native_sr.max(1) as u64 - }) - .max() - .unwrap_or(0); - (out_ms + in_ms) as u32 - }) - .max() - .unwrap_or(0) + /// Round-trip latency of the slowest input-to-speaker path, the load of + /// the busiest speaker callback since the last report, and every effect's + /// timing. Zeroed when idle. + pub fn latency_report(&mut self) -> LatencyReport { + let buffer_frames = self.current.as_ref().map_or(0, |g| g.buffer_frames); + let pipeline_rate = self.current.as_ref().map_or(48_000, |g| g.sample_rate); + let mut report = LatencyReport { + buffer_frames, + nodes: self.node_timings.values().cloned().collect(), + ..LatencyReport::default() + }; + report.nodes.sort_by(|a, b| a.node_id.cmp(&b.node_id)); + for (id, s) in &self.speakers { + let io = &s.io; + let inputs: Vec = self + .source_stats + .iter() + .filter(|m| m.output_id == *id) + .map(|m| { + let input = m.input_id.as_ref().and_then(|i| self.inputs.get(i)); + PathInput { + device: input.and_then(|i| i.io), + queue_frames: m.stats.queue_frames.load(Ordering::Relaxed), + normalizer_frames: input.map_or(0, |i| i.normalizer_frames), + rate: m.native_sr, + } + }) + .collect(); + let out = PathOutput { + pipeline_rate, + graph_latency_frames: io.graph_latency_frames.load(Ordering::Relaxed) as u64, + device_rate: io.sample_rate, + callback_frames: io.callback_frames.load(Ordering::Relaxed) as u64, + carried_frames: io.carried_frames.load(Ordering::Relaxed) as u64, + resampler_frames: io.resampler_delay_frames as u64, + hardware_frames: io.hardware_frames, + }; + let path = breakdown(&inputs, &out); + if report + .path + .as_ref() + .map_or(true, |p| path.total_ms > p.total_ms) + { + report.device_buffer_frames = Some(out.callback_frames as u32); + report.path = Some(path); + } + let load = io.load_peak_permille.swap(0, Ordering::Relaxed) as f32 / 1000.0; + report.dsp_load = report.dsp_load.max(load); + let overloads = io.overloads.load(Ordering::Relaxed); + let seen = self + .reported_overloads + .insert(id.clone(), overloads) + .unwrap_or(0); + report.overloads += overloads.saturating_sub(seen) as u32; + } + report } fn teardown(&mut self) { @@ -380,13 +419,15 @@ impl ActivePipeline { GraphSwap, Drop, } - let sample_rate_changed = self + // Rate and buffer size are negotiated with every device when its + // stream opens, so changing either reopens them all. + let engine_format_changed = self .current .as_ref() - .map_or(false, |c| c.sample_rate != new_graph.sample_rate); + .map_or(false, |c| !same_engine_format(c, new_graph)); let mut cats: HashMap = HashMap::new(); for (id, new_sig) in &new_sigs { - let cat = if sample_rate_changed { + let cat = if engine_format_changed { Cat::Drop } else { match self.current_output_sig(id) { @@ -488,7 +529,7 @@ impl ActivePipeline { .inputs .keys() .filter(|id| { - if sample_rate_changed { + if engine_format_changed { return true; } match ( @@ -522,7 +563,7 @@ impl ActivePipeline { let Some(current) = &self.current else { return false; }; - if current.sample_rate != graph.sample_rate { + if !same_engine_format(current, graph) { return false; } let cur_inputs: HashMap<&str, &InputSpec> = current @@ -593,6 +634,10 @@ impl Drop for ActivePipeline { } } +fn same_engine_format(a: &ValidGraph, b: &ValidGraph) -> bool { + a.sample_rate == b.sample_rate && a.buffer_frames == b.buffer_frames +} + fn monitor_mode(graph: &ValidGraph) -> bool { if graph.outputs.is_empty() { return true; @@ -796,7 +841,7 @@ impl ActivePipeline { // output then node id; the consumer's build reads them as ring-sources. let mut pending_cuts: HashMap, u32, usize)>> = HashMap::new(); - for out in &graph.outputs { + for out in dag::owner_order(graph) { if !output_runtime.contains_key(&out.id) { continue; } @@ -817,11 +862,18 @@ impl ActivePipeline { } OutputSpec::WebRtcSend { .. } => pipeline_sr, }; + // Only a speaker is paced by its device; every other output rides a + // timer, which cannot hold small blocks and whose latency nobody hears. + let block_frames = match &out.spec { + OutputSpec::Speaker { .. } => graph.buffer_frames as usize, + _ => dag::TIMER_BLOCK_FRAMES, + }; let mut my_pairs: Vec<(String, Producer)> = Vec::new(); let cut_leaves = pending_cuts.remove(&out.id).unwrap_or_default(); let mut built = build_output_graph( Some(out.id.as_str()), output_sr, + block_frames, !matches!(out.spec, OutputSpec::FileRecording { .. }), graph, &input_native_sr, @@ -878,6 +930,9 @@ impl ActivePipeline { for s in built.scopes { self.scopes.insert(s.node_id.clone(), s); } + for t in built.node_timings { + self.node_timings.insert(t.node_id.clone(), t); + } self.source_stats.extend(built.sources); self.output_stats.push(built.output); output_stat_idx.insert(out.id.clone(), self.output_stats.len() - 1); @@ -899,6 +954,7 @@ impl ActivePipeline { let built = build_output_graph( None, monitor_sr, + dag::TIMER_BLOCK_FRAMES, true, graph, &input_native_sr, @@ -935,6 +991,9 @@ impl ActivePipeline { for s in built.scopes { self.scopes.insert(s.node_id.clone(), s); } + for t in built.node_timings { + self.node_timings.insert(t.node_id.clone(), t); + } self.source_stats.extend(built.sources); self.output_stats.push(built.output); monitor_graph = Some(built.graph); @@ -1003,6 +1062,8 @@ impl ActivePipeline { captured.push((input_id.clone(), out_id.clone(), capture)); bridges_by_output.entry(out_id).or_default().push(slot); } + let io = configure_io(&resolved, graph.buffer_frames as usize, pipeline_sr); + let normalizer_frames = input::normalizer_frames(&resolved, pipeline_sr); let handle = start_input_stream( &input_id, resolved, @@ -1018,6 +1079,8 @@ impl ActivePipeline { _handle: handle, sample_rate, channels, + io, + normalizer_frames, bridge_tx, bridges_by_output, volume, @@ -1062,6 +1125,8 @@ impl ActivePipeline { let paused = new_input_paused.remove(&input_id); let drain = new_input_drain.remove(&input_id); let (bridge_tx, bridge_rx) = broadcast_channel(); + let io = configure_io(&resolved, graph.buffer_frames as usize, pipeline_sr); + let normalizer_frames = input::normalizer_frames(&resolved, pipeline_sr); let handle = start_input_stream( &input_id, resolved, @@ -1077,6 +1142,8 @@ impl ActivePipeline { _handle: handle, sample_rate, channels, + io, + normalizer_frames, bridge_tx, bridges_by_output: HashMap::new(), volume, @@ -1238,6 +1305,9 @@ impl ActivePipeline { } } + self.node_timings + .retain(|id, _| graph.effects.iter().any(|e| &e.id == id)); + // Sync volume atomics for all surviving inputs from the new graph spec. for inp in &graph.inputs { if let Some(state) = self.inputs.get(&inp.id) { @@ -1345,6 +1415,7 @@ mod tests { fn mic_to_speaker() -> ValidGraph { GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g", 0.0), speaker("s")], edges: vec![edge("e1", "m", "g"), edge("e2", "g", "s")], } @@ -1362,12 +1433,14 @@ mod tests { effects: Vec::new(), edges: Vec::new(), sample_rate: 48_000, + buffer_frames: 256, }; assert!(monitor_mode(&g)); // Analyzer present even with a real output → monitor for it. let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), speaker("s"), @@ -1385,8 +1458,10 @@ mod tests { #[test] fn empty_pipeline_is_a_quiescent_noop() { - let p = ActivePipeline::new(); - assert_eq!(p.output_latency_ms(), 0, "idle pipeline reports no latency"); + let mut p = ActivePipeline::new(); + let report = p.latency_report(); + assert_eq!(report.path, None, "idle pipeline reports no latency"); + assert_eq!(report.dsp_load, 0.0); // Every live-param command on an unknown node is a silent no-op. p.update_effect("ghost", &serde_json::json!({ "gainDb": -6.0 })); p.seek_audio_file("ghost", 100); @@ -1426,6 +1501,18 @@ mod tests { assert!(!p.is_structurally_current(&mic_to_speaker())); } + #[test] + fn buffer_or_rate_change_reopens_every_stream() { + let a = mic_to_speaker(); + assert!(same_engine_format(&a, &a.clone())); + let mut b = a.clone(); + b.buffer_frames = 64; + assert!(!same_engine_format(&a, &b), "buffer size change"); + let mut c = a.clone(); + c.sample_rate = 96_000; + assert!(!same_engine_format(&a, &c), "sample rate change"); + } + #[test] fn teardown_on_an_empty_pipeline_is_harmless() { let mut p = ActivePipeline::new(); @@ -1476,6 +1563,8 @@ mod tests { _handle: InputHandle::AudioFile(reader), sample_rate: 48_000, channels: 2, + io: None, + normalizer_frames: 0, bridge_tx: broadcast_channel().0, bridges_by_output: HashMap::new(), volume: volume.clone(), diff --git a/src-tauri/src/audio/pipeline/output/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs index 29c8e3fb..4c7219c1 100644 --- a/src-tauri/src/audio/pipeline/output/linux.rs +++ b/src-tauri/src/audio/pipeline/output/linux.rs @@ -8,7 +8,7 @@ use crate::error::AppResult; use super::super::dag::OutputGraph; use super::super::worker::WorkerCtrl; -use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, SpeakerWorker, StreamGuard}; +use super::{speaker_callback, speaker_renderer, SpeakerIo, SpeakerLink, StreamGuard}; pub(in crate::audio::pipeline) struct SpeakerResolved { pub node_id: String, @@ -18,10 +18,18 @@ pub(in crate::audio::pipeline) struct SpeakerResolved { pub(in crate::audio::pipeline) struct SpeakerHandle { _playback: crate::audio::playback::Playback, - _worker: SpeakerWorker, + link: SpeakerLink, _alive: StreamGuard, } +// The renderer is taken back before the playback thread stops, so the graph +// drops on this thread rather than inside the PipeWire process callback. +impl Drop for SpeakerHandle { + fn drop(&mut self) { + drop(self.link.retire()); + } +} + pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult { let info = crate::audio::device::device_info(crate::audio::device::DeviceKind::Output, device_id)?; @@ -42,36 +50,27 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( info!(node = %spec.node_id, sample_rate = spec.sample_rate, "opening speaker stream (PipeWire)"); let dead = Arc::new(AtomicBool::new(false)); - let (producer, mut fill, level, target, io) = speaker_ring( - spec.out_channels, - graph.sample_rate(), - spec.sample_rate, - graph.latency_frames(), - ); + let (mut link, mut fill) = speaker_callback(); + let channels = spec.out_channels.max(1); let fill_pw = move |out: &mut [f32]| { - fill(out, 0); + fill(out, out.len() / channels); out.len() }; let playback = crate::audio::playback::Playback::start( &spec.node_id, spec.sample_rate, spec.out_channels, + graph.block_frames(), fill_pw, )?; - let (worker_handle, ctrl) = spawn_speaker_worker( - producer, - level, - target, - io.sample_rate.clone(), - spec.out_channels, - graph, - meter, - )?; + let (renderer, ctrl, io) = + speaker_renderer(graph, spec.sample_rate, spec.out_channels, None, meter)?; + link.attach(renderer); Ok(( SpeakerHandle { _playback: playback, - _worker: worker_handle, + link, _alive: StreamGuard::new(), }, ctrl, diff --git a/src-tauri/src/audio/pipeline/output/macos.rs b/src-tauri/src/audio/pipeline/output/macos.rs index f646b2cd..56fc37e0 100644 --- a/src-tauri/src/audio/pipeline/output/macos.rs +++ b/src-tauri/src/audio/pipeline/output/macos.rs @@ -1,4 +1,4 @@ -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; use std::time::Duration; @@ -10,13 +10,16 @@ use tracing::{error, info, warn}; use crate::audio::device::{self, DeviceKind}; use crate::audio::health; +use crate::audio::macos_hal; use crate::audio::streams; use crate::error::{AppError, AppResult}; use super::super::dag::OutputGraph; use super::super::native::native_config; use super::super::worker::WorkerCtrl; -use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, SpeakerWorker, StreamGuard}; +use super::{ + device_block, speaker_callback, speaker_renderer, SpeakerIo, SpeakerLink, StreamGuard, +}; // Bluetooth AUHAL often returns DeviceNotAvailable on first bind; retry covers settling. const SPEAKER_MAX_ATTEMPTS: u32 = 3; @@ -30,11 +33,9 @@ pub(in crate::audio::pipeline) struct SpeakerResolved { pub sample_rate: u32, } -// Field order: the stream drops before the worker so the audio callback stops -// before the ring is freed. pub(in crate::audio::pipeline) struct SpeakerHandle { _stream: cpal::Stream, - _worker: SpeakerWorker, + link: SpeakerLink, _alive: StreamGuard, } @@ -45,11 +46,16 @@ pub(in crate::audio::pipeline) struct SpeakerHandle { // never reaches refcount zero, so the AudioUnit is never disposed and keeps // calling `fill` on a ring nobody drains anymore. `pause` reaches the // AudioUnit through `&self` and stops it for real, independent of the cycle. +// +// The renderer (and the graph it owns) is taken back first, so it drops here +// rather than leaking with that closure. impl Drop for SpeakerHandle { fn drop(&mut self) { + let renderer = self.link.retire(); if let Err(e) = self._stream.pause() { warn!(error = %e, "failed to pause speaker stream on teardown"); } + drop(renderer); } } @@ -88,7 +94,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( // AirPods A2DP/HFP switch can race resolve_output; verify state fresh. { - let fresh = crate::audio::macos_hal::find_output_device(&device_name); + let fresh = macos_hal::find_output_device(&device_name); match fresh { None => warn!(device = %device_name, "HAL no longer sees the device"), Some(hal) if hal.sample_rate != spec.sample_rate => warn!( @@ -107,20 +113,17 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( } } + // One callback per engine block. The device may clamp the request to its + // range; the renderer adapts to whatever it grants. + let requested = device_block(graph.block_frames(), graph.sample_rate(), spec.sample_rate); + let granted = macos_hal::set_buffer_frames(DeviceKind::Output, &device_name, requested); + info!(device = %device_name, requested, granted, "speaker buffer size"); + let dead = Arc::new(AtomicBool::new(false)); - let mut producer_holder: Option> = None; - let mut level_holder: Option> = None; - let mut target_holder: Option> = None; - let mut io_holder: Option = None; - let mut stream_holder: Option = None; + let mut opened: Option<(cpal::Stream, SpeakerLink)> = None; for attempt in 1..=SPEAKER_MAX_ATTEMPTS { - let (producer, fill, level, target, io) = speaker_ring( - spec.out_channels, - graph.sample_rate(), - spec.sample_rate, - graph.latency_frames(), - ); + let (link, fill) = speaker_callback(); let app_err = app.clone(); let dead_cb = dead.clone(); let node_id_cb = node_id.to_string(); @@ -144,11 +147,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( err_cb, ) { Ok(s) => { - producer_holder = Some(producer); - level_holder = Some(level); - target_holder = Some(target); - io_holder = Some(io); - stream_holder = Some(s); + opened = Some((s, link)); break; } Err(e) if attempt < SPEAKER_MAX_ATTEMPTS && is_device_not_available(&e) => { @@ -162,25 +161,17 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( Err(e) => return Err(e), } } - let producer = producer_holder.expect("loop sets producer on success or returns Err"); - let level = level_holder.expect("loop sets level on success or returns Err"); - let target = target_holder.expect("loop sets target on success or returns Err"); - let io = io_holder.expect("loop sets io on success or returns Err"); - let stream = stream_holder.expect("loop sets stream on success or returns Err"); + let (stream, mut link) = opened.expect("loop opens the stream or returns Err"); - let (worker_handle, ctrl) = spawn_speaker_worker( - producer, - level, - target, - io.sample_rate.clone(), - spec.out_channels, - graph, - meter, - )?; + let hardware = + macos_hal::io_latency(DeviceKind::Output, &device_name).map(|l| l.hardware_frames); + let (renderer, ctrl, io) = + speaker_renderer(graph, spec.sample_rate, spec.out_channels, hardware, meter)?; + link.attach(renderer); Ok(( SpeakerHandle { _stream: stream, - _worker: worker_handle, + link, _alive: StreamGuard::new(), }, ctrl, diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index dcbf93a6..1af811c0 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -1,23 +1,20 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; -use rtrb::{Producer, RingBuffer}; use serde_json::json; use tauri::{AppHandle, Emitter}; use tracing::warn; -use crate::audio::clock::{ClockSource, DeviceFillClock, SystemClockTicker}; -use crate::audio::effects::{update_meter, MeterHandle, WaveformHandle}; +use crate::audio::clock::{ClockSource, SystemClockTicker}; +use crate::audio::effects::{MeterHandle, WaveformHandle}; use crate::audio::encoders::{build_encoder, validate_append_target, AudioEncoder}; use crate::audio::graph::{NetCodec, OutputSpec, RecordingFormat, RecordingMode, ValidOutput}; -use crate::audio::resample::FixedRateResampler; -use crate::audio::streams; use crate::error::{AppError, AppResult}; -use super::dag::{ring_capacity_frames, OutputGraph, DSP_BLOCK_FRAMES}; +use super::dag::{OutputGraph, TIMER_BLOCK_FRAMES}; use super::worker::{dsp_worker, WorkerCtrl}; #[cfg(target_os = "macos")] @@ -33,26 +30,15 @@ mod windows; #[cfg(target_os = "windows")] use windows as platform; +mod render; + pub(super) use platform::{resolve_speaker, start_speaker_stream, SpeakerHandle, SpeakerResolved}; +pub(super) use render::SpeakerIo; +use render::{speaker_link, SpeakerLink, SpeakerRenderer}; // No live inputs -> fall back to 48 kHz for the recorder. const RECORDER_DEFAULT_SR: u32 = 48_000; -// Floor for the adaptive fill target: enough to absorb a DSP-side spike -// without the device clock -- not the wall clock -- ever seeing an empty ring. -// 3 blocks = 64 ms @ 48 kHz / DSP_BLOCK_FRAMES. -pub(super) const SPEAKER_TARGET_FILL_BLOCKS: usize = 3; - -// Extra frames held beyond the device's own callback buffer so the ring never -// sits exactly empty when the next callback lands. -const SPEAKER_TARGET_MARGIN_BLOCKS: usize = 2; - -#[inline] -fn pipeline_frames_to_device_frames(frames: usize, pipeline_rate: u32, device_rate: u32) -> usize { - ((frames as u64 * device_rate.max(1) as u64 + pipeline_rate.max(1) as u64 / 2) - / pipeline_rate.max(1) as u64) as usize -} - pub(super) enum ResolvedOutput { Speaker(SpeakerResolved), File { @@ -173,20 +159,6 @@ pub(super) fn resolve_output( } } -pub(super) struct SpeakerWorker { - stop: Arc, - join: Option>, -} - -impl Drop for SpeakerWorker { - fn drop(&mut self) { - self.stop.store(true, Ordering::SeqCst); - if let Some(j) = self.join.take() { - let _ = j.join(); - } - } -} - pub(super) struct RecorderWorker { pub stop: Arc, pub join: Option>, @@ -201,42 +173,36 @@ impl Drop for RecorderWorker { } } -// Per-callback counters for diagnosing the device's actual pull rate against -// the DSP worker's supply rate -- distinguishes "device asking for more than -// real time implies" from "ring nobody fills". Read once a second by the -// non-RT tick thread (`meter::spawn_xrun_thread`); every write here is -// `Ordering::Relaxed`, no allocation, no other sync. -#[derive(Clone)] -pub(super) struct SpeakerIo { - /// Native clock rate of the physical output stream. This differs from the - /// graph's pipeline rate when the output resampler is active. - pub sample_rate: Arc, - /// Samples cpal's `fill` was asked for (`out.len()`), summed across callbacks. - pub requested: Arc, - /// Samples actually popped off the ring (`bulk_pop`'s return), summed across callbacks. - pub read: Arc, - /// Number of `fill` invocations. - pub callbacks: Arc, - /// Adaptive fill target in frames, sized to the device's own buffer by the - /// callback. The worker's clock steers to it; the UI reads it back as the - /// current output latency. - pub target_frames: Arc, - /// Lookahead delay compensation has aligned the graph to (frames) -- the - /// deepest cumulative effect latency on the path to this output. - pub graph_latency_frames: usize, +/// A speaker's device callback plus the handoff that carries its renderer in +/// and back out. Built per open attempt; the renderer is attached only once the +/// stream is up, so a failed open never takes the graph down with its closure. +pub(super) fn speaker_callback() -> (SpeakerLink, impl FnMut(&mut [f32], usize) + Send + 'static) { + let (link, mut callback) = speaker_link(); + (link, move |out: &mut [f32], frames: usize| { + callback.fill(out, frames) + }) } -impl SpeakerIo { - fn new(sample_rate: u32, target_frames: Arc, graph_latency_frames: usize) -> Self { - Self { - sample_rate: Arc::new(AtomicU32::new(sample_rate)), - requested: Arc::new(AtomicU64::new(0)), - read: Arc::new(AtomicU64::new(0)), - callbacks: Arc::new(AtomicU64::new(0)), - target_frames, - graph_latency_frames, - } - } +/// Renderer for a speaker whose stream opened at `device_rate` with +/// `channels` physical channels. `hardware_frames` is what the device reports +/// adding past its buffer, where the OS reports it. +pub(super) fn speaker_renderer( + mut graph: OutputGraph, + device_rate: u32, + channels: usize, + hardware_frames: Option, + meter: MeterHandle, +) -> AppResult<(SpeakerRenderer, WorkerCtrl, SpeakerIo)> { + graph.set_out_channels(channels); + SpeakerRenderer::new(graph, device_rate, hardware_frames, meter) +} + +/// The engine block expressed in a device's own frames: what its IO buffer +/// should be so one callback carries one block. +pub(super) fn device_block(block_frames: usize, pipeline_rate: u32, device_rate: u32) -> u32 { + ((block_frames as u64 * device_rate as u64 + pipeline_rate as u64 / 2) + / pipeline_rate.max(1) as u64) + .max(1) as u32 } /// Speaker streams still able to call back into us. Counts handles rather than @@ -262,157 +228,6 @@ impl Drop for StreamGuard { } } -// Builds the speaker ring plus the cpal-side `fill` closure, a fill-level -// handle shared with the worker's sink, and the adaptive fill target the -// worker's clock steers toward -- one ring shape for all platforms. -pub(super) fn speaker_ring( - out_channels: usize, - pipeline_rate: u32, - device_rate: u32, - graph_latency_frames: usize, -) -> ( - Producer, - impl FnMut(&mut [f32], usize) + Send + 'static, - Arc, - Arc, - SpeakerIo, -) { - // One second at the actual device rate, so high-rate and wide-channel - // streams have the same time capacity as 48 kHz stereo. - let capacity_frames = ring_capacity_frames(device_rate); - let (producer, mut consumer) = RingBuffer::::new(capacity_frames * out_channels); - let level = Arc::new(AtomicI64::new(0)); - let level_cb = level.clone(); - let target = Arc::new(AtomicI64::new(pipeline_frames_to_device_frames( - SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES, - pipeline_rate, - device_rate, - ) as i64)); - let target_cb = target.clone(); - let io = SpeakerIo::new(device_rate, target.clone(), graph_latency_frames); - let io_cb = io.clone(); - let fill = move |out: &mut [f32], callback_frames: usize| { - let read = streams::bulk_pop(&mut consumer, out); - level_cb.fetch_sub((read / out_channels) as i64, Ordering::Relaxed); - // Size the fill target to the device's own callback buffer so the ring - // always bridges one full callback. A healthy device asks for a few - // blocks and the floor holds; a large-buffer device (PipeWire handing - // out ~250 ms buffers) grows the target and runs at that latency instead - // of underrunning at a fraction of real time. - let min = pipeline_frames_to_device_frames( - SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES, - pipeline_rate, - device_rate, - ); - let margin = pipeline_frames_to_device_frames( - SPEAKER_TARGET_MARGIN_BLOCKS * DSP_BLOCK_FRAMES, - pipeline_rate, - device_rate, - ); - let dev_frames = if callback_frames == 0 { - out.len() / out_channels - } else { - callback_frames - }; - let max = capacity_frames.saturating_sub(dev_frames + margin).max(min); - target_cb.store( - (dev_frames + margin).clamp(min, max) as i64, - Ordering::Relaxed, - ); - io_cb - .requested - .fetch_add(out.len() as u64, Ordering::Relaxed); - io_cb.read.fetch_add(read as u64, Ordering::Relaxed); - io_cb.callbacks.fetch_add(1, Ordering::Relaxed); - }; - (producer, fill, level, target, io) -} - -fn output_resampler( - pipeline_rate: u32, - device_rate: u32, - channels: usize, -) -> AppResult> { - if pipeline_rate == device_rate { - Ok(None) - } else { - FixedRateResampler::new(pipeline_rate, device_rate, DSP_BLOCK_FRAMES, channels).map(Some) - } -} - -// Shared by both platforms' `start_speaker_stream`: a device-fill-paced -// worker that mixes the output sub-graph and bulk-pushes blocks into the -// speaker ring. -pub(super) fn spawn_speaker_worker( - mut producer: Producer, - level: Arc, - target: Arc, - device_sample_rate: Arc, - channels: usize, - graph: OutputGraph, - meter: MeterHandle, -) -> AppResult<(SpeakerWorker, WorkerCtrl)> { - let pipeline_rate = graph.sample_rate(); - let stop = Arc::new(AtomicBool::new(false)); - let stop_thread = stop.clone(); - let (worker, ctrl) = dsp_worker(graph); - let clock: Box = Box::new(DeviceFillClock::new( - pipeline_rate, - device_sample_rate.clone(), - DSP_BLOCK_FRAMES, - level.clone(), - target, - )); - let initial_device_rate = device_sample_rate.load(Ordering::Relaxed); - let mut resampler = output_resampler(pipeline_rate, initial_device_rate, channels)?; - let mut resampled = vec![ - 0.0_f32; - resampler - .as_ref() - .map(|r| r.out_max() * channels) - .unwrap_or(DSP_BLOCK_FRAMES * channels) - ]; - let mut resampled_channels = 0; - let join = thread::Builder::new() - .name(format!("speaker:{initial_device_rate}")) - .spawn(move || { - worker.run( - stop_thread, - clock, - Some(("speaker", pipeline_rate)), - |block, active_channels| { - update_meter(&meter, block, channels); - let device_block = if let Some(resampler) = &mut resampler { - resampled_channels = resampled_channels.max(active_channels); - let written = resampler.process_chunk_into( - block, - resampled_channels, - &mut resampled, - )?; - &resampled[..written] - } else { - block - }; - let written = streams::bulk_push_counted( - &mut producer, - device_block, - &crate::audio::health::SPEAKER_RING_OVERRUN_SAMPLES, - ); - level.fetch_add((written / channels) as i64, Ordering::Relaxed); - Ok(()) - }, - ); - }) - .map_err(|e| AppError::Stream(format!("spawn speaker worker: {e}")))?; - Ok(( - SpeakerWorker { - stop, - join: Some(join), - }, - ctrl, - )) -} - // Drives analyzers when there's no real output; sink discards the mix. pub(super) fn start_monitor_worker(graph: OutputGraph) -> AppResult<(RecorderWorker, WorkerCtrl)> { let stop = Arc::new(AtomicBool::new(false)); @@ -422,7 +237,7 @@ pub(super) fn start_monitor_worker(graph: OutputGraph) -> AppResult<(RecorderWor // keeps meters/scopes at real time and, crucially, consumes network-sourced // audio (WebRTC) at the rate it arrives instead of draining its jitter buffer. let sample_rate = graph.sample_rate(); - let ticker = SystemClockTicker::new(sample_rate, DSP_BLOCK_FRAMES); + let ticker = SystemClockTicker::new(sample_rate, TIMER_BLOCK_FRAMES); let (worker, ctrl) = dsp_worker(graph); let join = thread::Builder::new() .name("monitor".into()) @@ -456,7 +271,7 @@ pub(super) fn start_wire_sender_worker( let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); let sample_rate = graph.sample_rate(); - let ticker = SystemClockTicker::with_catchup(sample_rate, DSP_BLOCK_FRAMES, 8); + let ticker = SystemClockTicker::with_catchup(sample_rate, TIMER_BLOCK_FRAMES, 8); let (worker, ctrl) = dsp_worker(graph); let join = thread::Builder::new() .name("netsender".into()) @@ -496,7 +311,7 @@ pub(super) fn start_recorder_worker( // A file source decodes faster than real time; availability pacing would // drain it as fast as it arrives and over-run (a 1 s clip becomes 1:22). let clock: Box = - Box::new(SystemClockTicker::new(sample_rate, DSP_BLOCK_FRAMES)); + Box::new(SystemClockTicker::new(sample_rate, TIMER_BLOCK_FRAMES)); // Scope-style waveform feed, emitted to the UI by the meter tick thread. let wave = WaveformHandle::for_recorder(node_id.clone(), sample_rate, base_frames); @@ -599,8 +414,6 @@ pub(super) fn start_recorder_worker( #[cfg(test)] mod tests { use super::*; - use crate::audio::pipeline::dag::RESAMPLE_CHUNK; - use std::collections::HashMap; fn recording_output( path: &std::path::Path, @@ -666,67 +479,6 @@ mod tests { ); } - #[test] - fn test_output_resampler_bypassed_when_rates_match() { - // When initial_device_rate == pipeline_rate (e.g. 96 kHz pipeline and 96 kHz speaker), - // no output resampler must be allocated, preserving 1:1 bit-transparent playback. - for rate in [44_100, 48_000, 96_000, 192_000] { - let resampler = output_resampler(rate, rate, 2).unwrap(); - assert!( - resampler.is_none(), - "output resampler should be None for matching rate {rate}" - ); - } - } - - #[test] - fn test_output_resampler_only_allocated_when_rates_differ() { - let resampler = output_resampler(96_000, 48_000, 2).unwrap(); - assert!( - resampler.is_some(), - "output resampler must be Some when rates differ" - ); - } - - #[test] - fn test_output_bit_transparent_sample_passthrough() { - // Verify that when output resampler is None, samples pass directly to the device buffer. - let total_samples = RESAMPLE_CHUNK * 2; - let mut block = vec![0.0f32; total_samples]; - for (i, sample) in block.iter_mut().enumerate() { - *sample = ((i as f32) * 0.005).cos(); - } - - let mut resampler = output_resampler(96_000, 96_000, 2).unwrap(); - let mut resampled = Vec::new(); - let active_channels = 2; - let mut resampled_channels = 0; - - let device_block = if let Some(resampler) = &mut resampler { - resampled_channels = resampled_channels.max(active_channels); - let written = resampler - .process_chunk_into(&block[..total_samples], resampled_channels, &mut resampled) - .unwrap(); - &resampled[..written] - } else { - &block[..total_samples] - }; - - assert_eq!(device_block.len(), total_samples); - for (a, b) in device_block.iter().zip(block.iter()) { - assert_eq!(a.to_bits(), b.to_bits()); - } - } - - #[test] - fn pipeline_frames_convert_to_device_frames_with_rounding() { - assert_eq!(pipeline_frames_to_device_frames(1024, 48_000, 48_000), 1024); - assert_eq!(pipeline_frames_to_device_frames(1024, 48_000, 96_000), 2048); - assert_eq!(pipeline_frames_to_device_frames(1024, 96_000, 48_000), 512); - // Half-frame rounds up (512*48/96 = 256.0 → 256). - assert_eq!(pipeline_frames_to_device_frames(512, 96_000, 48_000), 256); - } - #[test] fn resolved_output_reports_rates() { assert_eq!(ResolvedOutput::WireSender(44_100).sample_rate(), 44_100); @@ -747,25 +499,10 @@ mod tests { } #[test] - fn speaker_ring_reads_what_was_pushed() { - let (mut prod, mut fill, level, target, io) = speaker_ring(2, 48_000, 48_000, 0); - let data: Vec = (0..1024 * 2).map(|i| i as f32 * 0.001).collect(); - // Ring is one second at the device rate — far larger than a block. - assert_eq!( - prod.push_entire_slice(&data).ok(), - Some(()), - "ring must absorb a block" - ); - let mut out = vec![0.0f32; 1024 * 2]; - fill(&mut out, 0); - assert_eq!(out, data); - // The fill decrements the gauge; the push-side increment lives in the - // worker, so the standalone gauge reads -read/out_channels. - assert_eq!(level.load(Ordering::Relaxed), -1024); - assert!(target.load(Ordering::Relaxed) > 0); - assert!(io.requested.load(Ordering::Relaxed) > 0); - assert!(io.read.load(Ordering::Relaxed) > 0); - assert_eq!(io.callbacks.load(Ordering::Relaxed), 1); + fn device_block_follows_the_device_rate() { + assert_eq!(device_block(64, 48_000, 48_000), 64); + assert_eq!(device_block(64, 48_000, 96_000), 128); + assert_eq!(device_block(256, 48_000, 44_100), 235); } #[test] @@ -778,90 +515,6 @@ mod tests { assert_eq!(LIVE_SPEAKER_STREAMS.load(Ordering::Relaxed), before); } - #[test] - fn speaker_worker_pushes_blocks_into_the_ring() { - use super::super::dag::build_output_graph; - use crate::audio::effects::EffectRegistry; - use crate::audio::graph::{EdgeSpec, GraphSpec, NodeKind, NodeSpec, ValidGraph}; - - // Build a monitor-style graph (mic source ring, no real device). - let g = GraphSpec { - sample_rate: None, - nodes: vec![ - NodeSpec { - id: "m".into(), - kind: NodeKind::Microphone, - data: serde_json::json!({ "deviceId": "dev" }), - }, - NodeSpec { - id: "s".into(), - kind: NodeKind::Speaker, - data: serde_json::json!({ "deviceId": "dev" }), - }, - ], - edges: vec![EdgeSpec { - id: "e".into(), - source: "m".into(), - source_handle: None, - target: "s".into(), - target_handle: None, - }], - }; - let valid: ValidGraph = g.validate().expect("valid"); - let mut producer_pairs = Vec::new(); - let native = valid - .inputs - .iter() - .map(|i| (i.id.clone(), 48_000)) - .collect(); - let native_ch = valid.inputs.iter().map(|i| (i.id.clone(), 2u32)).collect(); - let mut reg = EffectRegistry::new(); - let built = build_output_graph( - Some("s"), - 48_000, - false, - &valid, - &native, - &native_ch, - &mut producer_pairs, - &mut reg, - &HashMap::new(), - &HashMap::new(), - &HashMap::new(), - &HashMap::new(), - HashMap::new(), - ) - .expect("build"); - - let (prod, _fill, level, target, io) = speaker_ring(2, 48_000, 48_000, 0); - let device_sr = Arc::new(AtomicU32::new(48_000)); - let meter = MeterHandle::new("w".into()); - let (_worker, _ctrl) = spawn_speaker_worker( - prod, - level.clone(), - target.clone(), - device_sr.clone(), - 2, - built.graph, - meter, - ) - .expect("spawn worker"); - // Polled, not slept: a loaded CI runner can start the worker late. - let deadline = std::time::Instant::now() + Duration::from_secs(3); - while level.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(10)); - } - assert!( - level.load(Ordering::Relaxed) > 0, - "worker pushed audio into the ring" - ); - assert_eq!( - io.callbacks.load(Ordering::Relaxed), - 0, - "no device callback is attached" - ); - } - #[test] fn monitor_worker_runs_at_wall_clock() { use super::super::dag::build_output_graph; @@ -871,6 +524,7 @@ mod tests { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ NodeSpec { id: "m".into(), @@ -903,6 +557,7 @@ mod tests { let built = build_output_graph( None, 48_000, + TIMER_BLOCK_FRAMES, true, &valid, &native, diff --git a/src-tauri/src/audio/pipeline/output/render.rs b/src-tauri/src/audio/pipeline/output/render.rs new file mode 100644 index 00000000..3359e291 --- /dev/null +++ b/src-tauri/src/audio/pipeline/output/render.rs @@ -0,0 +1,505 @@ +//! Speaker rendering inside the device callback. +//! +//! The device callback is the clock: each callback renders exactly the audio +//! it plays, with no worker thread and no output ring in between. A callback +//! size that differs from the engine block (or a device rate that differs from +//! the pipeline rate) goes through a small adapter holding at most one +//! rendered block. +//! +//! The renderer owns the output graph, so it must never be freed on the audio +//! thread or leaked with a callback closure (cpal's CoreAudio backend leaks the +//! closure of a non-default device). It therefore reaches the callback through +//! an SPSC handoff and is handed back to the control thread on retire. + +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::audio::effects::{update_meter, MeterHandle}; +use crate::audio::health; +use crate::audio::resample::FixedRateResampler; +use crate::error::AppResult; + +use super::super::dag::OutputGraph; +use super::super::worker::{dsp_worker, DspWorker, WorkerCtrl}; + +/// How long a retire waits for the callback to hand the renderer back. A live +/// device calls back within one buffer; a dead one never will. +const RETIRE_TIMEOUT: Duration = Duration::from_millis(500); + +/// Blocks rendered in a row without output before a callback gives up. The +/// FFT resampler holds input until it has a whole FFT frame (160 frames for +/// 48 -> 44.1 kHz), so several small blocks may go in before any comes out. +const MAX_SILENT_BLOCKS: usize = 64; + +/// Per-speaker counters, written relaxed by the callback and read by the +/// non-RT tick thread and the latency report. +#[derive(Clone)] +pub(in crate::audio::pipeline) struct SpeakerIo { + /// Native clock rate of the physical output stream. Differs from the + /// graph's pipeline rate when the output resampler is active. + pub sample_rate: u32, + /// Samples the device asked for, summed across callbacks. + pub requested: Arc, + pub callbacks: Arc, + /// Frames the device asked for in its latest callback: its real IO buffer. + pub callback_frames: Arc, + /// Device frames already rendered but not yet played when the latest + /// callback started: the block adapter's share of the latency. + pub carried_frames: Arc, + /// Worst render time since the last read, in permille of the audio it + /// produced. Above 1000 the device outran the graph. + pub load_peak_permille: Arc, + /// Renders that took longer than the audio they produced. + pub overloads: Arc, + /// Delay compensation the running graph is aligned to, in pipeline frames. + /// Follows graph swaps. + pub graph_latency_frames: Arc, + /// Filter delay of the output resampler, in device frames. + pub resampler_delay_frames: usize, + /// What the device adds past its buffer (safety offset, converters), in + /// device frames. `None` where the OS does not report it. + pub hardware_frames: Option, +} + +pub(in crate::audio::pipeline) struct SpeakerRenderer { + worker: DspWorker, + block: Box<[f32]>, + resampler: Option, + resampled_channels: usize, + /// Rendered device-rate audio not yet handed to the device. + pending: Box<[f32]>, + pending_pos: usize, + pending_len: usize, + channels: usize, + meter: MeterHandle, + io: SpeakerIo, +} + +impl SpeakerRenderer { + /// `graph` must already carry the device's channel count. + pub(in crate::audio::pipeline) fn new( + graph: OutputGraph, + device_rate: u32, + hardware_frames: Option, + meter: MeterHandle, + ) -> AppResult<(Self, WorkerCtrl, SpeakerIo)> { + let channels = graph.out_channels().max(1); + let block_frames = graph.block_frames(); + let pipeline_rate = graph.sample_rate(); + let resampler = if pipeline_rate == device_rate { + None + } else { + Some(FixedRateResampler::new( + pipeline_rate, + device_rate, + block_frames, + channels, + )?) + }; + let pending_frames = resampler + .as_ref() + .map_or(block_frames, FixedRateResampler::out_max); + let io = SpeakerIo { + sample_rate: device_rate, + requested: Arc::new(AtomicU64::new(0)), + callbacks: Arc::new(AtomicU64::new(0)), + callback_frames: Arc::new(AtomicU32::new(0)), + carried_frames: Arc::new(AtomicU32::new(0)), + load_peak_permille: Arc::new(AtomicU32::new(0)), + overloads: Arc::new(AtomicU64::new(0)), + graph_latency_frames: Arc::new(AtomicU32::new(graph.latency_frames() as u32)), + resampler_delay_frames: resampler + .as_ref() + .map_or(0, FixedRateResampler::delay_frames), + hardware_frames, + }; + let (worker, ctrl) = dsp_worker(graph); + Ok(( + Self { + worker, + block: vec![0.0; block_frames * channels].into_boxed_slice(), + resampler, + resampled_channels: 0, + pending: vec![0.0; pending_frames * channels].into_boxed_slice(), + pending_pos: 0, + pending_len: 0, + channels, + meter, + io: io.clone(), + }, + ctrl, + io, + )) + } + + /// Fill `out` (interleaved, device channel width) with the next audio. + /// `callback_frames` is the device's whole callback, which a caller may + /// deliver in several slices. RT-safe: no allocation, lock or syscall. + pub(in crate::audio::pipeline) fn render(&mut self, out: &mut [f32], callback_frames: usize) { + let started = Instant::now(); + self.io + .requested + .fetch_add(out.len() as u64, Ordering::Relaxed); + self.io.callbacks.fetch_add(1, Ordering::Relaxed); + self.io + .callback_frames + .store(callback_frames as u32, Ordering::Relaxed); + self.io.carried_frames.store( + ((self.pending_len - self.pending_pos) / self.channels) as u32, + Ordering::Relaxed, + ); + + let mut written = 0; + let mut silent_blocks = 0; + while written < out.len() { + if self.pending_pos == self.pending_len { + if !self.produce() || silent_blocks == MAX_SILENT_BLOCKS { + out[written..].fill(0.0); + break; + } + silent_blocks += 1; + continue; + } + silent_blocks = 0; + let n = (self.pending_len - self.pending_pos).min(out.len() - written); + out[written..written + n] + .copy_from_slice(&self.pending[self.pending_pos..self.pending_pos + n]); + self.pending_pos += n; + written += n; + } + + let frames = (out.len() / self.channels) as u64; + if frames > 0 { + let period_ns = frames * 1_000_000_000 / self.io.sample_rate.max(1) as u64; + let spent_ns = started.elapsed().as_nanos() as u64; + let permille = (spent_ns * 1000 / period_ns.max(1)).min(u32::MAX as u64) as u32; + self.io + .load_peak_permille + .fetch_max(permille, Ordering::Relaxed); + if permille > 1000 { + self.io.overloads.fetch_add(1, Ordering::Relaxed); + } + } + } + + /// Render one engine block into `pending`, which may stay empty while the + /// resampler fills an FFT frame. False when the resampler failed. + fn produce(&mut self) -> bool { + let active = self.worker.next_block(&mut self.block); + self.io.graph_latency_frames.store( + self.worker.graph().latency_frames() as u32, + Ordering::Relaxed, + ); + update_meter(&self.meter, &self.block, self.channels); + self.pending_pos = 0; + self.pending_len = 0; + self.pending_len = match &mut self.resampler { + None => { + self.pending[..self.block.len()].copy_from_slice(&self.block); + self.block.len() + } + Some(resampler) => { + self.resampled_channels = self.resampled_channels.max(active); + match resampler.process_chunk_into( + &self.block, + self.resampled_channels, + &mut self.pending, + ) { + Ok(n) => n, + Err(_) => { + health::bump(&health::SPEAKER_RENDER_FAILED_BLOCKS, 1); + return false; + } + } + } + }; + true + } +} + +/// The audio-thread end of a speaker: renders once the renderer has arrived, +/// silence before that and after a retire. +pub(in crate::audio::pipeline) struct SpeakerCallback { + incoming: Consumer, + outgoing: Producer, + retire: Arc, + renderer: Option, +} + +impl SpeakerCallback { + pub(in crate::audio::pipeline) fn fill(&mut self, out: &mut [f32], callback_frames: usize) { + if self.retire.load(Ordering::Acquire) { + if let Some(r) = self.renderer.take() { + let _ = self.outgoing.push(r); + } + } else if self.renderer.is_none() { + self.renderer = self.incoming.pop().ok(); + } + match &mut self.renderer { + Some(r) => r.render(out, callback_frames), + None => out.fill(0.0), + } + } +} + +/// The control-thread end of a speaker's handoff. +pub(in crate::audio::pipeline) struct SpeakerLink { + incoming: Producer, + outgoing: Consumer, + retire: Arc, +} + +pub(in crate::audio::pipeline) fn speaker_link() -> (SpeakerLink, SpeakerCallback) { + let (in_tx, in_rx) = RingBuffer::new(1); + let (out_tx, out_rx) = RingBuffer::new(1); + let retire = Arc::new(AtomicBool::new(false)); + ( + SpeakerLink { + incoming: in_tx, + outgoing: out_rx, + retire: retire.clone(), + }, + SpeakerCallback { + incoming: in_rx, + outgoing: out_tx, + retire, + renderer: None, + }, + ) +} + +impl SpeakerLink { + /// Hand the renderer to the callback. Call once the stream is open, so a + /// failed open never takes the graph down with its closure. + pub(in crate::audio::pipeline) fn attach(&mut self, renderer: SpeakerRenderer) { + if self.incoming.push(renderer).is_err() { + tracing::error!("speaker renderer attached twice"); + } + } + + /// Take the renderer back from the callback so it drops here, not on the + /// audio thread. `None` when the device stopped calling back first. + pub(in crate::audio::pipeline) fn retire(&mut self) -> Option { + self.retire.store(true, Ordering::Release); + let deadline = Instant::now() + RETIRE_TIMEOUT; + loop { + if let Ok(r) = self.outgoing.pop() { + return Some(r); + } + if self.outgoing.is_abandoned() { + return None; + } + if Instant::now() >= deadline { + tracing::warn!("speaker callback never handed its renderer back"); + return None; + } + std::thread::sleep(Duration::from_millis(1)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::graph::BUFFER_FRAME_OPTIONS; + use crate::audio::pipeline::dag::graph_tests::{build_with_block, passthrough_graph}; + + const SR: u32 = 48_000; + + fn feed(input: &mut rtrb::Producer, data: &[f32]) { + let (_, rest) = input.push_partial_slice(data); + assert!(rest.is_empty(), "input ring holds the whole test signal"); + } + + fn ramp(frames: usize) -> Vec { + (0..frames * 2).map(|i| (i / 2) as f32 + 1.0).collect() + } + + struct Rig { + r: SpeakerRenderer, + ctrl: WorkerCtrl, + io: SpeakerIo, + input: rtrb::Producer, + blocks: Arc, + } + + fn rig(block: usize, device_rate: u32) -> Rig { + let (valid, _) = passthrough_graph(); + let (built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, false); + let blocks = built.output.blocks.clone(); + let (r, ctrl, io) = + SpeakerRenderer::new(built.graph, device_rate, None, MeterHandle::new("s".into())) + .expect("renderer"); + Rig { + r, + ctrl, + io, + input: producers.remove("m").unwrap(), + blocks, + } + } + + /// Renders `sizes` in turn until `total` frames have been played. + fn play(r: &mut SpeakerRenderer, sizes: &[usize], total: usize) -> Vec { + let mut got = Vec::new(); + let mut i = 0; + while got.len() < total * 2 { + let n = sizes[i % sizes.len()]; + let mut out = vec![f32::NAN; n * 2]; + r.render(&mut out, n); + got.extend_from_slice(&out); + i += 1; + } + got.truncate(total * 2); + got + } + + #[test] + fn callback_matching_the_block_adds_no_latency() { + for block in BUFFER_FRAME_OPTIONS.map(|n| n as usize) { + let mut t = rig(block, SR); + let fed = ramp(block * 8); + feed(&mut t.input, &fed); + let got = play(&mut t.r, &[block], block * 8); + assert_eq!( + got, fed, + "{block}: first callback plays the first input frame" + ); + assert_eq!(t.io.carried_frames.load(Ordering::Relaxed), 0); + assert_eq!(t.io.callback_frames.load(Ordering::Relaxed), block as u32); + } + } + + #[test] + fn uneven_callbacks_stream_without_gaps_or_repeats() { + // Device slices that never line up with the 64-frame block. + let mut t = rig(64, SR); + let fed = ramp(4096); + feed(&mut t.input, &fed); + let got = play(&mut t.r, &[37, 100, 1, 64, 250], 4000); + assert_eq!(got, fed[..4000 * 2]); + assert!( + t.io.carried_frames.load(Ordering::Relaxed) < 64, + "the adapter never holds a whole block" + ); + } + + #[test] + fn device_rate_differs_from_pipeline_rate() { + // 48 kHz graph into a 44.1 kHz device. Small blocks go into the FFT + // resampler several times before it emits anything; the device must + // still get continuous audio, and the graph must run at real time. + for block in [32, 64, 128, 256, 1024] { + let mut t = rig(block, 44_100); + feed(&mut t.input, &vec![0.25_f32; 48_000 * 2]); + let mut got = Vec::new(); + for _ in 0..100 { + let mut out = vec![f32::NAN; 441 * 2]; + t.r.render(&mut out, 441); + got.extend_from_slice(&out); + } + let settled = &got[2 * 441 * 2..]; + assert!( + settled.iter().all(|s| (s - 0.25).abs() < 0.01), + "{block}: gap or glitch in the resampled stream" + ); + let blocks = t.blocks.load(Ordering::Relaxed) as i64; + let want = 48_000 / block as i64; + assert!( + (blocks - want).abs() <= 2 + 160 / block as i64, + "{block}: {blocks} blocks rendered for one second, want ~{want}" + ); + assert!(t.io.resampler_delay_frames > 0); + } + } + + #[test] + fn graph_swap_lands_between_blocks() { + let mut t = rig(64, SR); + let mut out = vec![0.0; 64 * 2]; + t.r.render(&mut out, 64); + let (valid, _) = passthrough_graph(); + let (built, _) = build_with_block(Some("s"), SR, 64, &valid, SR, false); + let mut next = built.graph; + next.set_out_channels(2); + let blocks = built.output.blocks.clone(); + t.ctrl.send_graph(next).expect("swap"); + t.r.render(&mut out, 64); + assert_eq!(blocks.load(Ordering::Relaxed), 1, "new graph renders"); + assert_eq!(t.blocks.load(Ordering::Relaxed), 1, "old graph stopped"); + assert_eq!(t.io.callbacks.load(Ordering::Relaxed), 2); + } + + #[test] + fn rendering_never_touches_the_heap() { + for (block, device_rate) in [(32, SR), (256, SR), (64, 44_100), (512, 96_000)] { + let mut t = rig(block, device_rate); + feed(&mut t.input, &ramp(40_000)); + let mut out = vec![0.0; 1024 * 2]; + for n in [block, 37, 500] { + t.r.render(&mut out[..n * 2], n); + } + crate::audio::rt_guard::assert_no_alloc( + &format!("render {block} @ {device_rate}"), + || { + for n in [block, 37, 500, 1, block * 2, 1024] { + t.r.render(&mut out[..n * 2], n); + } + }, + ); + } + } + + #[test] + fn load_is_measured_against_the_callback_period() { + let mut t = rig(256, SR); + let mut out = vec![0.0; 256 * 2]; + t.r.render(&mut out, 256); + let load = t.io.load_peak_permille.load(Ordering::Relaxed); + assert!( + load < 1000, + "a passthrough renders faster than real time: {load}" + ); + } + + #[test] + fn callback_plays_silence_until_attached_and_after_retire() { + let (mut link, mut cb) = speaker_link(); + let mut t = rig(64, SR); + feed(&mut t.input, &ramp(1024)); + + let mut out = vec![1.0; 64 * 2]; + cb.fill(&mut out, 64); + assert!(out.iter().all(|&s| s == 0.0), "no renderer yet"); + + link.attach(t.r); + cb.fill(&mut out, 64); + assert_eq!(out[0], 1.0, "renderer arrived and plays the input"); + + // Retire on another thread while the "device" keeps calling back. + let handle = std::thread::spawn(move || link.retire().is_some()); + let deadline = Instant::now() + Duration::from_secs(2); + while !handle.is_finished() && Instant::now() < deadline { + cb.fill(&mut out, 64); + std::thread::sleep(Duration::from_millis(1)); + } + assert!( + handle.join().unwrap(), + "renderer came back to the control thread" + ); + cb.fill(&mut out, 64); + assert!(out.iter().all(|&s| s == 0.0), "retired callback is silent"); + } + + #[test] + fn retire_gives_up_on_a_dead_device() { + let (mut link, cb) = speaker_link(); + let t = rig(64, SR); + link.attach(t.r); + drop(cb); + assert!(link.retire().is_none()); + } +} diff --git a/src-tauri/src/audio/pipeline/output/windows.rs b/src-tauri/src/audio/pipeline/output/windows.rs index e018b017..e5e67484 100644 --- a/src-tauri/src/audio/pipeline/output/windows.rs +++ b/src-tauri/src/audio/pipeline/output/windows.rs @@ -14,7 +14,7 @@ use crate::error::AppResult; use super::super::dag::OutputGraph; use super::super::native::native_config; use super::super::worker::WorkerCtrl; -use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, SpeakerWorker, StreamGuard}; +use super::{speaker_callback, speaker_renderer, SpeakerIo, SpeakerLink, StreamGuard}; pub(in crate::audio::pipeline) struct SpeakerResolved { pub device: cpal::Device, @@ -24,22 +24,23 @@ pub(in crate::audio::pipeline) struct SpeakerResolved { pub sample_rate: u32, } -// The stream drops before the worker so the audio callback stops before the -// ring is freed. pub(in crate::audio::pipeline) struct SpeakerHandle { _stream: cpal::Stream, - _worker: SpeakerWorker, + link: SpeakerLink, _alive: StreamGuard, } // `Stream::drop` isn't guaranteed to stop the underlying device (cpal's macOS // backend never does for a non-default device, see the macOS SpeakerHandle); // call `pause` explicitly so teardown doesn't depend on that guarantee here too. +// The renderer is taken back first so the graph drops on this thread. impl Drop for SpeakerHandle { fn drop(&mut self) { + let renderer = self.link.retire(); if let Err(e) = self._stream.pause() { warn!(error = %e, "failed to pause speaker stream on teardown"); } + drop(renderer); } } @@ -73,12 +74,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( let dead = Arc::new(AtomicBool::new(false)); - let (producer, fill, level, target, io) = speaker_ring( - spec.out_channels, - graph.sample_rate(), - spec.sample_rate, - graph.latency_frames(), - ); + let (mut link, fill) = speaker_callback(); let app_err = app.clone(); let dead_cb = dead.clone(); let node_id_cb = node_id.to_string(); @@ -103,19 +99,13 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( err_cb, )?; - let (worker_handle, ctrl) = spawn_speaker_worker( - producer, - level, - target, - io.sample_rate.clone(), - spec.out_channels, - graph, - meter, - )?; + let (renderer, ctrl, io) = + speaker_renderer(graph, spec.sample_rate, spec.out_channels, None, meter)?; + link.attach(renderer); Ok(( SpeakerHandle { _stream: stream, - _worker: worker_handle, + link, _alive: StreamGuard::new(), }, ctrl, diff --git a/src-tauri/src/audio/pipeline/sig.rs b/src-tauri/src/audio/pipeline/sig.rs index 62fcf493..f3441843 100644 --- a/src-tauri/src/audio/pipeline/sig.rs +++ b/src-tauri/src/audio/pipeline/sig.rs @@ -244,6 +244,7 @@ mod tests { fn mic_to_speaker(gain_db: f32) -> ValidGraph { GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), gain_node("g", gain_db), speaker("s")], edges: vec![edge("e1", "m", "g"), edge("e2", "g", "s")], } @@ -257,6 +258,7 @@ mod tests { // its own output can reach. let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), gain_node("a", 0.0), @@ -295,6 +297,7 @@ mod tests { fn mute_and_bypass_flags_are_not_structural() { let with_bypass = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), node( @@ -318,6 +321,7 @@ mod tests { let mk = |lookahead: f32, ceiling: f32| { GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), node( @@ -347,6 +351,7 @@ mod tests { let mk = |state: Option<&str>| { GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ node( "p", @@ -370,6 +375,7 @@ mod tests { assert_eq!(a, b, "state applies only at instantiation"); let other_path = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ node( "p", @@ -418,6 +424,7 @@ mod tests { fn sidechain_edges_sort_after_main() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m1"), mic("m2"), @@ -520,6 +527,7 @@ mod tests { let mk = |data: serde_json::Value| { GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![mic("m"), node("fx", kind.clone(), data), speaker("s")], edges: vec![edge("e1", "m", "fx"), edge("e2", "fx", "s")], } diff --git a/src-tauri/src/audio/pipeline/worker.rs b/src-tauri/src/audio/pipeline/worker.rs index 1f5adc54..a6e8afb1 100644 --- a/src-tauri/src/audio/pipeline/worker.rs +++ b/src-tauri/src/audio/pipeline/worker.rs @@ -13,7 +13,7 @@ use audio_thread_priority::{ use crate::audio::clock::ClockSource; use crate::error::{AppError, AppResult}; -use super::dag::{OutputGraph, DSP_BLOCK_FRAMES}; +use super::dag::OutputGraph; /// Let input rings collect a few cpal buffers before starting the clock -- /// otherwise the first block is all zeros. @@ -97,19 +97,36 @@ pub(super) fn dsp_worker(graph: OutputGraph) -> (DspWorker, WorkerCtrl) { impl DspWorker { /// Drain any graph swaps queued by main. RT-safe -- alloc-free pop + - /// alloc-free push of the displaced graph back to main. + /// alloc-free push of the displaced graph back to main. A swap never + /// changes the block size: that is an engine format change, which reopens + /// the stream instead. #[inline] fn drain_swaps(&mut self) { while let Ok(new_graph) = self.cmd_rx.pop() { + debug_assert_eq!(new_graph.block_frames(), self.graph.block_frames()); let old = std::mem::replace(&mut self.graph, new_graph); let _ = self.old_graph_tx.push(old); } } - /// All workers ride the transport clock: produce a block each wall-clock - /// period. Speaker is device-paced; recording/monitoring share the same - /// cadence so a file source (which decodes faster than real time) can't - /// over-run a sink. A missed deadline becomes silence, never a rate error. + /// Take any queued graph swap, then render one block into `block` + /// (`block_frames * out_channels` long). Returns the output channels the + /// graph actually drives. RT-safe. + #[inline] + pub(super) fn next_block(&mut self, block: &mut [f32]) -> usize { + self.drain_swaps(); + self.graph.process_block(block); + self.graph.active_output_channels() + } + + pub(super) fn graph(&self) -> &OutputGraph { + &self.graph + } + + /// Timer-paced workers (recording, monitoring, wire senders): produce a + /// block each wall-clock period, so a file source (which decodes faster + /// than real time) can't over-run a sink. A missed deadline becomes + /// silence, never a rate error. Speakers render in their device callback. pub(super) fn run( mut self, stop: Arc, @@ -120,7 +137,7 @@ impl DspWorker { F: FnMut(&[f32], usize) -> AppResult<()>, { thread::sleep(DSP_PREROLL); - let mut block = vec![0.0_f32; DSP_BLOCK_FRAMES * self.graph.out_channels()]; + let mut block = vec![0.0_f32; self.graph.block_frames() * self.graph.out_channels()]; let mut rt = None; loop { @@ -134,14 +151,13 @@ impl DspWorker { if let Some((name, sample_rate)) = realtime { rt = Some(RtThread::promote( name, - DSP_BLOCK_FRAMES as u32, + self.graph.block_frames() as u32, sample_rate, )); } } - self.graph.process_block(&mut block); - let active_channels = self.graph.active_output_channels(); + let active_channels = self.next_block(&mut block); if let Err(e) = sink(&block, active_channels) { warn!(error = %e, "DSP worker sink failed; stopping"); break; diff --git a/src-tauri/src/audio/playback.rs b/src-tauri/src/audio/playback.rs index eaaedefc..09e021df 100644 --- a/src-tauri/src/audio/playback.rs +++ b/src-tauri/src/audio/playback.rs @@ -28,16 +28,29 @@ impl Drop for Playback { } impl Playback { + /// `block_frames` is the quantum requested from the graph: the speaker + /// renders inside `fill`, so it is the output's whole buffering. pub fn start( sink_node_name: &str, sample_rate: u32, channels: usize, + block_frames: usize, fill: impl FnMut(&mut [f32]) -> usize + Send + 'static, ) -> AppResult { let (sender, receiver) = pw::channel::channel::(); let target = sink_node_name.to_string(); let thread = std::thread::spawn(move || { - if let Err(e) = run(receiver, &target, sample_rate, channels, Box::new(fill)) { + // `fill` renders the whole speaker graph on this thread's loop. + // PipeWire owns the callback quantum, so no frame count is promised. + let _rt = crate::audio::pipeline::RtThread::promote("speaker", 0, sample_rate); + if let Err(e) = run( + receiver, + &target, + sample_rate, + channels, + block_frames, + Box::new(fill), + ) { tracing::error!("pipewire playback: {e:?}"); } }); @@ -53,6 +66,7 @@ fn run( sink_node_name: &str, sample_rate: u32, channels: usize, + block_frames: usize, fill: Box usize + Send>, ) -> Result<(), pw::Error> { let mainloop = pw::main_loop::MainLoopRc::new(None)?; @@ -70,10 +84,9 @@ fn run( *pw::keys::MEDIA_ROLE => "Music", }; props.insert(*pw::keys::TARGET_OBJECT, sink_node_name); - let latency_frames = (sample_rate / 100).max(1); props.insert( *pw::keys::NODE_LATENCY, - format!("{latency_frames}/{sample_rate}"), + format!("{}/{sample_rate}", block_frames.max(1)), ); let stream = pw::stream::StreamRc::new(core.clone(), "splitwave-playback", props)?; @@ -164,6 +177,7 @@ mod tests { "alsa_output.pci-0000_00_0a.0.stereo-fallback", 48_000, 2, + 256, move |buf| { c.fetch_add(1, Ordering::Relaxed); t.fetch_add(buf.len(), Ordering::Relaxed); diff --git a/src-tauri/src/audio/plugins/host_api.rs b/src-tauri/src/audio/plugins/host_api.rs index abfc7be0..9cfcfca1 100644 --- a/src-tauri/src/audio/plugins/host_api.rs +++ b/src-tauri/src/audio/plugins/host_api.rs @@ -16,9 +16,10 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD, Engine as _}; use super::{ParamRing, PluginFormat, PluginNode}; +use crate::audio::effects::offload::pad_frames; use crate::audio::effects::offload::{BlockProcessor, Offload}; use crate::audio::effects::Effect; -use crate::audio::pipeline::dag::DSP_BLOCK_FRAMES; +use crate::audio::graph::MAX_BUFFER_FRAMES; /// Emitted with the node id when a plugin editor window is closed via its /// titlebar, so the frontend node can reset its open/close button. @@ -146,6 +147,8 @@ pub struct HostedEffect { backend: HostedBackend, channels: usize, latency: usize, + /// The offload pad when it exceeds the engine block. + working_block: Option, } enum HostedBackend { @@ -155,7 +158,7 @@ enum HostedBackend { } impl HostedEffect { - pub fn new(node: HostedNode, realtime: bool) -> Self { + pub fn new(node: HostedNode, realtime: bool, sample_rate: u32, block_frames: usize) -> Self { let latency = node.latency_frames(); let width = node.channels(); if !realtime { @@ -163,29 +166,34 @@ impl HostedEffect { backend: HostedBackend::Inline { node }, channels: width, latency, + working_block: None, }; } let processor = HostedProcessor { node, width, - scratch: Vec::with_capacity(DSP_BLOCK_FRAMES * width), + scratch: Vec::with_capacity(MAX_BUFFER_FRAMES * width), }; - match Offload::spawn("plugin", processor, width) { - Ok(o) => { - let latency = latency + o.latency_frames(); - Self { - backend: HostedBackend::Offloaded(o), - channels: width, - latency, - } - } + let pad = pad_frames(sample_rate, block_frames, 0); + match Offload::spawn("plugin", processor, width, pad, sample_rate) { + Ok(o) => Self { + latency: latency + o.latency_frames(), + backend: HostedBackend::Offloaded(o), + channels: width, + working_block: (pad > block_frames).then_some(pad), + }, Err(p) => Self { backend: HostedBackend::Inline { node: p.node }, channels: width, latency, + working_block: None, }, } } + + pub fn working_block(&self) -> Option { + self.working_block + } } impl Effect for HostedEffect { diff --git a/src-tauri/src/audio/resample.rs b/src-tauri/src/audio/resample.rs index d9ea13cf..76b57e99 100644 --- a/src-tauri/src/audio/resample.rs +++ b/src-tauri/src/audio/resample.rs @@ -142,6 +142,11 @@ impl FixedRateResampler { self.out_max } + /// Output-rate frames between a sample entering and leaving the filter. + pub fn delay_frames(&self) -> usize { + self.inner.output_delay() + } + pub fn process_chunk_into( &mut self, interleaved_in: &[f32], @@ -204,6 +209,11 @@ impl MultiResampler { pub fn chunk_in(&self) -> usize { self.chunk_in } + + /// Frames between a sample entering and leaving the filter. + pub fn delay_frames(&self) -> usize { + self.inner.output_delay() + } pub fn out_max(&self) -> usize { self.out_max } @@ -298,6 +308,38 @@ mod tests { assert_eq!(written % 2, 0); } + // A speaker renders one engine block per resample, so every offered buffer + // size has to work against every common device rate. + #[test] + fn fixed_rate_resampler_runs_at_every_buffer_size() { + const PAIRS: [(u32, u32); 6] = [ + (48_000, 44_100), + (44_100, 48_000), + (48_000, 96_000), + (96_000, 48_000), + (44_100, 96_000), + (48_000, 192_000), + ]; + for block in crate::audio::graph::BUFFER_FRAME_OPTIONS.map(|n| n as usize) { + for (from, to) in PAIRS { + let mut r = FixedRateResampler::new(from, to, block, 2) + .unwrap_or_else(|e| panic!("{from}->{to} @ {block}: {e}")); + let input = vec![0.25_f32; block * 2]; + let mut out = vec![0.0_f32; r.out_max() * 2]; + let chunks = (16_384 / block).max(8); + let mut produced = 0usize; + for _ in 0..chunks { + produced += r.process_chunk_into(&input, 2, &mut out).unwrap() / 2; + } + let want = (chunks * block) as f64 * to as f64 / from as f64; + assert!( + (produced as f64 - want).abs() <= r.out_max() as f64 + r.delay_frames() as f64, + "{from}->{to} @ {block}: produced {produced}, want ~{want}" + ); + } + } + } + #[test] fn fixed_rate_resampler_leaves_trailing_channels_silent() { const CHANNELS: usize = 16; diff --git a/src-tauri/src/audio/stream_recv.rs b/src-tauri/src/audio/stream_recv.rs index c4bacb2c..1a771285 100644 --- a/src-tauri/src/audio/stream_recv.rs +++ b/src-tauri/src/audio/stream_recv.rs @@ -22,37 +22,33 @@ use std::sync::{Arc, Mutex, Weak}; use rtrb::{Consumer, Producer, RingBuffer}; +use crate::audio::adaptive_depth::DepthEstimator; use crate::audio::health; use crate::audio::resample::MultiResamplerOut; use crate::audio::streams::bulk_push_counted; /// Decoded network audio is always carried at 48 kHz, one channel per stream. pub const SR: u32 = 48_000; -/// Output frames produced per block; must equal the DSP worker's block size. -pub const OUT_BLOCK_FRAMES: usize = 1024; /// Per-consumer, per-channel 48 kHz jitter ring (~2 s mono) -- headroom for a /// deep adaptive target plus a burst after a latency spike. pub const CONSUMER_RING: usize = 96_000; -/// Adaptive jitter-buffer target (mono samples at 48 kHz): the fill the drift -/// loop steers toward and the buffer primes to. Fast attack, slow release: an -/// outage is answered with the depth that would have covered it, and that depth -/// is handed back over minutes. Release quick enough to see is worse than none -/// -- it returns the buffer to a depth already proven to fail, right in time for -/// the spike to repeat, and the user hears the cycle rather than the adaptation. -const TARGET_INIT: usize = 2_880; // ~60 ms -const TARGET_MIN: usize = 1_920; // ~40 ms +/// Jitter-buffer depth (mono samples at 48 kHz) the drift loop steers the +/// fill toward and the buffer primes to. Sized by `DepthEstimator` from the +/// arrival jitter actually measured; these bound it. +const TARGET_INIT: usize = 2_880; // ~60 ms, until the first second is measured +const TARGET_MIN: usize = 960; // ~20 ms: one packet of the default codec const TARGET_MAX: usize = 19_200; // ~400 ms -/// Headroom over an outage the buffer failed to ride out. -const TARGET_MARGIN: usize = 480; // ~10 ms -/// Ceiling on what one outage may add, so a single dropout cannot pin the -/// target at its maximum for the rest of the session. +/// Kept above the measured jitter. +const TARGET_SAFETY: usize = 240; // ~5 ms +/// One jitter measurement spans this long: many packets, so one late packet +/// shows as a dip rather than as the whole window. +const JITTER_WINDOW_MS: f64 = 500.0; +/// A backlog jump beyond this is a re-prime refill, not drift. const TARGET_EVENT_MAX: usize = 4_800; // ~100 ms -const TARGET_DECAY: usize = 240; // -5 ms per calm window -const CALM_WINDOW: u32 = 1_440; // ~30 s at ~21 ms/block -/// Depth kept above what a block consumes when shrinking: the buffer still has -/// to absorb the jitter it has not seen yet. -const KEEP_MARGIN: usize = 960; // ~20 ms +/// A gap longer than this is the sender pausing, not network jitter, and +/// must not leave the buffer deep for the minute that follows. +const OUTAGE_MAX: usize = 48_000; // 1 s /// Proportional gain: backlog error (samples) -> ratio correction. A varying /// resample ratio IS pitch modulation, so the loop stays far slower and far @@ -73,12 +69,12 @@ const DRIFT_BACKLOG_ALPHA: f64 = 0.007; /// No correction while the smoothed backlog is this close to target. const DRIFT_DEADBAND: f64 = 240.0; -/// Blocks without a single new sample before a channel counts as gone (~170 ms -/// at 1024 frames / 48 kHz, the same window the DSP sources call a stall). A -/// channel the sender never transmits still has a tap here -- the UI can wire a -/// handle the peer doesn't fill, and a channel that stops is never reaped -- and -/// a group decision taken over it would stall every sibling forever. -const IDLE_BLOCKS_DEAD: u32 = 8; +/// Time without a single new sample before a channel counts as gone (the same +/// window the DSP sources call a stall). A channel the sender never transmits +/// still has a tap here -- the UI can wire a handle the peer doesn't fill, and +/// a channel that stops is never reaped -- and a group decision taken over it +/// would stall every sibling forever. +const IDLE_DEAD_MS: usize = 170; /// One received channel's fan-out: the 48 kHz ring producer for each live /// consumer. The decode task pushes decoded audio into all of them. @@ -119,6 +115,9 @@ pub struct ConsumerHandle { pub drift: Arc, pub target: Arc, pub realtime: bool, + /// Output frames the consumer's graph renders per block. + pub block_frames: usize, + pub output_rate: u32, } /// Push one channel's audio for the `packets` packets ending at `seq` (a lost @@ -191,6 +190,7 @@ pub struct PlaybackTap { popped: u64, last_total: u64, idle_blocks: u32, + idle_dead_blocks: u32, // PLC: the last real output block, and whether we're currently in a gap. // On a network underrun we fade this out (instead of a hard silence step), // and fade the real audio back in on recovery. @@ -199,10 +199,12 @@ pub struct PlaybackTap { } impl PlaybackTap { + #[allow(clippy::too_many_arguments)] fn new( group: u64, consumer: Consumer, rate: u32, + block_frames: usize, realtime: bool, primed: bool, drift: Arc, @@ -211,7 +213,7 @@ impl PlaybackTap { let in_sr = source_sr.load(Ordering::Relaxed).max(1); let base_ratio = rate as f64 / in_sr as f64; let resampler = - MultiResamplerOut::new(in_sr, rate, OUT_BLOCK_FRAMES, 1).expect("mono resampler init"); + MultiResamplerOut::new(in_sr, rate, block_frames, 1).expect("mono resampler init"); Self { group, consumer, @@ -224,14 +226,16 @@ impl PlaybackTap { realtime, drift, in_buf: Vec::with_capacity(4096), - scratch: Vec::with_capacity(OUT_BLOCK_FRAMES), + scratch: Vec::with_capacity(block_frames), valid: 0, primed, snap_backlog: 0, popped: 0, last_total: 0, idle_blocks: 0, - last_block: vec![0.0; OUT_BLOCK_FRAMES], + idle_dead_blocks: (IDLE_DEAD_MS * rate as usize / 1000 / block_frames.max(1)).max(2) + as u32, + last_block: vec![0.0; block_frames], gap: false, } } @@ -248,7 +252,7 @@ impl PlaybackTap { /// Whether this channel has gone quiet long enough to be left out of its /// group's decisions. fn dead(&self) -> bool { - self.idle_blocks >= IDLE_BLOCKS_DEAD + self.idle_blocks >= self.idle_dead_blocks } /// Fold this block's arrivals into the idle count. Call once per block, @@ -263,7 +267,7 @@ impl PlaybackTap { self.idle_blocks = self.idle_blocks.saturating_add(1); // Drop out of the primed set on the way out, so coming back means // re-priming with the group and picking its alignment up again. - if self.idle_blocks == IDLE_BLOCKS_DEAD { + if self.idle_blocks == self.idle_dead_blocks { self.primed = false; } } @@ -298,7 +302,7 @@ impl PlaybackTap { self.current_source_sr = in_sr; self.base_ratio = self.rate as f64 / in_sr as f64; self.last_ratio = self.base_ratio; - if let Ok(r) = MultiResamplerOut::new(in_sr, self.rate, OUT_BLOCK_FRAMES, 1) { + if let Ok(r) = MultiResamplerOut::new(in_sr, self.rate, self.last_block.len(), 1) { self.resampler = r; } } @@ -384,6 +388,7 @@ pub struct FanoutRegistry { struct ConsumerRef { rate: u32, + block_frames: usize, realtime: bool, drift: Arc, target: Arc, @@ -412,7 +417,12 @@ impl FanoutRegistry { /// New output consumer: an empty tap map wired a fresh ring into every known /// channel's broadcast. Locks `consumers` before `broadcasts`. - pub fn register_consumer(&self, output_sr: u32, realtime: bool) -> ConsumerHandle { + pub fn register_consumer( + &self, + output_sr: u32, + block_frames: usize, + realtime: bool, + ) -> ConsumerHandle { let map: TapMap = Arc::new(Mutex::new(HashMap::new())); let drift = Arc::new(AtomicU32::new(1.0f32.to_bits())); let target = Arc::new(AtomicU32::new(TARGET_INIT as u32)); @@ -463,6 +473,7 @@ impl FanoutRegistry { gid, cons, output_sr, + block_frames, realtime, false, drift.clone(), @@ -474,6 +485,7 @@ impl FanoutRegistry { drop(broadcasts); consumers.push(ConsumerRef { rate: output_sr, + block_frames, realtime, drift: drift.clone(), target: target.clone(), @@ -484,6 +496,8 @@ impl FanoutRegistry { drift, target, realtime, + block_frames, + output_rate: output_sr, } } @@ -548,6 +562,7 @@ impl FanoutRegistry { gid, cons, c.rate, + c.block_frames, c.realtime, primed, c.drift.clone(), @@ -608,9 +623,9 @@ pub struct ChannelReceiver { drift: Arc, target: Arc, realtime: bool, - // Adaptive-jitter state; only the single worker thread touches these. - min_backlog: Cell, - window_blocks: Cell, + block_frames: usize, + // Adaptive-jitter state; only the single audio thread touches these. + depth: std::cell::RefCell, starve_blocks: Cell, ever_primed: Cell, avg_backlog: Cell, @@ -635,17 +650,25 @@ struct GroupPlan { impl ChannelReceiver { pub fn new(handle: ConsumerHandle) -> Self { + // The fill is counted in 48 kHz source samples; one block takes the + // output block's worth of them. + let need = handle.block_frames * SR as usize / handle.output_rate.max(1) as usize; Self { taps: handle.taps, drift: handle.drift, target: handle.target, realtime: handle.realtime, - min_backlog: Cell::new(usize::MAX), - window_blocks: Cell::new(0), + block_frames: handle.block_frames, + depth: std::cell::RefCell::new(DepthEstimator::new( + SR, + need.max(1), + JITTER_WINDOW_MS, + TARGET_INIT, + )), starve_blocks: Cell::new(0), ever_primed: Cell::new(false), avg_backlog: Cell::new(-1.0), - last_mix: std::cell::RefCell::new(Vec::new()), + last_mix: std::cell::RefCell::new(vec![0.0; handle.block_frames * 2]), plans: std::cell::RefCell::new(Vec::with_capacity(8)), } } @@ -653,10 +676,11 @@ impl ChannelReceiver { /// Resample one block from every tap into its `scratch` and sum into `mix`. /// Real-time consumers also adapt the buffer depth and drift ratio here. pub fn mix_block(&self, mix: &mut [f32]) { + let frames = self.block_frames; // The node's width is whatever its graph resolved to, not always stereo. - let width = (mix.len() / OUT_BLOCK_FRAMES).max(1); - // The playback resamplers are built for exactly OUT_BLOCK_FRAMES output. - debug_assert_eq!(mix.len() / width, OUT_BLOCK_FRAMES); + let width = (mix.len() / frames).max(1); + // The playback resamplers are built for exactly one block of output. + debug_assert_eq!(mix.len() / width, frames); let Ok(mut taps) = self.taps.try_lock() else { // Map briefly locked for registration: hold the last mix rather than // emit a silent click. @@ -723,7 +747,7 @@ impl ChannelReceiver { // Safety net only (abnormal burst): the drift loop normally keeps // the ring near target. Generous headroom -- sender catch-up bursts // after a scheduler stall are legitimate and must not get spliced. - let hard_cap = (target * 2).max(target + OUT_BLOCK_FRAMES * 20); + let hard_cap = (target * 2).max(target + TARGET_EVENT_MAX * 4); if p.min_backlog > hard_cap { p.trim = p.min_backlog - target; } @@ -742,8 +766,10 @@ impl ChannelReceiver { // that concealed, and the target has to answer for both. Only the // first prime of all is exempt: filling from empty is how playback // starts, not something the buffer failed at. + // A source whose every channel went quiet has no plan at all; that + // silence is part of the outage too. let starving = live.is_some_and(|p| p.min_backlog < p.need) - || (self.ever_primed.get() && plans.iter().any(|p| p.hold)); + || (self.ever_primed.get() && (plans.is_empty() || plans.iter().any(|p| p.hold))); self.account(starving, live.map(|p| (p.min_backlog, p.need))); if let Some(p) = live { self.steer(p.min_backlog); @@ -768,7 +794,7 @@ impl ChannelReceiver { continue; } if plan.conceal { - let n = tap.conceal().min(OUT_BLOCK_FRAMES); + let n = tap.conceal().min(frames); for (frame, &v) in mix.chunks_mut(width).zip(tap.scratch[..n].iter()) { for s in frame.iter_mut() { *s += v; @@ -778,7 +804,7 @@ impl ChannelReceiver { } tap.primed = true; tap.trim(plan.trim); - let n = tap.fill_block().min(OUT_BLOCK_FRAMES); + let n = tap.fill_block().min(frames); for (frame, &v) in mix.chunks_mut(width).zip(tap.scratch[..n].iter()) { for s in frame.iter_mut() { *s += v; @@ -797,42 +823,22 @@ impl ChannelReceiver { fn account(&self, starving: bool, live: Option<(usize, usize)>) { if starving { self.starve_blocks.set(self.starve_blocks.get() + 1); - self.min_backlog.set(usize::MAX); - self.window_blocks.set(0); - return; - } - // Charged when the outage ends, so its length sizes the answer: a buffer - // deeper by what the source failed to deliver is precisely the buffer - // that would have played straight through it. - let blocks = self.starve_blocks.replace(0) as usize; - if blocks > 0 { - let grow = (blocks * OUT_BLOCK_FRAMES + TARGET_MARGIN).min(TARGET_EVENT_MAX); - let target = self.target.load(Ordering::Relaxed) as usize; - self.target - .store((target + grow).min(TARGET_MAX) as u32, Ordering::Relaxed); return; } - let Some((backlog, need)) = live else { return }; - self.min_backlog.set(self.min_backlog.get().min(backlog)); - self.window_blocks.set(self.window_blocks.get() + 1); - if self.window_blocks.get() < CALM_WINDOW { - return; + let mut depth = self.depth.borrow_mut(); + // Charged when the outage ends, as one event sized by everything the + // source failed to deliver: a buffer deeper by that much is precisely + // the buffer that would have played straight through it. + let missed = self.starve_blocks.replace(0) as usize * need; + if missed > 0 && missed <= OUTAGE_MAX { + depth.underrun(missed); } - // Give back the depth the window never touched. Measured against what a - // block consumes, not against the target: the drift loop holds the fill - // *at* the target, so a backlog above it is not something this can ever - // observe. - let spare = self.min_backlog.get().saturating_sub(need + KEEP_MARGIN); - if spare > 0 { - let target = self.target.load(Ordering::Relaxed) as usize; - let target = target - .saturating_sub(spare.min(TARGET_DECAY)) - .max(TARGET_MIN); + { + depth.observe(backlog); + let target = (need + depth.depth() + TARGET_SAFETY).clamp(TARGET_MIN, TARGET_MAX); self.target.store(target as u32, Ordering::Relaxed); } - self.min_backlog.set(usize::MAX); - self.window_blocks.set(0); } /// Drift ratio from the current backlog. @@ -869,7 +875,7 @@ impl ChannelReceiver { let Some(tap) = taps.get(key) else { return }; let src = &tap.scratch[..tap.valid]; // Taps are mono; a wider destination gets the channel centred across it. - let width = out.len() / OUT_BLOCK_FRAMES; + let width = out.len() / self.block_frames; if width <= 1 { let n = src.len().min(out.len()); out[..n].copy_from_slice(&src[..n]); @@ -884,7 +890,7 @@ impl ChannelReceiver { pub fn prefix_mix(&self, prefix: &str, out: &mut [f32]) { out.fill(0.0); if let Ok(taps) = self.taps.try_lock() { - let width = (out.len() / OUT_BLOCK_FRAMES).max(1); + let width = (out.len() / self.block_frames).max(1); for (key, tap) in taps.iter() { if key.starts_with(prefix) { for (frame, &v) in out.chunks_mut(width).zip(tap.scratch[..tap.valid].iter()) { @@ -902,13 +908,15 @@ impl ChannelReceiver { mod tests { use super::*; + const BLOCK: usize = 1024; + /// Registry + stereo consumer primed with `frames` of constant audio on /// both channels. Returns the receiver to mix and the broadcast handles. fn primed_registry(frames: usize, realtime: bool) -> (FanoutRegistry, ChannelReceiver) { let reg = FanoutRegistry::default(); let seq: u16 = 100; // Consumers register FIRST: a push with no live consumer goes nowhere. - let handle = reg.register_consumer(48_000, realtime); + let handle = reg.register_consumer(48_000, BLOCK, realtime); for key in ["0", "1"] { let bc = reg.attach_channel(key.to_string(), seq); broadcast_push(&bc, seq + 7, 8, &vec![0.5f32; frames]); @@ -917,6 +925,141 @@ mod tests { (reg, recv) } + struct NetRun { + /// Output samples after settling that were not the pushed constant. + gaps: usize, + target: usize, + target_after_outage: usize, + } + + /// One 48 kHz channel of 20 ms packets of 0.5 into a consumer rendering + /// `block` frames per callback. Packet `k` lands `jitter(k)` seconds late + /// (in order); `outage` silences the sender for a span of time. + fn net_sim( + block: usize, + seconds: f64, + settle: f64, + jitter: impl Fn(u32) -> f64, + outage: Option<(f64, f64)>, + ) -> NetRun { + const PACKET: usize = 960; + let reg = FanoutRegistry::default(); + let handle = reg.register_consumer(48_000, block, true); + let target = handle.target.clone(); + let bc = reg.attach_channel("0".into(), 0); + let recv = ChannelReceiver::new(handle); + let period = PACKET as f64 / SR as f64; + let out_period = block as f64 / SR as f64; + let mut run = NetRun { + gaps: 0, + target: 0, + target_after_outage: 0, + }; + let (mut k, mut t) = (0u32, 0.0); + let mut mix = vec![0.0f32; block]; + let packet = vec![0.5f32; PACKET]; + while t < seconds { + let due = k as f64 * period + jitter(k); + if due <= t { + let sent = k as f64 * period; + let dropped = outage.is_some_and(|(a, b)| sent >= a && sent < b); + if !dropped { + broadcast_push(&bc, k as u16, 1, &packet); + } + k += 1; + continue; + } + recv.mix_block(&mut mix); + let in_outage = outage.is_some_and(|(a, b)| t >= a && t < b + 1.0); + if t > settle && !in_outage { + run.gaps += mix.iter().filter(|s| (*s - 0.5).abs() > 1e-3).count(); + } + if let Some((_, b)) = outage { + if t >= b + 0.3 && run.target_after_outage == 0 { + run.target_after_outage = target.load(Ordering::Relaxed) as usize; + } + } + t += out_period; + } + run.target = target.load(Ordering::Relaxed) as usize; + run + } + + /// Deterministic pseudo-random share in [0, 1) per packet. + fn noise(k: u32) -> f64 { + let mut x = k.wrapping_mul(0x9E37_79B9) ^ 0x85EB_CA6B; + x ^= x >> 15; + x = x.wrapping_mul(0x2C1B_3C6D); + x ^= x >> 12; + (x % 10_000) as f64 / 10_000.0 + } + + #[test] + fn steady_link_settles_below_the_initial_depth() { + for block in [64, 256, 1024] { + let r = net_sim(block, 30.0, 10.0, |_| 0.0, None); + assert_eq!(r.gaps, 0, "{block}: gaps on a clean link"); + assert!( + r.target < TARGET_INIT, + "{block}: clean link still buffers {} samples", + r.target + ); + } + } + + #[test] + fn jittery_link_buys_depth_instead_of_gaps() { + // Wi-Fi-like: every packet up to 40 ms late. + let r = net_sim(64, 60.0, 20.0, |k| noise(k) * 0.040, None); + assert_eq!(r.gaps, 0, "gaps once the depth has adapted"); + assert!( + r.target >= 1_920, + "40 ms of jitter needs >= 40 ms: {}", + r.target + ); + assert!(r.target <= 4 * 1_920, "over-buffered: {}", r.target); + } + + #[test] + fn an_outage_deepens_the_buffer_at_once() { + let r = net_sim(64, 20.0, 5.0, |_| 0.0, Some((8.0, 8.15))); + assert!( + r.target_after_outage >= 7_200, + "150 ms outage must buy >= 150 ms: {}", + r.target_after_outage + ); + } + + #[test] + fn a_sender_pause_is_not_charged_as_jitter() { + let r = net_sim(64, 30.0, 5.0, |_| 0.0, Some((8.0, 13.0))); + assert!( + r.target_after_outage < TARGET_INIT, + "a 5 s pause left {} samples of depth", + r.target_after_outage + ); + } + + #[test] + fn mixing_never_touches_the_heap() { + for block in [32, 64, 512] { + let reg = FanoutRegistry::default(); + let handle = reg.register_consumer(44_100, block, true); + let bc = reg.attach_channel("0".into(), 0); + let recv = ChannelReceiver::new(handle); + broadcast_push(&bc, 30, 30, &vec![0.5f32; 960 * 30]); + let mut mix = vec![0.0f32; block * 2]; + for _ in 0..8 { + recv.mix_block(&mut mix); + } + crate::audio::rt_guard::assert_no_alloc(&format!("mix_block @ {block}"), || { + for _ in 0..200 { + recv.mix_block(&mut mix); + } + }); + } + } + #[test] fn extend_seq_wraps_16bit_counters() { assert_eq!(extend_seq(0, 0), 0); @@ -954,7 +1097,7 @@ mod tests { #[test] fn realtime_consumer_mixes_after_prime() { let (_, recv) = primed_registry(960 * 40, true); - let mut mix = vec![0.0f32; OUT_BLOCK_FRAMES * 2]; + let mut mix = vec![0.0f32; BLOCK * 2]; recv.mix_block(&mut mix); let max = mix.iter().fold(0.0f32, |m, s| m.max(s.abs())); assert!(max > 0.4, "primed tap must reach the mix: {max}"); @@ -966,9 +1109,9 @@ mod tests { let reg = FanoutRegistry::default(); let bc = reg.attach_channel("ch".into(), 0); // Nothing pushed: a realtime consumer must stream silence, not panic. - let handle = reg.register_consumer(48_000, true); + let handle = reg.register_consumer(48_000, BLOCK, true); let recv = ChannelReceiver::new(handle); - let mut mix = vec![0.0f32; OUT_BLOCK_FRAMES * 2]; + let mut mix = vec![0.0f32; BLOCK * 2]; recv.mix_block(&mut mix); assert!(mix.iter().all(|s| *s == 0.0)); let _ = bc; @@ -978,13 +1121,13 @@ mod tests { fn channel_taps_draw_per_channel_audio() { let reg = FanoutRegistry::default(); let bc = reg.attach_channel("0".into(), 10); - let handle = reg.register_consumer(48_000, true); + let handle = reg.register_consumer(48_000, BLOCK, true); // Prime well past the 60 ms target so the tap actually plays out. broadcast_push(&bc, 11, 1, &vec![0.7f32; 960 * 40]); let recv = ChannelReceiver::new(handle); - let mut mix = vec![0.0f32; OUT_BLOCK_FRAMES * 2]; + let mut mix = vec![0.0f32; BLOCK * 2]; recv.mix_block(&mut mix); - let mut tap = vec![0.0f32; OUT_BLOCK_FRAMES]; + let mut tap = vec![0.0f32; BLOCK]; recv.channel("0", &mut tap); let peak = tap.iter().fold(0.0f32, |m, s| m.max(s.abs())); assert!(peak > 0.6, "channel tap carries the pushed audio: {peak}"); @@ -993,9 +1136,9 @@ mod tests { #[test] fn unknown_channel_tap_is_silence_not_panic() { let reg = FanoutRegistry::default(); - let handle = reg.register_consumer(48_000, true); + let handle = reg.register_consumer(48_000, BLOCK, true); let recv = ChannelReceiver::new(handle); - let mut tap = vec![0.0f32; OUT_BLOCK_FRAMES]; + let mut tap = vec![0.0f32; BLOCK]; recv.channel("ghost", &mut tap); assert!(tap.iter().all(|s| *s == 0.0)); recv.prefix_mix("ghost:", &mut tap); @@ -1006,7 +1149,7 @@ mod tests { fn drop_channel_and_clear_reset_state() { let reg = FanoutRegistry::default(); let bc = reg.attach_channel("0".into(), 1); - let handle = reg.register_consumer(48_000, true); + let handle = reg.register_consumer(48_000, BLOCK, true); let taps = handle.taps.clone(); broadcast_push(&bc, 2, 1, &vec![0.5f32; 960]); assert_eq!(taps.lock().unwrap().len(), 1); @@ -1032,7 +1175,7 @@ mod tests { assert_eq!(reg.buffer_depth(), None, "no consumers yet"); let bc = reg.attach_channel("c".into(), 1); broadcast_push_sr(&bc, 2, 1, &vec![0.0f32; 1920], 44_100); - let _handle = reg.register_consumer(48_000, true); + let _handle = reg.register_consumer(48_000, BLOCK, true); let depth = reg.buffer_depth().expect("consumer registered"); assert_eq!( depth, TARGET_INIT as u32, diff --git a/src-tauri/src/audio/streams/mod.rs b/src-tauri/src/audio/streams/mod.rs index f2a6355c..473ac057 100644 --- a/src-tauri/src/audio/streams/mod.rs +++ b/src-tauri/src/audio/streams/mod.rs @@ -1,7 +1,6 @@ //! Cross-platform SPSC ring helpers plus the cpal stream builders. //! -//! `bulk_pop` / `bulk_push_counted` move whole blocks between the pipeline and the -//! audio callback on every platform. The cpal `build_*_stream` builders back +//! `bulk_push_counted` moves whole blocks into SPSC rings on every platform. The cpal `build_*_stream` builders back //! macOS (CoreAudio) and Windows (WASAPI); Linux opens its mic via //! `capture/linux.rs` and its speaker via `playback.rs`, so it skips them. @@ -16,36 +15,6 @@ mod cpal_stream; #[cfg(any(target_os = "macos", target_os = "windows"))] pub use cpal_stream::{build_input_stream, build_output_stream}; -/// Bulk drain `dst.len()` samples from an SPSC ring. Anything we couldn't -/// read (consumer faster than producer) is zero-filled -- that's the device -/// playing silence, not glitching. Returns the number of samples actually -/// read, so callers tracking ring fill level can subtract it. -pub fn bulk_pop(cons: &mut rtrb::Consumer, dst: &mut [f32]) -> usize { - let want = dst.len(); - if want == 0 { - return 0; - } - let avail = cons.slots(); - let to_read = want.min(avail); - if to_read > 0 { - if let Ok(chunk) = cons.read_chunk(to_read) { - let (first, second) = chunk.as_slices(); - let n1 = first.len(); - dst[..n1].copy_from_slice(first); - let n2 = second.len(); - if n2 > 0 { - dst[n1..n1 + n2].copy_from_slice(second); - } - chunk.commit_all(); - } - } - for s in &mut dst[to_read..] { - *s = 0.0; - } - health::bump(&health::OUTPUT_UNDERRUN_SAMPLES, (want - to_read) as u64); - to_read -} - /// Bulk push via one `write_chunk` reservation -- one atomic-CAS per block /// instead of one per sample. On overflow only the head fits and the rest is /// dropped (consumer is behind anyway; staying RT-safe beats blocking). diff --git a/src-tauri/src/audio/webrtc/session.rs b/src-tauri/src/audio/webrtc/session.rs index d85172dd..52e5fb9b 100644 --- a/src-tauri/src/audio/webrtc/session.rs +++ b/src-tauri/src/audio/webrtc/session.rs @@ -116,8 +116,14 @@ impl WebRtcSession { self.output_sr.store(output_sr, Ordering::Relaxed); } - pub fn register_bridge(&self, output_sr: u32, realtime: bool) -> ConsumerHandle { - self.fanout.register_consumer(output_sr, realtime) + pub fn register_bridge( + &self, + output_sr: u32, + block_frames: usize, + realtime: bool, + ) -> ConsumerHandle { + self.fanout + .register_consumer(output_sr, block_frames, realtime) } /// New received channel (keyed `peer:channel`), wired into every live bridge. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 476dc28f..0ba51ff1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -457,9 +457,11 @@ pub async fn is_pipeline_running(state: State<'_, AppState>) -> AppResult } #[tauri::command] -pub async fn output_latency_ms(state: State<'_, AppState>) -> AppResult { +pub async fn latency_report( + state: State<'_, AppState>, +) -> AppResult { let tx = state.audio_tx.clone(); - audio_request(tx, |reply| Command::OutputLatencyMs { reply }).await + audio_request(tx, |reply| Command::LatencyReport { reply }).await } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e5e5ae7..ecb48abe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -331,7 +331,7 @@ pub fn run() { commands::path_exists, commands::read_file_peaks, commands::is_pipeline_running, - commands::output_latency_ms, + commands::latency_report, commands::start_pipeline, commands::stop_pipeline, commands::reconcile_pipeline, diff --git a/src/lib/modules/pipeline/generated/LatencyBreakdown.ts b/src/lib/modules/pipeline/generated/LatencyBreakdown.ts new file mode 100644 index 00000000..a52fa911 --- /dev/null +++ b/src/lib/modules/pipeline/generated/LatencyBreakdown.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LatencyBreakdown = { totalMs: number, +/** + * Capture device buffer and hardware. + */ +inputDeviceMs: number, +/** + * Audio queued ahead of the graph, including capture resampling. + */ +inputQueueMs: number, +/** + * Delay compensation for the slowest path through the graph. + */ +processingMs: number, +/** + * Output resampling and block adapter. + */ +outputAdapterMs: number, +/** + * Playback device buffer and hardware. + */ +outputDeviceMs: number, +/** + * False when some device did not report what it adds past its buffer, + * so the total is a lower bound. + */ +hardwareIncluded: boolean, }; diff --git a/src/lib/modules/pipeline/generated/LatencyReport.ts b/src/lib/modules/pipeline/generated/LatencyReport.ts new file mode 100644 index 00000000..3f896175 --- /dev/null +++ b/src/lib/modules/pipeline/generated/LatencyReport.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LatencyBreakdown } from "./LatencyBreakdown"; +import type { NodeTiming } from "./NodeTiming"; + +export type LatencyReport = { +/** + * The slowest input-to-speaker path; `None` without a speaker. + */ +path: LatencyBreakdown | null, +/** + * Engine buffer the settings asked for. + */ +bufferFrames: number, +/** + * Buffer the slowest speaker actually runs at, in its own frames. + */ +deviceBufferFrames: number | null, +/** + * Peak share of the audio period spent rendering since the last report + * (1.0 = the device outran the graph). + */ +dspLoad: number, +/** + * Callbacks that ran past their period since the last report. + */ +overloads: number, nodes: Array, }; diff --git a/src/lib/modules/pipeline/generated/NodeTiming.ts b/src/lib/modules/pipeline/generated/NodeTiming.ts new file mode 100644 index 00000000..9cfbe529 --- /dev/null +++ b/src/lib/modules/pipeline/generated/NodeTiming.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NodeTiming = { nodeId: string, +/** + * Delay this node adds, in pipeline frames. + */ +latencyFrames: number, +/** + * Block the node actually works in, when larger than the engine buffer. + */ +workingBlock: number | null, }; From 35e3473bb28e3f58fd5d4de843857975bf649158 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:51:40 +0300 Subject: [PATCH 03/51] feat(ui): buffer size setting, round-trip latency readout and working-block badge - Settings: buffer size presets with their duration at the pipeline rate. - Header: real round-trip latency with a per-stage breakdown, DSP load, and a warning when audio was dropped. - Nodes that run at a larger block than the engine buffer show an hourglass next to the resampling icon. - Storybook: mocked latency_report, bufferFrames/workingBlock controls, Larger block stories for Noise Suppressor and Plugin. --- src/lib/components/format.ts | 5 ++ src/lib/components/icons/hourglass.svelte | 8 ++ src/lib/components/icons/index.ts | 1 + src/lib/modules/audio/latency.svelte.ts | 41 +++++++++ src/lib/modules/audio/methods.ts | 12 ++- src/lib/modules/audio/ui/latency_badge.svelte | 85 +++++++++++++------ src/lib/modules/flow/ui/node.svelte | 22 ++++- src/lib/modules/settings/stores.svelte.ts | 11 +++ src/routes/settings/+page.svelte | 34 +++++++- src/stories/harness/args.ts | 4 +- src/stories/harness/backend.ts | 35 +++++++- src/stories/harness/node_canvas.svelte | 4 +- .../nodes/noise_suppressor.stories.svelte | 3 +- src/stories/nodes/plugin.stories.svelte | 3 +- 14 files changed, 229 insertions(+), 39 deletions(-) create mode 100644 src/lib/components/icons/hourglass.svelte create mode 100644 src/lib/modules/audio/latency.svelte.ts diff --git a/src/lib/components/format.ts b/src/lib/components/format.ts index 4c4ae6d1..edf90937 100644 --- a/src/lib/components/format.ts +++ b/src/lib/components/format.ts @@ -108,3 +108,8 @@ export function formatGain(db: number): string { export function formatDb(db: number, floor: number = -96): string { return Number.isFinite(db) && db > floor ? db.toFixed(1) : '−∞'; } + +/** Latency in milliseconds: one decimal below 10 ms, whole above. */ +export function formatLatencyMs(ms: number): string { + return ms < 10 ? ms.toFixed(1) : Math.round(ms).toString(); +} diff --git a/src/lib/components/icons/hourglass.svelte b/src/lib/components/icons/hourglass.svelte new file mode 100644 index 00000000..c7e26370 --- /dev/null +++ b/src/lib/components/icons/hourglass.svelte @@ -0,0 +1,8 @@ + + + diff --git a/src/lib/components/icons/index.ts b/src/lib/components/icons/index.ts index 899d64c8..0a01f1a8 100644 --- a/src/lib/components/icons/index.ts +++ b/src/lib/components/icons/index.ts @@ -42,6 +42,7 @@ export { default as PeopleTeam } from './people_team.svelte'; export { default as SpeakerMute } from './speaker_mute.svelte'; export { default as Sliders } from './sliders.svelte'; export { default as Gauge } from './gauge.svelte'; +export { default as Hourglass } from './hourglass.svelte'; export { default as Trending } from './trending.svelte'; export { default as Pulse } from './pulse.svelte'; export { default as Wand } from './wand.svelte'; diff --git a/src/lib/modules/audio/latency.svelte.ts b/src/lib/modules/audio/latency.svelte.ts new file mode 100644 index 00000000..30522ea5 --- /dev/null +++ b/src/lib/modules/audio/latency.svelte.ts @@ -0,0 +1,41 @@ +import { browser } from '$app/environment'; +import type { LatencyReport } from '$lib/modules/pipeline/generated/LatencyReport'; +import type { NodeTiming } from '$lib/modules/pipeline/generated/NodeTiming'; +import { methods } from './methods'; +import { audioStore } from './stores.svelte'; + +const POLL_MS = 500; + +/** Latest engine latency report, polled while a pipeline runs. */ +class LatencyStore { + report = $state(null); + #nodes = $derived(new Map((this.report?.nodes ?? []).map((n) => [n.nodeId, n]))); + + node(id: string): NodeTiming | undefined { + return this.#nodes.get(id); + } +} + +export const latencyStore = new LatencyStore(); + +if (browser) { + $effect.root(() => { + $effect(() => { + if (!audioStore.isRunning) { + latencyStore.report = null; + return; + } + let cancelled = false; + const poll = async () => { + const report = await methods.getLatencyReport().catch(() => null); + if (!cancelled) latencyStore.report = report; + }; + void poll(); + const id = setInterval(() => void poll(), POLL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }); + }); +} diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts index 827ce139..a0aee593 100644 --- a/src/lib/modules/audio/methods.ts +++ b/src/lib/modules/audio/methods.ts @@ -1,6 +1,12 @@ import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { appSettings } from '$lib/modules/settings/stores.svelte'; +import type { LatencyReport } from '$lib/modules/pipeline/generated/LatencyReport'; + +/** Rate and buffer every device stream opens with. */ +function engineFormat() { + return { sampleRate: appSettings.pipelineSampleRate, bufferFrames: appSettings.bufferFrames }; +} import type { AudioApplication, AudioDevice, @@ -55,14 +61,14 @@ export const methods = { maxs: number[][]; }> => invoke('read_file_peaks', { path, startFrame, framesPerBin, binCount }), isPipelineRunning: (): Promise => invoke('is_pipeline_running'), - getOutputLatency: (): Promise => invoke('output_latency_ms'), + getLatencyReport: (): Promise => invoke('latency_report'), startPipeline: (graph: StartPipelinePayload): Promise => - invoke('start_pipeline', { graph: { sampleRate: appSettings.pipelineSampleRate, ...graph } }), + invoke('start_pipeline', { graph: { ...engineFormat(), ...graph } }), stopPipeline: (): Promise => invoke('stop_pipeline'), /** Hot-reconfigure a running pipeline. Errors with `NotRunning` if no * pipeline is active — callers should fall back to `startPipeline`. */ reconcilePipeline: (graph: StartPipelinePayload): Promise => - invoke('reconcile_pipeline', { graph: { sampleRate: appSettings.pipelineSampleRate, ...graph } }), + invoke('reconcile_pipeline', { graph: { ...engineFormat(), ...graph } }), /** No-op when the pipeline isn't running; callers can fire-and-forget. */ updateEffect: (nodeId: string, data: Record): Promise => invoke('update_effect', { nodeId, data }), /** Seek an AudioFile input. No-op when not running. */ diff --git a/src/lib/modules/audio/ui/latency_badge.svelte b/src/lib/modules/audio/ui/latency_badge.svelte index 8d5ab361..dff40440 100644 --- a/src/lib/modules/audio/ui/latency_badge.svelte +++ b/src/lib/modules/audio/ui/latency_badge.svelte @@ -1,34 +1,65 @@ -{#if latencyMs !== null && latencyMs > 0} - - - {latencyMs} ms - +{#if report && path} + + + + + {ms(path.totalMs)}{path.hardwareIncluded ? '' : '+'} ms + + + {#snippet content()} +
+ Round-trip latency + {#each rows as [label, value] (label)} + + {label}{ms(value as number)} ms + + {/each} + + Buffer + + {report.bufferFrames}{report.deviceBufferFrames !== null && report.deviceBufferFrames !== report.bufferFrames + ? ` (device ${report.deviceBufferFrames})` + : ''} smp + + + + DSP load{loadPct}% + + {#if report.overloads > 0} + Audio dropped: raise the buffer size + {/if} + {#if !path.hardwareIncluded} + Device hardware latency not reported by the OS + {/if} +
+ {/snippet} +
{/if} diff --git a/src/lib/modules/flow/ui/node.svelte b/src/lib/modules/flow/ui/node.svelte index 55144399..3429ff27 100644 --- a/src/lib/modules/flow/ui/node.svelte +++ b/src/lib/modules/flow/ui/node.svelte @@ -5,7 +5,10 @@ import { PREVIEW_CTX } from '../utils'; import { CATEGORY_TEXT } from '../utils/accents'; import type { NodeCategory } from '$lib/modules/pipeline/types'; - import { ArrowSwap } from '$lib/components/icons'; + import { ArrowSwap, Hourglass } from '$lib/components/icons'; + import { latencyStore } from '$lib/modules/audio/latency.svelte'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; + import { formatLatencyMs } from '$lib/components/format'; import { Tooltip } from '$lib/modules/overlay/ui'; import ChannelHandles from './_channel_handles.svelte'; @@ -64,6 +67,16 @@ let chExpanded = $derived(channelIo && !!nodeId && !isPreview); + // A node that cannot keep up with the engine buffer runs at a larger block + // of its own; say so, the way the resampling icon flags a rate change. + let timing = $derived(nodeId ? latencyStore.node(nodeId) : undefined); + let blockTooltip = $derived.by(() => { + const block = timing?.workingBlock; + if (!block || !latencyStore.report) return undefined; + const rate = appSettings.pipelineSampleRate; + return `Runs in ${block}-sample blocks (buffer ${latencyStore.report.bufferFrames}), adding ${formatLatencyMs((timing!.latencyFrames * 1000) / rate)} ms`; + }); + function pos(p: InputHandleConfig['position']): Position { if (p === 'bottom') return Position.Bottom; if (p === 'top') return Position.Top; @@ -92,6 +105,13 @@ {/if} + {#if blockTooltip} + + + + + + {/if}
{#if badge}{@render badge()}{/if} diff --git a/src/lib/modules/settings/stores.svelte.ts b/src/lib/modules/settings/stores.svelte.ts index 100aa3dc..1dfeebc5 100644 --- a/src/lib/modules/settings/stores.svelte.ts +++ b/src/lib/modules/settings/stores.svelte.ts @@ -14,6 +14,7 @@ interface Stored { confirmOverwriteChanges: boolean; keepRunningOnDisconnect: boolean; pipelineSampleRate: number; + bufferFrames: number; uiScale: number; } @@ -27,6 +28,7 @@ const DEFAULTS: Stored = { confirmOverwriteChanges: true, keepRunningOnDisconnect: true, pipelineSampleRate: 48_000, + bufferFrames: 256, uiScale: 100 }; @@ -36,6 +38,8 @@ export const UI_SCALE_MIN = 50; export const UI_SCALE_MAX = 200; export const UI_SCALE_STEP = 10; export const PIPELINE_SAMPLE_RATE_PRESETS = [44100, 48000, 88200, 96000, 176400, 192000] as const; +/** Mirrors `BUFFER_FRAME_OPTIONS` in `src-tauri/src/audio/graph.rs`. */ +export const BUFFER_FRAME_PRESETS = [32, 64, 128, 256, 512, 1024, 2048] as const; function load(): Stored { if (!browser) return DEFAULTS; @@ -57,6 +61,11 @@ class AppSettings { confirmOverwriteChanges = $state(this.#initial.confirmOverwriteChanges); keepRunningOnDisconnect = $state(this.#initial.keepRunningOnDisconnect); pipelineSampleRate = $state(this.#initial.pipelineSampleRate ?? 48_000); + // An unsupported size would fail every pipeline start, so a stale or + // hand-edited value falls back to the default. + bufferFrames = $state( + (BUFFER_FRAME_PRESETS as readonly number[]).includes(this.#initial.bufferFrames) ? this.#initial.bufferFrames : DEFAULTS.bufferFrames + ); uiScale = $state(this.#initial.uiScale ?? 100); persist(): void { @@ -71,6 +80,7 @@ class AppSettings { confirmOverwriteChanges, keepRunningOnDisconnect, pipelineSampleRate, + bufferFrames, uiScale } = this; window.localStorage.setItem( @@ -85,6 +95,7 @@ class AppSettings { confirmOverwriteChanges, keepRunningOnDisconnect, pipelineSampleRate, + bufferFrames, uiScale }) ); diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 38819a1e..6a70544c 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -14,10 +14,11 @@ UI_SCALE_STEP, GRID_SIZES, SNAPSHOT_LIMITS, - PIPELINE_SAMPLE_RATE_PRESETS + PIPELINE_SAMPLE_RATE_PRESETS, + BUFFER_FRAME_PRESETS } from '$lib/modules/settings/stores.svelte'; import NumberStepper from '$lib/components/number_stepper.svelte'; - import { formatHz } from '$lib/components/format'; + import { formatHz, formatLatencyMs } from '$lib/components/format'; import PresetsSection from './_presets_section.svelte'; const SHAPES: { value: EdgeShape; label: string; hint: string }[] = [ @@ -61,6 +62,7 @@ | 'confirmOverwriteChanges' | 'keepRunningOnDisconnect' | 'pipelineSampleRate' + | 'bufferFrames' >(key: K, value: (typeof appSettings)[K]) { appSettings[key] = value; appSettings.persist(); @@ -312,6 +314,34 @@ {/if} +
+
+

Buffer size

+

+ Samples each device callback carries. Smaller buffers cut latency but leave the CPU less time per block; raise it if the + latency readout reports dropped audio. Nodes that need larger blocks run at their own size and show an hourglass. Applies the next + time the pipeline starts. +

+
+ +
+ {#each BUFFER_FRAME_PRESETS as frames (frames)} + + {/each} +
+
+

Device disconnection

diff --git a/src/stories/harness/args.ts b/src/stories/harness/args.ts index f8677da0..3455e5dd 100644 --- a/src/stories/harness/args.ts +++ b/src/stories/harness/args.ts @@ -37,7 +37,9 @@ const ENV_ARG_TYPES: Record = { linkChannels: { control: range(1, 8) }, webrtcPhase: { control: 'inline-radio', options: ['idle', 'hosting', 'joining'] }, webrtcPeers: { control: range(0, 5) }, - missingFile: { control: 'boolean', description: 'Chosen file no longer exists' } + missingFile: { control: 'boolean', description: 'Chosen file no longer exists' }, + bufferFrames: { control: 'select', options: [32, 64, 128, 256, 512, 1024, 2048], description: 'Engine buffer, frames' }, + workingBlock: { control: 'select', options: [0, 256, 480, 512], description: 'Block the node reports running at; 0 keeps up' } }; function withCategory(types: Record, category: string): ArgTypes { diff --git a/src/stories/harness/backend.ts b/src/stories/harness/backend.ts index 4bbf6d91..58391247 100644 --- a/src/stories/harness/backend.ts +++ b/src/stories/harness/backend.ts @@ -2,6 +2,7 @@ import { mockIPC } from '@tauri-apps/api/mocks'; import type { AudioApplication, AudioDevice, PluginDescriptor, PluginParam } from '$lib/modules/audio/types'; import { audioStore } from '$lib/modules/audio/stores.svelte'; import { appSettings } from '$lib/modules/settings/stores.svelte'; +import type { LatencyReport } from '$lib/modules/pipeline/generated/LatencyReport'; export type Platform = 'macos' | 'windows' | 'linux'; export type LinkQuality = 'offline' | 'good' | 'lossy'; @@ -26,8 +27,14 @@ export interface MockEnv { webrtcPhase: 'idle' | 'hosting' | 'joining'; webrtcPeers: number; missingFile: boolean; + bufferFrames: number; + /** Block the story node reports running at; 0 when it keeps up. */ + workingBlock: number; } +/** Id of the node a story renders; the engine mocks answer for it. */ +export const STORY_NODE_ID = 'story-node'; + export const DEFAULT_ENV: MockEnv = { platform: 'macos', running: false, @@ -46,7 +53,9 @@ export const DEFAULT_ENV: MockEnv = { linkChannels: 2, webrtcPhase: 'idle', webrtcPeers: 0, - missingFile: false + missingFile: false, + bufferFrames: 256, + workingBlock: 0 }; const INPUT_NAMES = ['MacBook Pro Microphone', 'Scarlett 2i2 USB', 'Splitwave', 'AirPods Pro', 'Shure MV7', 'RODECaster Pro II']; @@ -179,6 +188,8 @@ export function installMockBackend(env: MockEnv): void { return null; case 'check_capture_permission': return env.capturePermission === 'none' ? { kind: 'none', state: 'unknown' } : { kind: 'screenrecording', state: env.capturePermission }; + case 'latency_report': + return mockLatencyReport(env); case 'is_pipeline_running': return env.running; case 'path_exists': @@ -255,4 +266,26 @@ export function installMockBackend(env: MockEnv): void { audioStore.startedAt = env.running ? Date.now() : null; audioStore.missingFilePaths = new Set(env.missingFile ? ['/Users/demo/Music/demo-track.wav'] : []); appSettings.pipelineSampleRate = env.pipelineSampleRate; + appSettings.bufferFrames = env.bufferFrames; +} + +function mockLatencyReport(env: MockEnv): LatencyReport { + const ms = (frames: number) => (frames * 1000) / env.pipelineSampleRate; + const node = env.workingBlock > 0 ? env.workingBlock : 0; + const path = { + inputDeviceMs: ms(env.bufferFrames + 400), + inputQueueMs: ms(env.bufferFrames * 2), + processingMs: ms(node), + outputAdapterMs: 0, + outputDeviceMs: ms(env.bufferFrames + 400), + hardwareIncluded: env.platform === 'macos' + }; + return { + path: { ...path, totalMs: Object.values(path).reduce((a, v) => a + (typeof v === 'number' ? v : 0), 0) }, + bufferFrames: env.bufferFrames, + deviceBufferFrames: env.bufferFrames, + dspLoad: 0.18, + overloads: 0, + nodes: node ? [{ nodeId: STORY_NODE_ID, latencyFrames: node, workingBlock: node }] : [] + }; } diff --git a/src/stories/harness/node_canvas.svelte b/src/stories/harness/node_canvas.svelte index cdcfd9cc..93f8ca93 100644 --- a/src/stories/harness/node_canvas.svelte +++ b/src/stories/harness/node_canvas.svelte @@ -6,7 +6,7 @@ import ChannelEdge from '$lib/modules/flow/ui/_channel_edge.svelte'; import StubSource from './stub_source.svelte'; import EnvScope from './env_scope.svelte'; - import { setNodeDataLookup } from './backend'; + import { setNodeDataLookup, STORY_NODE_ID } from './backend'; import { startSignal } from './signal'; import { splitArgs } from './args'; @@ -16,7 +16,7 @@ } let { kind, args }: Props = $props(); - const NODE_ID = 'story-node'; + const NODE_ID = STORY_NODE_ID; const STUB_ID = 'story-source'; const types = { ...nodeTypes, stub: StubSource }; const edgeTypes = { channel: ChannelEdge }; diff --git a/src/stories/nodes/noise_suppressor.stories.svelte b/src/stories/nodes/noise_suppressor.stories.svelte index e9a6ec6f..3a5a452a 100644 --- a/src/stories/nodes/noise_suppressor.stories.svelte +++ b/src/stories/nodes/noise_suppressor.stories.svelte @@ -16,7 +16,7 @@ { attenuationLimitDb: 100, postFilterBeta: 0, minThreshDb: -10, maxErbThreshDb: 30, maxDfThreshDb: 20, bypassed: false } ), wiringArgs(2), - envArgs(['pipelineSampleRate']) + envArgs(['pipelineSampleRate', 'running', 'bufferFrames', 'workingBlock']) ); const { Story } = defineMeta({ title: 'Nodes/Effects/Noise Suppressor', argTypes, args }); @@ -30,4 +30,5 @@ + diff --git a/src/stories/nodes/plugin.stories.svelte b/src/stories/nodes/plugin.stories.svelte index 267f8c68..4f5fa1be 100644 --- a/src/stories/nodes/plugin.stories.svelte +++ b/src/stories/nodes/plugin.stories.svelte @@ -28,7 +28,7 @@ { plugin: 1, showParams: false, bypassed: false } ), wiringArgs(2), - envArgs(['running', 'plugins', 'pluginParams', 'pluginEditor']) + envArgs(['running', 'plugins', 'pluginParams', 'pluginEditor', 'bufferFrames', 'workingBlock']) ); const { Story } = defineMeta({ title: 'Nodes/Effects/Plugin', argTypes, args }); @@ -44,6 +44,7 @@ + From dd6e35803b977b998a5eca9da83e0986948e887c Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:40:09 +0300 Subject: [PATCH 04/51] fix(audio): silence from a quiet source no longer deepens any buffer --- docs/CONCEPT.md | 2 +- src-tauri/src/audio/adaptive_depth.rs | 280 +++++++++++++++++++++++- src-tauri/src/audio/pipeline/cushion.rs | 196 ++++++++++++++--- src-tauri/src/audio/pipeline/dag.rs | 162 +++++++++++++- src-tauri/src/audio/stream_recv.rs | 129 ++++++++--- 5 files changed, 703 insertions(+), 66 deletions(-) diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 12871c78..8ac39185 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -42,7 +42,7 @@ Without these, one chunk takes ~16 ms and the worker stalls. - Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. -- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. Excess is spliced out and shortfall stretched in, with crossfades; an underrun re-primes once instead of clicking per block. +- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. Excess is spliced out and shortfall stretched in, with crossfades; an underrun re-primes once instead of clicking per block. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`); a source that goes quiet (paused app, DTX sender) never does. - An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs at its own working block, declares it as latency for PDC, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/src/audio/adaptive_depth.rs b/src-tauri/src/audio/adaptive_depth.rs index 676cee23..e20d9e90 100644 --- a/src-tauri/src/audio/adaptive_depth.rs +++ b/src-tauri/src/audio/adaptive_depth.rs @@ -16,7 +16,15 @@ //! (slow release), so a single spike cannot pin the latency high and a //! repeating one is not forgotten between repeats. //! -//! Everything is fixed-size: `observe` and `underrun` are RT-safe. +//! Running dry is not always lateness. A source that goes quiet (a paused +//! app, a sender with nothing to say) leaves the queue empty too, but that +//! audio does not exist and never arrives. `OutageJudge` tells the two apart +//! by what follows: late audio is still owed by a clocked source, so it turns +//! up as delivery running ahead of real time once the flow resumes; silence +//! is followed by delivery at the ordinary rate. Only what was caught up is +//! lateness worth buffering against. +//! +//! Everything is fixed-size: every method here is RT-safe. const BUCKETS: usize = 64; /// First non-zero bucket edge in frames; each next edge is 20% wider. @@ -110,6 +118,15 @@ impl DepthEstimator { self.low = usize::MAX; } + /// Drops the window in progress. The queue draining because its source + /// went quiet is not jitter, and a window holding that drain would read + /// it as one. + pub fn discard_window(&mut self) { + self.blocks = 0; + self.sum = 0.0; + self.low = usize::MAX; + } + /// Headroom to keep above one block at the queue's mean level. pub fn depth(&self) -> usize { let measured = self.measured_depth(); @@ -144,6 +161,267 @@ impl DepthEstimator { } } +/// Watch this long after the flow resumes before ruling: a capture device +/// catches up on late callbacks within a few periods, a network within a +/// burst, and the envelope needs a few deliveries to settle. +const JUDGE_HORIZON_MS: f64 = 100.0; +/// Differences this small are rounding in the envelope, not lateness. +const JUDGE_TOLERANCE_MS: f64 = 2.0; + +enum Judgement { + Clear, + /// The consumer is going without audio. + Missing { + missed: usize, + peak_before: i64, + }, + /// Flow is back; find where its envelope settles. + Watching { + missed: usize, + peak_before: i64, + peak_after: i64, + watched: usize, + }, +} + +/// Decides how much of a gap in delivery was late audio rather than no audio. +/// +/// It keeps the running balance of frames delivered minus frames of time +/// elapsed. For a clocked source that balance saws within a fixed band, and +/// its upper envelope (the peak right after each delivery) stays put. Silence +/// removes audio for good, so after it the envelope settles lower by the +/// silence's length; late audio is still delivered, so the envelope comes back +/// to where it was. Comparing the envelopes on both sides of a gap separates +/// the two exactly, whatever size the source's deliveries are. +pub struct OutageJudge { + state: Judgement, + horizon: usize, + tolerance: usize, + balance: i64, + peak: i64, +} + +impl OutageJudge { + pub fn new(sample_rate: u32) -> Self { + let frames = |ms: f64| (sample_rate as f64 * ms / 1000.0).round() as usize; + Self { + state: Judgement::Clear, + horizon: frames(JUDGE_HORIZON_MS), + tolerance: frames(JUDGE_TOLERANCE_MS), + balance: 0, + peak: 0, + } + } + + /// The consumer came up `frames` short: a gap starts (or grows). + pub fn missing(&mut self, frames: usize) { + self.state = match self.state { + Judgement::Clear => Judgement::Missing { + missed: frames, + peak_before: self.peak, + }, + Judgement::Missing { + missed, + peak_before, + } + | Judgement::Watching { + missed, + peak_before, + .. + } => Judgement::Missing { + missed: missed + frames, + peak_before, + }, + }; + } + + /// Once per block: `arrived` frames were delivered while `elapsed` frames + /// of time passed. When a gap's verdict is in, returns how many of the + /// frames the consumer went without had really been late (and were later + /// delivered), 0 when the source had simply gone quiet. + pub fn delivered(&mut self, arrived: usize, elapsed: usize) -> Option { + self.balance += arrived as i64 - elapsed as i64; + match &mut self.state { + Judgement::Clear => { + // The envelope follows the peaks, sinking slowly between them + // so it can track a capture clock drifting against ours. + self.peak = self.balance.max(self.peak - (elapsed / 64) as i64); + None + } + Judgement::Missing { + missed, + peak_before, + } => { + if arrived == 0 { + *missed += elapsed; + } else { + self.state = Judgement::Watching { + missed: *missed, + peak_before: *peak_before, + peak_after: self.balance, + watched: elapsed, + }; + } + None + } + Judgement::Watching { + missed, + peak_before, + peak_after, + watched, + } => { + *peak_after = (*peak_after).max(self.balance); + *watched += elapsed; + if *watched < self.horizon { + return None; + } + let gone_for_good = (*peak_before - *peak_after).max(0) as usize; + let late = missed.saturating_sub(gone_for_good); + let verdict = if late > self.tolerance { late } else { 0 }; + self.peak = *peak_after; + self.state = Judgement::Clear; + Some(verdict) + } + } + } +} + +#[cfg(test)] +mod judge_tests { + use super::*; + + const SR: u32 = 48_000; + const BLOCK: usize = 64; + + /// Drives a judge with a source delivering `burst` frames every + /// `burst` frames of time, silent in `quiet`, and holding back (then + /// catching up) in `late`. Returns every verdict. + fn run(burst: usize, seconds: f64, quiet: &[(f64, f64)], late: &[(f64, f64)]) -> Vec { + run_from(burst, seconds, quiet, late, 4 * burst) + } + + /// `queue` is what the consumer holds when the run starts. + fn run_from( + burst: usize, + seconds: f64, + quiet: &[(f64, f64)], + late: &[(f64, f64)], + mut queue: usize, + ) -> Vec { + let mut j = OutageJudge::new(SR); + let mut verdicts = Vec::new(); + let (mut t, mut next, mut owed_back) = (0usize, 0usize, 0usize); + let mut refilling = false; + let end = (seconds * SR as f64) as usize; + let secs = |f: usize| f as f64 / SR as f64; + while t < end { + let mut arrived = 0; + while next <= t { + let s = secs(next); + if quiet.iter().any(|&(a, b)| s >= a && s < b) { + // Nothing exists to deliver. + } else if late.iter().any(|&(a, b)| s >= a && s < b) { + owed_back += burst; + } else { + arrived += burst + owed_back; + owed_back = 0; + } + next += burst; + } + queue += arrived; + if let Some(v) = j.delivered(arrived, BLOCK) { + verdicts.push(v); + } + // Like a real consumer, one that ran dry refills a reserve of two + // deliveries before it plays on. + if refilling && queue >= 2 * burst { + refilling = false; + } + if !refilling { + if queue < BLOCK { + j.missing(BLOCK - queue); + queue = 0; + refilling = true; + } else { + queue -= BLOCK; + } + } + t += BLOCK; + } + verdicts + } + + #[test] + fn a_steady_source_is_never_judged() { + assert!(run(480, 10.0, &[], &[]).is_empty()); + } + + #[test] + fn a_source_that_went_quiet_was_not_late() { + let verdicts = run(480, 20.0, &[(2.0, 7.0), (10.0, 10.08), (12.0, 12.3)], &[]); + assert_eq!(verdicts.len(), 3, "each gap judged once: {verdicts:?}"); + assert!( + verdicts.iter().all(|&v| v == 0), + "silence charged: {verdicts:?}" + ); + } + + #[test] + fn late_audio_that_catches_up_is_charged() { + // 50 ms of deliveries held back, then released together, into a + // consumer holding nothing in reserve: all of it went unplayed. + let verdicts = run_from(480, 10.0, &[], &[(3.0, 3.05)], 0); + // A consumer with no reserve also runs dry once at startup, between + // the first two deliveries; that gap is rightly not lateness. + assert!( + verdicts[..verdicts.len() - 1].iter().all(|&v| v == 0), + "{verdicts:?}" + ); + let late = *verdicts.last().expect("the held-back delivery was judged"); + assert!( + (1_900..=2_900).contains(&late), + "50 ms late should charge ~2400 frames: {late}" + ); + // With a reserve, only what the reserve failed to cover is charged. + let covered = *run_from(480, 10.0, &[], &[(3.0, 3.05)], 1_920) + .last() + .expect("judged"); + assert!(covered < late && covered > 0, "{covered} vs {late}"); + } + + #[test] + fn a_quiet_spell_ending_in_late_audio_charges_only_the_lateness() { + // 2 s quiet, then the first 30 ms of the resumed flow arrive late. + let verdicts = run(480, 10.0, &[(2.0, 4.0)], &[(4.0, 4.03)]); + assert_eq!(verdicts.len(), 1, "{verdicts:?}"); + assert!(verdicts[0] < 2_400, "charged the silence: {}", verdicts[0]); + } + + #[test] + fn talk_spurts_with_gaps_are_never_charged() { + // DTX-like: 300 ms of audio, 200 ms of nothing, for a minute. + let quiet: Vec<(f64, f64)> = (0..120) + .map(|k| (k as f64 * 0.5 + 0.3, k as f64 * 0.5 + 0.5)) + .collect(); + let verdicts = run(960, 60.0, &quiet, &[]); + assert!(!verdicts.is_empty()); + assert!(verdicts.iter().all(|&v| v == 0), "{verdicts:?}"); + } + + #[test] + fn judging_never_allocates() { + let mut j = OutageJudge::new(SR); + crate::audio::rt_guard::assert_no_alloc("judge", || { + for i in 0..10_000 { + if i % 100 == 0 { + j.missing(64); + } + std::hint::black_box(j.delivered(if i % 7 == 0 { 480 } else { 0 }, 64)); + } + }); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/audio/pipeline/cushion.rs b/src-tauri/src/audio/pipeline/cushion.rs index 92f9659c..bb84b2eb 100644 --- a/src-tauri/src/audio/pipeline/cushion.rs +++ b/src-tauri/src/audio/pipeline/cushion.rs @@ -7,8 +7,12 @@ //! startup backlog) or stretching a few in (capture clock slower), sparsely //! and with crossfades, so the correction is inaudible and never adds latency //! of its own. +//! +//! Running dry deepens the queue only by the audio that turns out to have +//! been late (`OutageJudge`). A source that goes quiet -- a paused app, a +//! muted device -- leaves the depth where it was. -use crate::audio::adaptive_depth::DepthEstimator; +use crate::audio::adaptive_depth::{DepthEstimator, OutageJudge}; /// Correction the source should apply before reading its next block. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,6 +38,10 @@ const SAFETY_MS: f64 = 1.0; /// One splice per this much audio at most, so time is never compressed or /// stretched by more than ~6% while a correction runs. const SPLICE_EVERY_MS: f64 = 21.0; +/// Ceiling on the measured headroom. Jitter beyond this is a broken capture, +/// and a deeper target would outgrow the one-second input ring and never +/// finish priming. +const MAX_DEPTH_MS: f64 = 250.0; pub(super) struct Cushion { need: usize, @@ -47,8 +55,8 @@ pub(super) struct Cushion { cooldown: usize, cooldown_blocks: usize, primed: bool, - /// Frames missed since the queue last ran dry, while it refills. - outage: Option, + judge: OutageJudge, + max_depth: usize, } impl Cushion { @@ -66,7 +74,8 @@ impl Cushion { cooldown: 0, cooldown_blocks: (frames(SPLICE_EVERY_MS) / need.max(1)).max(1), primed: false, - outage: None, + judge: OutageJudge::new(sample_rate), + max_depth: frames(MAX_DEPTH_MS), } } @@ -80,32 +89,42 @@ impl Cushion { /// Mean queue depth to hold: the floor plus the measured dip. pub(super) fn target(&self) -> usize { - self.floor() + self.depth.depth() + self.floor() + self.depth.depth().min(self.max_depth) + } + + /// Once per block, before anything else: the frames the source delivered + /// since the last block. A gap's verdict lands here, once the flow after + /// it shows whether the missing audio was late or never existed. + pub(super) fn arrived(&mut self, frames: usize) { + if let Some(late) = self.judge.delivered(frames, self.need) { + if late > 0 { + self.depth.underrun(late); + } + } + } + + /// Queue to start playing from. Priming ends right after a delivery, at + /// the top of the saw, so it keeps the mean target plus the saw's dip: + /// what the queue will have drained by the time the next delivery lands. + fn start_level(&self) -> usize { + self.target() + self.depth.depth().min(self.max_depth) } /// Before the source's first read, and again after it ran dry. `None` /// while the queue is still filling: play silence for this block. Once it - /// reaches the target, everything beyond it is backlog nobody has heard, - /// so it can go at once: returns the frames to discard. - /// - /// A refill after running dry is one outage, however many blocks it - /// spans; it is charged to the depth once, sized by everything missed. + /// reaches the start level, everything beyond it is backlog nobody has + /// heard, so it can go at once: returns the frames to discard. pub(super) fn prime(&mut self, queued: usize) -> Option { - if queued < self.target() { - if let Some(missed) = self.outage.as_mut() { - *missed += self.need; - } + let start = self.start_level(); + if queued < start { return None; } - if let Some(missed) = self.outage.take() { - self.depth.underrun(missed); - } self.primed = true; self.owed = 0; self.sum = 0.0; self.blocks = 0; self.low = usize::MAX; - Some(queued.saturating_sub(self.target())) + Some(queued - start) } pub(super) fn is_primed(&self) -> bool { @@ -116,7 +135,9 @@ impl Cushion { /// on, rather than limp along a starved queue one click per block. pub(super) fn underrun(&mut self, missing: usize) { self.primed = false; - self.outage = Some(self.outage.unwrap_or(0) + missing); + self.judge.missing(missing); + // The window holds the drain, which says nothing about jitter. + self.depth.discard_window(); } /// Asymmetric on purpose. Running short is an audible dropout, so any @@ -203,6 +224,31 @@ mod tests { initial_backlog: usize, seconds: f64, settle_s: f64, + ) -> Run { + simulate_with_quiet( + block, + burst, + capture_ppm, + jitter, + initial_backlog, + seconds, + settle_s, + &[], + ) + } + + /// As `simulate`, with spans where the source delivers nothing at all + /// (its audio does not exist, as with a paused app). + #[allow(clippy::too_many_arguments)] + fn simulate_with_quiet( + block: usize, + burst: usize, + capture_ppm: f64, + jitter: f64, + initial_backlog: usize, + seconds: f64, + settle_s: f64, + quiet: &[(f64, f64)], ) -> Run { let mut c = Cushion::new(block, SR); let out_period = block as f64 / SR as f64; @@ -219,9 +265,13 @@ mod tests { max_queued: 0, }; let mut samples = 0usize; + let mut arrived = 0usize; while t_out < seconds { if next_in + late <= t_out { - queued += burst; + if !quiet.iter().any(|&(a, b)| next_in >= a && next_in < b) { + queued += burst; + arrived += burst; + } next_in += in_period; seed ^= seed << 13; seed ^= seed >> 17; @@ -229,6 +279,7 @@ mod tests { late = jitter * in_period * (seed % 1000) as f64 / 1000.0; continue; } + c.arrived(std::mem::take(&mut arrived)); if !c.is_primed() { match c.prime(queued) { Some(excess) => queued -= excess, @@ -250,7 +301,10 @@ mod tests { Adjust::None => {} } if queued < block { - if t_out > settle_s { + let quiet_now = quiet + .iter() + .any(|&(a, b)| t_out >= a - 0.05 && t_out < b + 0.05); + if t_out > settle_s && !quiet_now { run.late_underruns += 1; } c.underrun(block - queued); @@ -275,7 +329,7 @@ mod tests { assert_eq!(c.prime(10), None, "still filling"); assert!(!c.is_primed()); let excess = c.prime(20_000).expect("primed"); - assert_eq!(20_000 - excess, c.target()); + assert_eq!(20_000 - excess, c.start_level()); assert!(c.is_primed()); } @@ -331,22 +385,96 @@ mod tests { ); } + /// A second of ordinary flow (`burst` frames every `burst` frames of + /// time), then `gap` frames of time with nothing delivered, then + /// `catch_up` extra frames on the first delivery after it, then ordinary + /// flow until the verdict is in. The consumer runs dry right as the gap + /// starts, the worst case. + fn gap_then(c: &mut Cushion, need: usize, burst: usize, gap: usize, catch_up: usize) { + let flow = |c: &mut Cushion, frames: usize, first_extra: usize| { + let (mut t, mut next, mut extra) = (0, 0, first_extra); + while t < frames { + let mut arrived = 0; + while next <= t { + arrived += burst + std::mem::take(&mut extra); + next += burst; + } + c.arrived(arrived); + t += need; + } + }; + flow(c, SR as usize, 0); + c.underrun(need); + for _ in 0..gap / need { + c.arrived(0); + } + flow(c, SR as usize, catch_up); + } + #[test] - fn a_run_of_empty_blocks_is_one_outage() { + fn late_audio_deepens_the_queue_once_by_what_was_late() { let mut c = Cushion::new(32, SR); - c.prime(c.target()).expect("primed"); + c.prime(c.start_level()).expect("primed"); let before = c.target(); - c.underrun(32); - // 20 ms of silence while the capture refills. - for _ in 0..30 { - assert_eq!(c.prime(0), None); - } - let excess = c.prime(100_000).expect("refilled"); + // 20 ms held back, then delivered all at once with the next burst. + gap_then(&mut c, 32, 960, 960, 960); let grown = c.target() - before; - let missed = 32 + 30 * 32; - assert!(grown >= missed, "depth must cover the outage: +{grown}"); - assert!(grown < 2 * missed, "one outage, not thirty: +{grown}"); - assert_eq!(100_000 - excess, c.target()); + assert!(grown >= 900, "depth must cover the lateness: +{grown}"); + assert!(grown < 2 * 1_920, "one outage, not thirty: +{grown}"); + } + + #[test] + fn a_source_going_quiet_is_not_charged_as_jitter() { + let mut c = Cushion::new(64, SR); + c.prime(c.start_level()).expect("primed"); + let before = c.target(); + // Five seconds of a paused app, then it plays on at the ordinary rate. + gap_then(&mut c, 64, 480, 5 * SR as usize, 0); + assert_eq!(c.target(), before, "a pause left the queue deeper"); + // And a short quiet spell between sounds, the same. + gap_then(&mut c, 64, 480, 384, 0); + assert_eq!(c.target(), before, "a 8 ms gap left the queue deeper"); + } + + #[test] + fn the_target_always_fits_the_input_ring() { + let mut c = Cushion::new(64, SR); + for _ in 0..50 { + // Huge lateness, all caught up: the worst a capture can report. + gap_then(&mut c, 64, 480, 320_000, 320_000); + } + assert!(c.target() < SR as usize / 2, "target {} frames", c.target()); + } + + #[test] + fn an_app_that_plays_and_pauses_keeps_capture_latency() { + // Sounds of 200 ms with 60-400 ms of nothing between them, for two + // minutes: none of that silence may turn into queue depth. + let quiet: Vec<(f64, f64)> = (0..240) + .map(|k| { + let start = k as f64 * 0.5 + 0.2; + (start, start + 0.06 + (k % 7) as f64 * 0.05) + }) + .collect(); + let steady = simulate(64, 480, 0.0, 0.0, 0, 120.0, 10.0); + let r = simulate_with_quiet(64, 480, 0.0, 0.0, 0, 120.0, 10.0, &quiet); + assert_eq!(r.late_underruns, 0, "underran while the app was playing"); + assert!( + r.max_queued <= steady.max_queued + 64, + "pauses deepened the queue: {} vs {} frames", + r.max_queued, + steady.max_queued + ); + } + + #[test] + fn jitter_still_buys_headroom_between_pauses() { + // A jittery capture that also pauses: the jitter is still measured. + let quiet: Vec<(f64, f64)> = (0..60) + .map(|k| (k as f64 * 2.0 + 1.0, k as f64 * 2.0 + 1.3)) + .collect(); + let r = simulate_with_quiet(64, 480, 0.0, 0.8, 0, 120.0, 30.0, &quiet); + assert_eq!(r.late_underruns, 0); } #[test] diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 36a6fdbf..ba189967 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -280,6 +280,9 @@ struct SourceState { cushion: Option, /// Smoothed frames left queued after each read: this source's latency. queue_avg: f64, + /// Frames queued when the previous block finished; what the queue holds + /// beyond it at the next block is what the source delivered in between. + queued_after: usize, /// >STALL_THRESHOLD since last pop => zero-fill and stop waiting on this source. last_pop_at: Instant, volume: Arc, @@ -420,7 +423,8 @@ impl SourceState { } fn publish_queue(&mut self) { - let queued = self.queued_frames() as f64; + self.queued_after = self.queued_frames(); + let queued = self.queued_after as f64; self.queue_avg += (queued - self.queue_avg) * 0.02; self.stats .queue_frames @@ -435,6 +439,9 @@ impl SourceState { }; let queued = (self.consumer.slots() + self.input_staging.len() + self.out_pending.len()) / self.channels; + // Taken before any splice or discard this block, against where the + // previous block left the queue: only the source moves it in between. + c.arrived(queued.saturating_sub(self.queued_after)); if !c.is_primed() { match c.prime(queued) { None => return false, @@ -1419,6 +1426,7 @@ pub(super) fn build_output_graph( cushion: source_realtime .then(|| Cushion::new(input_frames_per_block as usize, input_sr)), queue_avg: 0.0, + queued_after: 0, last_pop_at: Instant::now(), volume: input_volumes .get(id) @@ -1910,6 +1918,7 @@ fn ring_source( input_samples_per_block, cushion: realtime.then(|| Cushion::new(input_frames_per_block as usize, owner_sr)), queue_avg: 0.0, + queued_after: 0, last_pop_at: Instant::now(), volume: Arc::new(AtomicU32::new(0x3F80_0000)), paused: None, @@ -2674,6 +2683,157 @@ pub(super) mod graph_tests { (kept, stats) } + /// One observation per output block of a live source whose capture goes + /// quiet during `gaps` (a paused app delivers nothing at all). + struct Tick { + t: f64, + /// Frames between the newest captured frame and the one just played; + /// `None` while the output is silent. + real_delay: Option, + reported_queue: usize, + } + + fn live_capture_with_gaps( + block: usize, + burst: usize, + seconds: f64, + gaps: &[(f64, f64)], + ) -> Vec { + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, true); + let stats = built.sources[0].stats.clone(); + let input = producers.get_mut("m").unwrap(); + let out_period = block as f64 / SR as f64; + let in_period = burst as f64 / SR as f64; + let (mut t_in, mut t_out, mut fed) = (in_period * 0.37, 0.0, 0usize); + let mut out = vec![0.0; block * 2]; + let mut chunk = vec![0.0; burst * 2]; + let mut ticks = Vec::new(); + while t_out < seconds { + if t_in <= t_out { + let paused = gaps.iter().any(|&(a, b)| t_in >= a && t_in < b); + if !paused { + // Each sample carries its own capture index (+1, so 0 is silence). + for f in 0..burst { + let v = (fed + f + 1) as f32; + chunk[f * 2] = v; + chunk[f * 2 + 1] = v; + } + push_all(input, &chunk); + fed += burst; + } + t_in += in_period; + continue; + } + built.graph.process_block(&mut out); + let last = out[(block - 1) * 2]; + ticks.push(Tick { + t: t_out, + real_delay: (last > 0.0).then(|| fed - last as usize), + reported_queue: stats.queue_frames.load(Ordering::Relaxed) as usize, + }); + t_out += out_period; + } + ticks + } + + #[test] + fn a_paused_app_resumes_at_low_latency_every_time() { + let gaps = [(10.0, 15.0), (25.0, 30.0), (40.0, 41.0)]; + let ticks = live_capture_with_gaps(64, 480, 55.0, &gaps); + for (resume, until) in [(15.0, 25.0), (30.0, 40.0), (41.0, 55.0)] { + let window: Vec<&Tick> = ticks + .iter() + .filter(|k| k.t >= resume + 1.0 && k.t < until) + .collect(); + let playing = window.iter().filter(|k| k.real_delay.is_some()).count(); + assert!( + playing * 10 >= window.len() * 9, + "after resuming at {resume} s only {playing}/{} blocks played", + window.len() + ); + let worst = window + .iter() + .filter_map(|k| k.real_delay) + .max() + .unwrap_or(0); + assert!( + worst < 480 + 4 * 64 + 480, + "resumed at {resume} s with {worst} frames of delay" + ); + } + } + + fn worst_delay(ticks: &[Tick], from: f64) -> usize { + ticks + .iter() + .filter(|k| k.t >= from) + .filter_map(|k| k.real_delay) + .max() + .unwrap_or(0) + } + + #[test] + fn silence_between_sounds_never_becomes_latency() { + // An app playing 200 ms sounds with 60-360 ms of nothing between them, + // for two minutes: the delay must stay what a steady source gets. + let gaps: Vec<(f64, f64)> = (0..240) + .map(|k| { + let start = 5.0 + k as f64 * 0.5 + 0.2; + (start, start + 0.06 + (k % 7) as f64 * 0.05) + }) + .collect(); + let steady = live_capture_with_gaps(64, 480, 125.0, &[]); + let gappy = live_capture_with_gaps(64, 480, 125.0, &gaps); + let baseline = worst_delay(&steady, 3.0); + let with_gaps = worst_delay(&gappy, 3.0); + assert!( + with_gaps <= baseline + 64, + "silence added latency: {with_gaps} frames vs {baseline} steady" + ); + // Late in the run, after a hundred pauses, it has not crept up either. + assert!(worst_delay(&gappy, 100.0) <= baseline + 64); + } + + #[test] + fn readout_does_not_climb_across_pauses() { + let gaps: Vec<(f64, f64)> = (0..40).map(|k| (5.0 + k as f64, 5.4 + k as f64)).collect(); + let ticks = live_capture_with_gaps(64, 480, 45.0, &gaps); + let early = ticks + .iter() + .filter(|k| (3.0..5.0).contains(&k.t)) + .map(|k| k.reported_queue) + .max() + .unwrap(); + let late = ticks + .iter() + .filter(|k| k.t > 40.0) + .map(|k| k.reported_queue) + .max() + .unwrap(); + assert!( + late <= early + 64, + "readout crept from {early} to {late} frames" + ); + } + + #[test] + fn reported_queue_tracks_the_real_delay() { + let ticks = live_capture_with_gaps(64, 480, 55.0, &[(10.0, 15.0), (25.0, 30.0)]); + for k in ticks.iter().filter(|k| k.t > 2.0) { + if let Some(real) = k.real_delay { + assert!( + k.reported_queue <= real + 480, + "{:.2} s: reported {} frames queued, real delay {real}", + k.t, + k.reported_queue + ); + } + } + let worst = ticks.iter().map(|k| k.reported_queue).max().unwrap(); + assert!(worst < 4 * 480, "queue readout reached {worst} frames"); + } + #[test] fn live_source_runs_at_capture_latency_without_glitches() { for ppm in [-200.0, 0.0, 200.0] { diff --git a/src-tauri/src/audio/stream_recv.rs b/src-tauri/src/audio/stream_recv.rs index 1a771285..31ef80c1 100644 --- a/src-tauri/src/audio/stream_recv.rs +++ b/src-tauri/src/audio/stream_recv.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex, Weak}; use rtrb::{Consumer, Producer, RingBuffer}; -use crate::audio::adaptive_depth::DepthEstimator; +use crate::audio::adaptive_depth::{DepthEstimator, OutageJudge}; use crate::audio::health; use crate::audio::resample::MultiResamplerOut; use crate::audio::streams::bulk_push_counted; @@ -46,9 +46,6 @@ const TARGET_SAFETY: usize = 240; // ~5 ms const JITTER_WINDOW_MS: f64 = 500.0; /// A backlog jump beyond this is a re-prime refill, not drift. const TARGET_EVENT_MAX: usize = 4_800; // ~100 ms -/// A gap longer than this is the sender pausing, not network jitter, and -/// must not leave the buffer deep for the minute that follows. -const OUTAGE_MAX: usize = 48_000; // 1 s /// Proportional gain: backlog error (samples) -> ratio correction. A varying /// resample ratio IS pitch modulation, so the loop stays far slower and far @@ -191,6 +188,8 @@ pub struct PlaybackTap { last_total: u64, idle_blocks: u32, idle_dead_blocks: u32, + /// Samples delivered since the previous block. + arrived: usize, // PLC: the last real output block, and whether we're currently in a gap. // On a network underrun we fade this out (instead of a hard silence step), // and fade the real audio back in on recovery. @@ -233,6 +232,7 @@ impl PlaybackTap { popped: 0, last_total: 0, idle_blocks: 0, + arrived: 0, idle_dead_blocks: (IDLE_DEAD_MS * rate as usize / 1000 / block_frames.max(1)).max(2) as u32, last_block: vec![0.0; block_frames], @@ -259,6 +259,7 @@ impl PlaybackTap { /// after `snap_backlog` is taken. fn track_liveness(&mut self) { let total = self.popped + self.snap_backlog as u64; + self.arrived = total.saturating_sub(self.last_total) as usize; if total != self.last_total { self.last_total = total; self.idle_blocks = 0; @@ -626,7 +627,13 @@ pub struct ChannelReceiver { block_frames: usize, // Adaptive-jitter state; only the single audio thread touches these. depth: std::cell::RefCell, - starve_blocks: Cell, + judge: std::cell::RefCell, + /// Source samples one block consumes. + need: usize, + starving: Cell, + /// Fill a refilling source must reach before it plays: the target plus + /// the arrival saw's dip, since priming ends right after a packet. + start: Cell, ever_primed: Cell, avg_backlog: Cell, // Last emitted mix, held when the tap map is briefly locked for registration @@ -641,6 +648,8 @@ pub struct ChannelReceiver { struct GroupPlan { id: u64, min_backlog: usize, + /// Least any channel of the source received since the last block. + arrived: usize, need: usize, primed: bool, trim: usize, @@ -665,7 +674,10 @@ impl ChannelReceiver { JITTER_WINDOW_MS, TARGET_INIT, )), - starve_blocks: Cell::new(0), + judge: std::cell::RefCell::new(OutageJudge::new(SR)), + need: need.max(1), + starving: Cell::new(false), + start: Cell::new(TARGET_INIT), ever_primed: Cell::new(false), avg_backlog: Cell::new(-1.0), last_mix: std::cell::RefCell::new(vec![0.0; handle.block_frames * 2]), @@ -709,12 +721,14 @@ impl ChannelReceiver { match plans.iter_mut().find(|p| p.id == tap.group) { Some(p) => { p.min_backlog = p.min_backlog.min(backlog); + p.arrived = p.arrived.min(tap.arrived); p.need = p.need.max(need); p.primed &= tap.primed; } None => plans.push(GroupPlan { id: tap.group, min_backlog: backlog, + arrived: tap.arrived, need, primed: tap.primed, trim: 0, @@ -731,7 +745,7 @@ impl ChannelReceiver { // channel whose packet for this tick has already landed leads // its siblings by exactly that packet, and that lead *is* the // alignment. - if p.min_backlog < target.max(p.need) { + if p.min_backlog < self.start.get().max(p.need) { p.hold = true; continue; } @@ -770,7 +784,13 @@ impl ChannelReceiver { // silence is part of the outage too. let starving = live.is_some_and(|p| p.min_backlog < p.need) || (self.ever_primed.get() && (plans.is_empty() || plans.iter().any(|p| p.hold))); - self.account(starving, live.map(|p| (p.min_backlog, p.need))); + // What the emptiest source delivered, holding or not: the judge + // needs the flow while a gap refills, too. + let arrived = plans + .iter() + .min_by_key(|p| p.min_backlog) + .map_or(0, |p| p.arrived); + self.account(starving, live.map(|p| (p.min_backlog, p.need)), arrived); if let Some(p) = live { self.steer(p.min_backlog); } @@ -819,26 +839,35 @@ impl ChannelReceiver { } /// Adaptive buffer depth. `live` is the emptiest source that is actually - /// playing, absent while every source is refilling. - fn account(&self, starving: bool, live: Option<(usize, usize)>) { + /// playing, absent while every source is refilling; `arrived` is what the + /// emptiest source delivered this block. + /// + /// Running dry deepens the buffer only by audio that turns out to have + /// been late: a sender that goes quiet (a paused peer, Opus DTX) sends + /// nothing because there is nothing, and waiting longer would not help. + fn account(&self, starving: bool, live: Option<(usize, usize)>, arrived: usize) { + let mut depth = self.depth.borrow_mut(); + let mut judge = self.judge.borrow_mut(); + if starving && !self.starving.get() { + judge.missing(self.need); + // The window holds the drain, which says nothing about jitter. + depth.discard_window(); + } + self.starving.set(starving); + if let Some(late) = judge.delivered(arrived, self.need) { + if late > 0 { + depth.underrun(late); + } + } if starving { - self.starve_blocks.set(self.starve_blocks.get() + 1); return; } let Some((backlog, need)) = live else { return }; - let mut depth = self.depth.borrow_mut(); - // Charged when the outage ends, as one event sized by everything the - // source failed to deliver: a buffer deeper by that much is precisely - // the buffer that would have played straight through it. - let missed = self.starve_blocks.replace(0) as usize * need; - if missed > 0 && missed <= OUTAGE_MAX { - depth.underrun(missed); - } - { - depth.observe(backlog); - let target = (need + depth.depth() + TARGET_SAFETY).clamp(TARGET_MIN, TARGET_MAX); - self.target.store(target as u32, Ordering::Relaxed); - } + depth.observe(backlog); + let dip = depth.depth(); + let target = (need + dip + TARGET_SAFETY).clamp(TARGET_MIN, TARGET_MAX); + self.target.store(target as u32, Ordering::Relaxed); + self.start.set((target + dip).min(TARGET_MAX)); } /// Drift ratio from the current backlog. @@ -941,6 +970,22 @@ mod tests { settle: f64, jitter: impl Fn(u32) -> f64, outage: Option<(f64, f64)>, + ) -> NetRun { + net_sim_with(block, seconds, settle, jitter, outage, &[]) + } + + /// Steady packets, except nothing at all is sent inside `quiet`. + fn net_sim_quiet(block: usize, seconds: f64, settle: f64, quiet: &[(f64, f64)]) -> NetRun { + net_sim_with(block, seconds, settle, |_| 0.0, None, quiet) + } + + fn net_sim_with( + block: usize, + seconds: f64, + settle: f64, + jitter: impl Fn(u32) -> f64, + outage: Option<(f64, f64)>, + quiet: &[(f64, f64)], ) -> NetRun { const PACKET: usize = 960; let reg = FanoutRegistry::default(); @@ -962,7 +1007,8 @@ mod tests { let due = k as f64 * period + jitter(k); if due <= t { let sent = k as f64 * period; - let dropped = outage.is_some_and(|(a, b)| sent >= a && sent < b); + let dropped = outage.is_some_and(|(a, b)| sent >= a && sent < b) + || quiet.iter().any(|&(a, b)| sent >= a && sent < b); if !dropped { broadcast_push(&bc, k as u16, 1, &packet); } @@ -1021,15 +1067,40 @@ mod tests { } #[test] - fn an_outage_deepens_the_buffer_at_once() { - let r = net_sim(64, 20.0, 5.0, |_| 0.0, Some((8.0, 8.15))); + fn a_network_stall_deepens_the_buffer_at_once() { + // Packets sent during a 150 ms stall reach us together when it clears. + let stall = |k: u32| { + let sent = k as f64 * 0.02; + if (8.0..8.15).contains(&sent) { + 8.15 - sent + } else { + 0.0 + } + }; + let before = net_sim(64, 7.9, 5.0, stall, None).target; + let r = net_sim(64, 20.0, 5.0, stall, Some((8.15, 8.15))); assert!( - r.target_after_outage >= 7_200, - "150 ms outage must buy >= 150 ms: {}", + r.target_after_outage >= before + 5_000, + "150 ms of late audio must buy most of 150 ms: {before} -> {}", r.target_after_outage ); } + #[test] + fn a_quiet_sender_between_phrases_keeps_the_buffer_shallow() { + // DTX-like: 400 ms of speech, 250 ms of nothing sent, for a minute. + let quiet: Vec<(f64, f64)> = (0..90) + .map(|k| (5.0 + k as f64 * 0.65 + 0.4, 5.0 + k as f64 * 0.65 + 0.65)) + .collect(); + let steady = net_sim(64, 65.0, 10.0, |_| 0.0, None).target; + let r = net_sim_quiet(64, 65.0, 10.0, &quiet); + assert!( + r.target <= steady + 480, + "silence between phrases deepened the buffer: {steady} -> {}", + r.target + ); + } + #[test] fn a_sender_pause_is_not_charged_as_jitter() { let r = net_sim(64, 30.0, 5.0, |_| 0.0, Some((8.0, 13.0))); From 75dfbbf812e058d09e3a893ce8228465cbb3e46f Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:34:53 +0300 Subject: [PATCH 05/51] fix(audio): lock same-clock sources, size capture buffers to the engine block --- .gitignore | 1 + docs/CONCEPT.md | 2 +- src-tauri/native/CATapCapture.swift | 23 ++ src-tauri/src/audio/adaptive_depth.rs | 10 + src-tauri/src/audio/capture/macos_backend.rs | 18 +- src-tauri/src/audio/capture/macos_tap.rs | 15 +- src-tauri/src/audio/drift_loop.rs | 326 +++++++++++++++ src-tauri/src/audio/input_bridge.rs | 37 +- src-tauri/src/audio/macos_hal.rs | 30 ++ src-tauri/src/audio/mod.rs | 1 + src-tauri/src/audio/pipeline/cushion.rs | 388 +++++++++++++++++- src-tauri/src/audio/pipeline/dag.rs | 259 +++++++++++- src-tauri/src/audio/pipeline/input/linux.rs | 7 + src-tauri/src/audio/pipeline/input/macos.rs | 31 +- src-tauri/src/audio/pipeline/input/mod.rs | 19 +- src-tauri/src/audio/pipeline/input/windows.rs | 7 + src-tauri/src/audio/pipeline/mod.rs | 20 +- src-tauri/src/audio/streams/mod.rs | 80 +++- 18 files changed, 1218 insertions(+), 56 deletions(-) create mode 100644 src-tauri/src/audio/drift_loop.rs diff --git a/.gitignore b/.gitignore index c81ee9cd..96a7be34 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ vite.config.ts.timestamp-* # storybook /storybook-static +/src-tauri/src/audio/pipeline/testdata diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 8ac39185..ed614aae 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -42,7 +42,7 @@ Without these, one chunk takes ~16 ms and the worker stalls. - Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. -- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. Excess is spliced out and shortfall stretched in, with crossfades; an underrun re-primes once instead of clicking per block. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`); a source that goes quiet (paused app, DTX sender) never does. +- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. Excess is spliced out and shortfall stretched in, with crossfades; an underrun re-primes once instead of clicking per block. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`); a source that goes quiet (paused app, DTX sender) never does. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) is locked: never spliced while it plays, realigned only from silence, with fades at every stop and start. Capture buffers (mic, tap aggregate) are requested at the engine block. - An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs at its own working block, declares it as latency for PDC, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/native/CATapCapture.swift b/src-tauri/native/CATapCapture.swift index 8337fe53..618f06b0 100644 --- a/src-tauri/native/CATapCapture.swift +++ b/src-tauri/native/CATapCapture.swift @@ -194,11 +194,15 @@ private final class Tap { private var tapChannels = 0 private var tapBufferIndex = 0 private var sampleRate = 0.0 + /// IO buffer the aggregate is asked for, in frames; 0 keeps the HAL default. + /// Each delivery is one buffer, so it sets how bursty capture is. + private var ioFrames: UInt32 = 0 /// Preallocated so the IOProc never allocates while de-interleaving. private var scratch = [Float]() func start( mode: TapMode, + ioFrames: UInt32, callback: @escaping TapSampleCallback, userData: UnsafeMutableRawPointer? ) -> Int32 { @@ -206,6 +210,7 @@ private final class Tap { defer { lock.unlock() } self.mode = mode + self.ioFrames = ioFrames self.callback = callback self.userData = userData @@ -329,6 +334,7 @@ private final class Tap { return RESULT_TAP_ERROR } aggregateID = aggregate + applyBufferSize() let maxFrames = Int(readValue(aggregateID, kAudioDevicePropertyBufferFrameSize, UInt32(0)) ?? 4096) scratch = [Float](repeating: 0, count: max(maxFrames, 4096) * tapChannels) @@ -351,6 +357,19 @@ private final class Tap { return RESULT_OK } + /// Clamped to the aggregate's range. A refusal leaves the HAL default, which + /// only makes deliveries burstier, never wrong. + private func applyBufferSize() { + guard ioFrames > 0 else { return } + var frames = ioFrames + if let range = readValue(aggregateID, kAudioDevicePropertyBufferFrameSizeRange, AudioValueRange()), + range.mMinimum > 0, range.mMaximum >= range.mMinimum { + frames = UInt32(min(max(Double(frames), range.mMinimum), range.mMaximum)) + } + var addr = address(kAudioDevicePropertyBufferFrameSize) + _ = AudioObjectSetPropertyData(aggregateID, &addr, 0, nil, UInt32(MemoryLayout.size), &frames) + } + /// Order matters: a tap destroyed before its aggregate leaves the aggregate /// stranded in the HAL and the next create fails. private func teardown() { @@ -495,6 +514,7 @@ public func ba_tap_destroy(_ handle: OpaquePointer) { public func ba_tap_start_app( _ handle: OpaquePointer, _ bundleIDC: UnsafePointer, + _ ioFrames: UInt32, _ callback: @escaping TapSampleCallback, _ userData: UnsafeMutableRawPointer? ) -> Int32 { @@ -502,6 +522,7 @@ public func ba_tap_start_app( let tap = Unmanaged.fromOpaque(UnsafeRawPointer(handle)).takeUnretainedValue() return tap.start( mode: .application(bundleID: String(cString: bundleIDC)), + ioFrames: ioFrames, callback: callback, userData: userData ) @@ -513,6 +534,7 @@ public func ba_tap_start_app( public func ba_tap_start_system( _ handle: OpaquePointer, _ excludeCurrentApp: Int32, + _ ioFrames: UInt32, _ callback: @escaping TapSampleCallback, _ userData: UnsafeMutableRawPointer? ) -> Int32 { @@ -520,6 +542,7 @@ public func ba_tap_start_system( let tap = Unmanaged.fromOpaque(UnsafeRawPointer(handle)).takeUnretainedValue() return tap.start( mode: .system(excludeCurrentApp: excludeCurrentApp != 0), + ioFrames: ioFrames, callback: callback, userData: userData ) diff --git a/src-tauri/src/audio/adaptive_depth.rs b/src-tauri/src/audio/adaptive_depth.rs index e20d9e90..3a25939e 100644 --- a/src-tauri/src/audio/adaptive_depth.rs +++ b/src-tauri/src/audio/adaptive_depth.rs @@ -118,6 +118,11 @@ impl DepthEstimator { self.low = usize::MAX; } + /// True once the depth comes from measurement rather than the prior. + pub fn is_measured(&self) -> bool { + self.measured >= PRIOR_WINDOWS + } + /// Drops the window in progress. The queue draining because its source /// went quiet is not jitter, and a window holding that drain would read /// it as one. @@ -213,6 +218,11 @@ impl OutageJudge { } } + /// No gap is open or being judged: the flow is steady. + pub fn is_clear(&self) -> bool { + matches!(self.state, Judgement::Clear) + } + /// The consumer came up `frames` short: a gap starts (or grows). pub fn missing(&mut self, frames: usize) { self.state = match self.state { diff --git a/src-tauri/src/audio/capture/macos_backend.rs b/src-tauri/src/audio/capture/macos_backend.rs index 6fd6877a..b65accfe 100644 --- a/src-tauri/src/audio/capture/macos_backend.rs +++ b/src-tauri/src/audio/capture/macos_backend.rs @@ -55,11 +55,19 @@ pub fn capture_rate() -> u32 { } impl Capture { - pub fn start_app(bundle_id: &str, sample_rate: u32, bridge: BroadcastRx) -> AppResult { + /// `io_frames` sizes a tap's deliveries; ScreenCaptureKit picks its own. + pub fn start_app( + bundle_id: &str, + sample_rate: u32, + io_frames: u32, + bridge: BroadcastRx, + ) -> AppResult { match backend() { Backend::Tap => { - info!(%bundle_id, "starting app-audio capture (Core Audio process tap)"); - Ok(Capture::Tap(TapCapture::start_app(bundle_id, bridge)?)) + info!(%bundle_id, io_frames, "starting app-audio capture (Core Audio process tap)"); + Ok(Capture::Tap(TapCapture::start_app( + bundle_id, io_frames, bridge, + )?)) } Backend::Sck => { info!(%bundle_id, "starting app-audio capture (ScreenCaptureKit, macOS < 14.4)"); @@ -76,16 +84,18 @@ impl Capture { pub fn start_system( exclude_current_app: bool, sample_rate: u32, + io_frames: u32, bridge: BroadcastRx, ) -> AppResult { match backend() { Backend::Tap => { info!( exclude_current_app, - "starting system-audio capture (Core Audio process tap)" + io_frames, "starting system-audio capture (Core Audio process tap)" ); Ok(Capture::Tap(TapCapture::start_system( exclude_current_app, + io_frames, bridge, )?)) } diff --git a/src-tauri/src/audio/capture/macos_tap.rs b/src-tauri/src/audio/capture/macos_tap.rs index 35bb5411..c9a0fbe6 100644 --- a/src-tauri/src/audio/capture/macos_tap.rs +++ b/src-tauri/src/audio/capture/macos_tap.rs @@ -69,12 +69,14 @@ extern "C" { fn ba_tap_start_app( handle: *mut c_void, bundle_id: *const c_char, + io_frames: u32, callback: SampleCallback, user_data: *mut c_void, ) -> i32; fn ba_tap_start_system( handle: *mut c_void, exclude_current_app: i32, + io_frames: u32, callback: SampleCallback, user_data: *mut c_void, ) -> i32; @@ -142,7 +144,9 @@ extern "C" fn sample_trampoline( } impl TapCapture { - pub fn start_app(bundle_id: &str, bridge: BroadcastRx) -> AppResult { + /// `io_frames` is the IO buffer asked of the tap's aggregate. Each delivery + /// carries one, so it sets how bursty capture is; 0 keeps the HAL default. + pub fn start_app(bundle_id: &str, io_frames: u32, bridge: BroadcastRx) -> AppResult { let bundle_cstr = CString::new(bundle_id) .map_err(|_| AppError::Validation("bundle id contains nul byte".into()))?; Self::start( @@ -150,7 +154,7 @@ impl TapCapture { &format!("app audio capture ({bundle_id})"), bridge, |handle, callback, user_data| unsafe { - ba_tap_start_app(handle, bundle_cstr.as_ptr(), callback, user_data) + ba_tap_start_app(handle, bundle_cstr.as_ptr(), io_frames, callback, user_data) }, ) } @@ -158,7 +162,11 @@ impl TapCapture { /// When `exclude_current_app` is set our own output is dropped from the /// mix, which prevents a feedback loop when System Audio is routed back /// through Splitwave. - pub fn start_system(exclude_current_app: bool, bridge: BroadcastRx) -> AppResult { + pub fn start_system( + exclude_current_app: bool, + io_frames: u32, + bridge: BroadcastRx, + ) -> AppResult { Self::start( "system".to_string(), "system audio capture", @@ -167,6 +175,7 @@ impl TapCapture { ba_tap_start_system( handle, if exclude_current_app { 1 } else { 0 }, + io_frames, callback, user_data, ) diff --git a/src-tauri/src/audio/drift_loop.rs b/src-tauri/src/audio/drift_loop.rs new file mode 100644 index 00000000..bdb150b8 --- /dev/null +++ b/src-tauri/src/audio/drift_loop.rs @@ -0,0 +1,326 @@ +//! Clock-drift correction for a queue that crosses clock domains, the way +//! zita-a2j/j2a do it: the consumer reads through an asynchronous resampler +//! whose ratio a slow second-order loop steers, so the queue's mean settles on +//! its target and the two clocks' drift is absorbed smoothly, never spliced. +//! +//! The loop must see the queue as a smooth function of time. Sampled only at +//! the consumer's reads, the level saws with every delivery and, worse, its +//! sampled mean steps by a whole block each time the two clocks' phases slide +//! past each other: a step no lowpass removes, which the loop would chase with +//! hundreds of ppm. So the caller adds what the producer has made but not yet +//! delivered (its rate times the time since its last write, see +//! `input_bridge::WriteClock`), which cancels both. A two-pole lowpass at +//! twenty times the loop bandwidth takes out the jitter that is left. +//! +//! Plant: the error `x` (frames above target) moves as `dx/dt = d - r*u`, +//! where `d` is the drift in frames per second, `r` the consumer rate and `u` +//! the fractional extra consumption. With `u = (kp*x + I)/r`, `I' = ki*x`, the +//! loop is `s^2 + kp*s + ki = 0`; `kp = sqrt(2)*w`, `ki = w^2` is critically +//! damped at `w = 2*pi*B`. + +/// Steady-state loop bandwidth: slow enough that ratio modulation stays far +/// below anything audible as pitch movement. +const BANDWIDTH_HZ: f64 = 0.05; +/// Bandwidth while settling after a start, so the initial offset clears in +/// seconds rather than a minute. +const SETTLE_BANDWIDTH_HZ: f64 = 0.5; +const SETTLE_SECONDS: f64 = 4.0; +/// The error lowpass sits this far above the loop bandwidth. +const LOWPASS_FACTOR: f64 = 20.0; +/// Largest correction: 1000 ppm, about 1.7 cents, beyond any real crystal. +const MAX_CORRECTION: f64 = 1.0e-3; + +/// How fast the producer's timing estimate follows its write times. Far +/// below the delivery rate, so single late deliveries average out; the lag it +/// leaves under drift is a constant offset the loop's integrator absorbs. +const ARRIVAL_SMOOTHING_S: f64 = 1.0; + +/// The producer's delivery timing, smoothed: the time its frame `n` was due, +/// as `n / rate + offset`, with `offset` lowpassed across writes. From it the +/// consumer knows how many frames the producer has made but not yet written +/// without the jitter of any single delivery. +pub struct ArrivalClock { + rate: f64, + offset: f64, + last_frames: u64, + last_at: f64, + primed: bool, +} + +impl ArrivalClock { + pub fn new(sample_rate: u32) -> Self { + Self { + rate: sample_rate.max(1) as f64, + offset: 0.0, + last_frames: 0, + last_at: 0.0, + primed: false, + } + } + + /// Forget the timing: after a gap the producer resumes on a new offset. + pub fn reset(&mut self) { + self.primed = false; + } + + /// The producer has written `frames` in total, the last of them at `at` + /// seconds. Cheap to call every block; only a new write moves anything. + pub fn observe(&mut self, frames: u64, at: f64) { + if self.primed && frames == self.last_frames { + return; + } + let raw = at - frames as f64 / self.rate; + if !self.primed { + self.offset = raw; + self.primed = true; + } else { + let dt = (at - self.last_at).max(0.0); + let a = 1.0 - (-dt / ARRIVAL_SMOOTHING_S).exp(); + self.offset += a * (raw - self.offset); + } + self.last_frames = frames; + self.last_at = at; + } + + /// Frames the producer has made by `now` but not yet written, never + /// negative and never more than `cap`. + pub fn pending(&self, now: f64, cap: f64) -> f64 { + if !self.primed { + return 0.0; + } + ((now - self.offset) * self.rate - self.last_frames as f64).clamp(0.0, cap) + } +} + +pub struct DriftLoop { + rate: f64, + dt: f64, + elapsed: f64, + lp1: f64, + lp2: f64, + integral: f64, + correction: f64, + primed: bool, +} + +impl DriftLoop { + /// `sample_rate` and `block_frames` are the consumer's: `update` is + /// called once per block. + pub fn new(sample_rate: u32, block_frames: usize) -> Self { + Self { + rate: sample_rate.max(1) as f64, + dt: block_frames.max(1) as f64 / sample_rate.max(1) as f64, + elapsed: 0.0, + lp1: 0.0, + lp2: 0.0, + integral: 0.0, + correction: 0.0, + primed: false, + } + } + + /// Restart the settling phase and forget the filtered error, keeping the + /// drift the integrator has learned: after a gap the queue is realigned, + /// but the two clocks drift exactly as before. + pub fn restart(&mut self) { + self.elapsed = 0.0; + self.primed = false; + } + + /// Once per block with the queue's error against its target, in frames + /// (positive: too much queued). Returns the fractional extra consumption: + /// read `1 + u` input frames per output frame. + pub fn update(&mut self, error_frames: f64) -> f64 { + if !self.primed { + // Start the filter at the current error so the first blocks do + // not read as a step. + self.lp1 = error_frames; + self.lp2 = error_frames; + self.primed = true; + } + let settling = self.elapsed < SETTLE_SECONDS; + let bw = if settling { + SETTLE_BANDWIDTH_HZ + } else { + BANDWIDTH_HZ + }; + self.elapsed += self.dt; + let w = std::f64::consts::TAU * bw; + let a = 1.0 - (-std::f64::consts::TAU * bw * LOWPASS_FACTOR * self.dt).exp(); + self.lp1 += a * (error_frames - self.lp1); + self.lp2 += a * (self.lp1 - self.lp2); + let x = self.lp2; + let kp = std::f64::consts::SQRT_2 * w; + let ki = w * w; + let proposed = (kp * x + self.integral + ki * x * self.dt) / self.rate; + // Anti-windup: stop integrating while the output is pinned. + if proposed.abs() < MAX_CORRECTION { + self.integral += ki * x * self.dt; + } + self.correction = ((kp * x + self.integral) / self.rate).clamp(-MAX_CORRECTION, MAX_CORRECTION); + self.correction + } + + pub fn correction(&self) -> f64 { + self.correction + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SR: u32 = 48_000; + + struct Run { + /// Largest |error| over the last `tail` seconds, frames. + tail_error: f64, + /// Mean correction over the tail, and its peak-to-peak. + tail_u: f64, + tail_u_ripple: f64, + min_queue: f64, + } + + /// A producer delivering `burst` frames at a clock `ppm` off ours into a + /// queue that a consumer reads `block` frames at a time through a + /// resampler steered by the loop. The loop is fed the queue plus what the + /// producer has made since its last delivery, as the pipeline feeds it. + /// The queue starts `offset` frames off target. + fn run(ppm: f64, burst: usize, block: usize, offset: f64, seconds: f64, tail: f64) -> Run { + run_jittered(ppm, burst, block, offset, seconds, tail, 0.0) + } + + /// As `run`, each delivery landing up to `jitter` seconds late. + fn run_jittered( + ppm: f64, + burst: usize, + block: usize, + offset: f64, + seconds: f64, + tail: f64, + jitter: f64, + ) -> Run { + let mut rng: u32 = 0x1234_5678; + let mut l = DriftLoop::new(SR, block); + let target = 2_000.0; + let mut queue = target + offset; + let in_rate = SR as f64 * (1.0 + ppm * 1e-6); + let in_period = burst as f64 / in_rate; + let out_period = block as f64 / SR as f64; + let (mut t, mut next_in, mut written) = (0.0, in_period, 0u64); + let mut last_late = 0.0; + let mut clock = ArrivalClock::new(SR); + let mut r = Run { + tail_error: 0.0, + tail_u: 0.0, + tail_u_ripple: 0.0, + min_queue: f64::MAX, + }; + let (mut umin, mut umax, mut usum, mut n) = (f64::MAX, f64::MIN, 0.0, 0usize); + while t < seconds { + while next_in <= t { + queue += burst as f64; + written += burst as u64; + clock.observe(written, next_in); + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + let late = jitter * (rng % 1000) as f64 / 1000.0; + // Late by `late` against the producer's own schedule. + next_in = (next_in - last_late) + in_period + late; + last_late = late; + } + let accrued = clock.pending(t, 4.0 * burst as f64); + let u = l.update(queue + accrued - target); + queue -= block as f64 * (1.0 + u); + r.min_queue = r.min_queue.min(queue); + if t > seconds - tail { + r.tail_error = r.tail_error.max((l.lp2).abs()); + umin = umin.min(u); + umax = umax.max(u); + usum += u; + n += 1; + } + t += out_period; + } + r.tail_u = usum / n.max(1) as f64; + r.tail_u_ripple = umax - umin; + r + } + + #[test] + fn drift_is_absorbed_without_a_standing_error() { + for ppm in [-500.0, -100.0, -20.0, 0.0, 20.0, 100.0, 500.0] { + let r = run(ppm, 512, 64, 0.0, 240.0, 20.0); + // A faster producer is consumed faster: u settles on the drift. + assert!( + (r.tail_u - ppm * 1e-6).abs() < 2e-6, + "{ppm} ppm: settled at {:.1} ppm", + r.tail_u * 1e6 + ); + assert!(r.tail_error < 8.0, "{ppm} ppm: error {:.1} frames", r.tail_error); + } + } + + #[test] + fn the_delivery_saw_does_not_modulate_the_ratio() { + // 512-frame deliveries saw the queue by 512 frames at ~94 Hz; after the + // lowpass the ratio must hold within a fraction of a ppm. + let r = run(50.0, 512, 64, 0.0, 240.0, 20.0); + assert!(r.tail_u_ripple < 0.5e-6, "ripple {:.3} ppm", r.tail_u_ripple * 1e6); + } + + #[test] + fn delivery_jitter_does_not_modulate_the_ratio() { + // A normalizer thread polling every 1 ms: each delivery up to 1 ms late. + let r = run_jittered(50.0, 256, 64, 0.0, 240.0, 20.0, 0.001); + assert!(r.tail_u_ripple < 1.0e-6, "ripple {:.3} ppm", r.tail_u_ripple * 1e6); + assert!((r.tail_u - 50e-6).abs() < 3e-6, "settled at {:.1} ppm", r.tail_u * 1e6); + } + + #[test] + fn an_initial_offset_clears_while_settling() { + let r = run(0.0, 256, 64, 800.0, 30.0, 5.0); + assert!(r.tail_error < 40.0, "error {:.1} frames after 25 s", r.tail_error); + } + + #[test] + fn corrections_are_bounded() { + let mut l = DriftLoop::new(SR, 64); + for _ in 0..100_000 { + let u = l.update(1_000_000.0); + assert!(u.abs() <= MAX_CORRECTION + 1e-12); + } + // Anti-windup: once the error clears, it lets go promptly. + for _ in 0..(SR as usize / 64) * 30 { + l.update(0.0); + } + assert!(l.correction().abs() < MAX_CORRECTION, "wound up"); + } + + #[test] + fn restart_keeps_the_learned_drift() { + let mut l = DriftLoop::new(SR, 64); + let mut queue = 2_000.0; + // Learn 200 ppm. + for _ in 0..(SR as usize / 64) * 120 { + queue += 64.0 * 1.0002; + let u = l.update(queue - 2_000.0); + queue -= 64.0 * (1.0 + u); + } + let learned = l.correction(); + l.restart(); + let after = l.update(0.0); + assert!((after - learned).abs() < 20e-6, "{learned} -> {after}"); + } + + #[test] + fn updating_never_allocates() { + let mut l = DriftLoop::new(SR, 64); + crate::audio::rt_guard::assert_no_alloc("drift loop", || { + for i in 0..10_000 { + std::hint::black_box(l.update((i % 700) as f64)); + } + }); + } +} diff --git a/src-tauri/src/audio/input_bridge.rs b/src-tauri/src/audio/input_bridge.rs index 1939adb9..178af3b3 100644 --- a/src-tauri/src/audio/input_bridge.rs +++ b/src-tauri/src/audio/input_bridge.rs @@ -332,12 +332,39 @@ mod tests { let (prod, mut cons) = RingBuffer::::new(8); let (_, stats) = tx.add(prod).expect("add"); rx.apply_commands(); - rx.broadcast(&vec![1.0f32; 64]); - assert_eq!(stats.fed.load(Ordering::Relaxed), 8); - assert_eq!(stats.dropped.load(Ordering::Relaxed), 56); + rx.broadcast(&vec![1.0f32; 6]); + // Does not fit whole: dropped whole, so no frame is ever split. + rx.broadcast(&vec![2.0f32; 4]); + assert_eq!(stats.fed.load(Ordering::Relaxed), 6); + assert_eq!(stats.dropped.load(Ordering::Relaxed), 4); let mut out = vec![0.0f32; 8]; - assert_eq!(pop_into(&mut cons, &mut out), 8); - assert_eq!(out, vec![1.0; 8]); + assert_eq!(pop_into(&mut cons, &mut out), 6); + assert_eq!(out[..6], [1.0; 6]); + } + + // A mono 48 kHz mic normalized to 44.1 kHz: the resampler emits 235 and + // 236 frames in turn, and a mono frame is one sample, so half its chunks + // are odd. Every sample it emits must reach the graph. + #[test] + fn a_resampled_mono_mic_loses_nothing() { + let (mut tx, mut rx) = broadcast_channel(); + let (prod, cons) = RingBuffer::::new(96_000); + let (_, stats) = tx.add(prod).expect("add"); + rx.apply_commands(); + let mut rs = crate::audio::resample::MultiResampler::new(48_000, 44_100, 256, 1).unwrap(); + let input = vec![0.25f32; 256]; + let mut out = Vec::with_capacity(rs.out_max()); + let (mut emitted, mut odd) = (0usize, 0usize); + for _ in 0..48_000 / 256 { + out.clear(); + rs.process_chunk(&input, &mut out).unwrap(); + odd += out.len() % 2; + emitted += out.len(); + rx.broadcast(&out); + } + assert!(odd > 0, "the case under test: odd chunks happen"); + assert_eq!(stats.dropped.load(Ordering::Relaxed), 0); + assert_eq!(cons.slots(), emitted, "every resampled sample arrived"); } #[test] diff --git a/src-tauri/src/audio/macos_hal.rs b/src-tauri/src/audio/macos_hal.rs index 79b1fc74..b805ca40 100644 --- a/src-tauri/src/audio/macos_hal.rs +++ b/src-tauri/src/audio/macos_hal.rs @@ -937,6 +937,36 @@ pub fn set_buffer_frames( } } +const K_AUDIO_DEVICE_PROPERTY_CLOCK_DOMAIN: AudioObjectPropertySelector = fourcc(b"clkd"); +const K_AUDIO_HARDWARE_PROPERTY_DEFAULT_OUTPUT_DEVICE: AudioObjectPropertySelector = + fourcc(b"dOut"); + +/// The device's clock domain; devices sharing a non-zero domain run off one +/// clock. `None` when unknown (0), which USB devices commonly report. +pub fn clock_domain(kind: crate::audio::device::DeviceKind, name: &str) -> Option { + let id = find_device_id(name, scope_for(kind))?; + unsafe { + read_u32( + id, + K_AUDIO_DEVICE_PROPERTY_CLOCK_DOMAIN, + K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL, + ) + } + .filter(|&d| d != 0) +} + +/// Name of the system's default output device, which process taps ride on. +pub fn default_output_name() -> Option { + unsafe { + let id = read_u32( + K_AUDIO_OBJECT_SYSTEM_OBJECT, + K_AUDIO_HARDWARE_PROPERTY_DEFAULT_OUTPUT_DEVICE, + K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL, + )?; + device_name(id) + } +} + /// The device's current buffer plus everything it adds past it, for one /// direction. pub fn io_latency(kind: crate::audio::device::DeviceKind, name: &str) -> Option { diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 33b479fe..666cbd70 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -2,6 +2,7 @@ pub mod adaptive_depth; pub mod capture; pub mod clock; pub mod device; +pub mod drift_loop; pub mod effects; pub mod encoders; pub mod engine; diff --git a/src-tauri/src/audio/pipeline/cushion.rs b/src-tauri/src/audio/pipeline/cushion.rs index bb84b2eb..cd50ace8 100644 --- a/src-tauri/src/audio/pipeline/cushion.rs +++ b/src-tauri/src/audio/pipeline/cushion.rs @@ -11,6 +11,11 @@ //! Running dry deepens the queue only by the audio that turns out to have //! been late (`OutageJudge`). A source that goes quiet -- a paused app, a //! muted device -- leaves the depth where it was. +//! +//! A source on the output's own clock (`Cushion::locked`) has no drift to +//! correct, so it is never spliced: any correction there could only chase +//! delivery jitter, and every one is audible. Its queue is set once, when it +//! starts or restarts from silence, and left alone while it plays. use crate::audio::adaptive_depth::{DepthEstimator, OutageJudge}; @@ -33,8 +38,9 @@ const WINDOW_MS: f64 = 250.0; /// Headroom assumed before anything is measured: a typical 512-frame capture /// buffer at 48 kHz. const PRIOR_MS: f64 = 10.0; -/// Kept above the measured dip at all times. -const SAFETY_MS: f64 = 1.0; +/// Kept above the measured dip at all times. A process tap's p99.9 delivery +/// jitter is already ~1.8 ms, so a thinner margin clicks every few seconds. +const SAFETY_MS: f64 = 2.0; /// One splice per this much audio at most, so time is never compressed or /// stretched by more than ~6% while a correction runs. const SPLICE_EVERY_MS: f64 = 21.0; @@ -42,6 +48,10 @@ const SPLICE_EVERY_MS: f64 = 21.0; /// and a deeper target would outgrow the one-second input ring and never /// finish priming. const MAX_DEPTH_MS: f64 = 250.0; +/// On a locked source, a late delivery deepens the queue only if another came +/// within this long. A lone stall costs one dropout; paying for it with +/// latency for the next minute would cost every sound after it. +const LATE_REPEAT_MS: f64 = 60_000.0; pub(super) struct Cushion { need: usize, @@ -55,8 +65,20 @@ pub(super) struct Cushion { cooldown: usize, cooldown_blocks: usize, primed: bool, + /// Has played before: a re-prime refills a stream in progress. + started: bool, + /// Same clock as the output: never splice while playing. + locked: bool, + /// A locked queue starts from the prior; the first measurement corrects + /// it once, the way a startup offset is removed, and never again. + settled: bool, judge: OutageJudge, max_depth: usize, + /// Blocks since the last late delivery was seen, for `LATE_REPEAT_MS`. + since_late: usize, + late_repeat_blocks: usize, + /// Largest recent arrival between two reads: the saw's height. + quantum: usize, } impl Cushion { @@ -74,8 +96,26 @@ impl Cushion { cooldown: 0, cooldown_blocks: (frames(SPLICE_EVERY_MS) / need.max(1)).max(1), primed: false, + started: false, + locked: false, + settled: false, judge: OutageJudge::new(sample_rate), max_depth: frames(MAX_DEPTH_MS), + since_late: usize::MAX, + late_repeat_blocks: frames(LATE_REPEAT_MS) / need.max(1), + quantum: 0, + } + } + + /// For a source on the output's own clock. + pub(super) fn locked(need: usize, sample_rate: u32) -> Self { + Self::new(need, sample_rate).into_locked() + } + + pub(super) fn into_locked(self) -> Self { + Self { + locked: true, + ..self } } @@ -96,35 +136,78 @@ impl Cushion { /// since the last block. A gap's verdict lands here, once the flow after /// it shows whether the missing audio was late or never existed. pub(super) fn arrived(&mut self, frames: usize) { + self.since_late = self.since_late.saturating_add(1); + // The largest single arrival is how far the queue saws between reads; + // it sinks back slowly if deliveries get smaller. Only steady flow + // counts: a catch-up burst after a gap is the gap, not the saw. + if self.primed && self.judge.is_clear() { + let steady = if self.quantum > 0 { + frames.min(2 * self.quantum) + } else { + frames + }; + self.quantum = steady.max(self.quantum - self.quantum / 4096); + } if let Some(late) = self.judge.delivered(frames, self.need) { if late > 0 { - self.depth.underrun(late); + if !self.locked || self.since_late <= self.late_repeat_blocks { + self.depth.underrun(late); + } + self.since_late = 0; } } } /// Queue to start playing from. Priming ends right after a delivery, at - /// the top of the saw, so it keeps the mean target plus the saw's dip: - /// what the queue will have drained by the time the next delivery lands. + /// the top of the saw, so it must last a whole saw down to the floor, plus + /// whatever jitter the measured dip shows beyond an ideal saw (whose dip + /// is half its height). Counting the dip twice would double the jitter + /// allowance along with the saw. + /// + /// Only a locked queue starts this tight: it is never corrected while it + /// plays, so the level it starts at is the level it keeps. A drifting one + /// starts a whole dip higher and lets its splices settle it. fn start_level(&self) -> usize { - self.target() + self.depth.depth().min(self.max_depth) + let dip = self.depth.depth().min(self.max_depth); + if !self.locked || self.quantum == 0 { + return self.target() + dip; + } + let jitter = dip.saturating_sub(self.quantum / 2); + self.floor() + self.quantum.max(dip) + jitter } /// Before the source's first read, and again after it ran dry. `None` - /// while the queue is still filling: play silence for this block. Once it - /// reaches the start level, everything beyond it is backlog nobody has - /// heard, so it can go at once: returns the frames to discard. + /// while the queue is still filling: play silence for this block. + /// + /// Returns the frames to discard. The first start always has them: what + /// piled up before anything played is backlog nobody has heard. + /// + /// After running dry it depends on the clock. A locked source realigns + /// here, under the silence it just played: with no drift, anything beyond + /// the start level is audio that arrived late for time already filled + /// with silence, and dropping exactly that keeps its latency where it was. + /// A drifting source keeps what arrived (it is the stream picking up where + /// it stopped) and lets the ordinary splices trim the excess. pub(super) fn prime(&mut self, queued: usize) -> Option { let start = self.start_level(); if queued < start { return None; } + let excess = if self.started && !self.locked { + 0 + } else { + queued - start + }; + self.started = true; self.primed = true; self.owed = 0; self.sum = 0.0; self.blocks = 0; self.low = usize::MAX; - Some(queued - start) + // Deliveries right after a gap come in catch-up bursts; measuring them + // would read the gap's aftermath as the source's everyday jitter. + self.depth.discard_window(); + Some(excess) } pub(super) fn is_primed(&self) -> bool { @@ -161,16 +244,28 @@ impl Cushion { /// Call once per block with the frames queued before the read. Returns the /// splice to perform now, if one is due and the queue can afford it. pub(super) fn observe(&mut self, queued: usize) -> Adjust { - self.sum += queued as f64; - self.blocks += 1; - self.low = self.low.min(queued); - if self.depth.observe(queued) { + // While a correction runs the queue moves by design, and a window + // holding that move would read it as jitter. + if self.owed != 0 { + self.depth.discard_window(); + self.sum = 0.0; + self.blocks = 0; + self.low = usize::MAX; + } else { + self.sum += queued as f64; + self.blocks += 1; + self.low = self.low.min(queued); + } + if self.owed == 0 && self.depth.observe(queued) { let mean = self.sum / self.blocks as f64; let low = self.low; self.sum = 0.0; self.blocks = 0; self.low = usize::MAX; - if self.owed == 0 { + if self.owed == 0 && !self.locked { + self.owed = self.correction(mean, low); + } else if self.locked && !self.settled && self.depth.is_measured() { + self.settled = true; self.owed = self.correction(mean, low); } } @@ -459,8 +554,10 @@ mod tests { let steady = simulate(64, 480, 0.0, 0.0, 0, 120.0, 10.0); let r = simulate_with_quiet(64, 480, 0.0, 0.0, 0, 120.0, 10.0, &quiet); assert_eq!(r.late_underruns, 0, "underran while the app was playing"); + // One delivery of overshoot on resuming is the onset being played, + // not cut; anything beyond is silence turned into latency. assert!( - r.max_queued <= steady.max_queued + 64, + r.max_queued <= steady.max_queued + 480, "pauses deepened the queue: {} vs {} frames", r.max_queued, steady.max_queued @@ -477,6 +574,223 @@ mod tests { assert_eq!(r.late_underruns, 0); } + /// Audible events a listener would notice, after settling. + #[derive(Debug, Default)] + struct Heard { + splices: usize, + underruns: usize, + mean_queued: f64, + } + + /// Plays `deliveries` (time in seconds, frames) through a cushion read + /// in `block`-frame blocks at 44.1 kHz. + fn replay(block: usize, deliveries: &[(f64, usize)], seconds: f64, settle: f64) -> Heard { + replay_with( + Cushion::new(block, 44_100), + block, + deliveries, + seconds, + settle, + ) + } + + fn replay_with( + mut c: Cushion, + block: usize, + deliveries: &[(f64, usize)], + seconds: f64, + settle: f64, + ) -> Heard { + let rate = 44_100u32; + let period = block as f64 / rate as f64; + let (mut t, mut i, mut queued, mut arrived) = (0.0, 0usize, 0usize, 0usize); + let mut heard = Heard::default(); + let mut samples = 0usize; + while t < seconds { + while i < deliveries.len() && deliveries[i].0 <= t { + queued += deliveries[i].1; + arrived += deliveries[i].1; + i += 1; + } + c.arrived(std::mem::take(&mut arrived)); + let counting = t >= settle; + if !c.is_primed() { + match c.prime(queued) { + Some(excess) => queued -= excess, + None => { + t += period; + continue; + } + } + } + match c.observe(queued) { + Adjust::Drop(n) => { + queued -= n; + heard.splices += counting as usize; + } + Adjust::Insert(n) => { + queued += n; + heard.splices += counting as usize; + } + Adjust::None => {} + } + if queued < block { + heard.underruns += counting as usize; + c.underrun(block - queued); + queued = 0; + } else { + queued -= block; + } + if counting { + heard.mean_queued += queued as f64; + samples += 1; + } + t += period; + } + heard.mean_queued /= samples.max(1) as f64; + heard + } + + /// A Core Audio process tap as it reaches a graph: 512-frame buffers on + /// the output device's clock, handed on by the normalizer thread in + /// 256-frame pieces after a poll of up to 1 ms, and every few seconds a + /// scheduling stall of 5-20 ms after which the backlog arrives at once. + fn tap_through_normalizer(seconds: f64, seed: u32) -> Vec<(f64, usize)> { + let rate = 44_100.0; + let mut rng = seed; + let mut next = move || { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + (rng % 10_000) as f64 / 10_000.0 + }; + let mut out = Vec::new(); + let mut stall_until = 0.0; + let mut k = 0usize; + loop { + let captured = (k + 1) as f64 * 512.0 / rate; + if captured > seconds { + break; + } + if next() < 0.004 { + stall_until = captured + 0.005 + next() * 0.015; + } + for half in 0..2 { + let poll = next() * 0.001; + let at = (captured + poll).max(stall_until) + half as f64 * 0.00002; + out.push((at, 256)); + } + k += 1; + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + out + } + + // The chain in the field report: app audio into a speaker at 64 frames. + // The source runs on the speaker's own clock, so there is nothing to + // correct; every splice or dropout is one the listener hears. + #[test] + fn a_process_tap_on_the_output_clock_plays_clean() { + for seed in [1, 7, 42] { + let deliveries = tap_through_normalizer(180.0, seed); + let h = replay(64, &deliveries, 180.0, 20.0); + assert!( + h.splices + h.underruns <= 2, + "seed {seed}: heard {} splices and {} dropouts in 160 s", + h.splices, + h.underruns + ); + } + } + + /// Recorded from a live Core Audio process tap on Chrome at 44.1 kHz with + /// the HAL's default 512-frame buffer: steady 11.6 ms deliveries, one + /// 30 ms stall caught up by a 1536-frame delivery, a 1 s pause at 12 s and + /// the app stopping at 23 s. + fn recorded_chrome_tap() -> Vec<(f64, usize)> { + include_str!("testdata/chrome_tap_512.csv") + .lines() + .map(|l| { + let (t, n) = l.split_once(',').expect("time,frames"); + (t.parse().expect("time"), n.parse().expect("frames")) + }) + .collect() + } + + // The field report, on real delivery timing: a tap on the speaker's own + // clock must play without a single splice. Dropouts are allowed only + // where the app itself went quiet or stalled. + #[test] + fn recorded_chrome_tap_plays_without_splices_when_locked() { + let trace = recorded_chrome_tap(); + // Counting from 2 s: the one startup correction lands in the first. + for block in [32, 64, 128, 256] { + let h = replay_with(Cushion::locked(block, 44_100), block, &trace, 23.0, 2.0); + assert_eq!(h.splices, 0, "{block}: {h:?}"); + assert!(h.underruns <= 2, "{block}: {h:?}"); + // About one delivery plus its jitter, well under the 28 ms the + // level-chasing cushion held on this trace. + assert!(h.mean_queued < 44.1 * 20.0, "{block}: {h:?}"); + } + } + + /// The same tap with its aggregate asked for 64-frame buffers: one + /// delivery every 1.45 ms instead of every 11.6 ms. + fn recorded_chrome_tap_64() -> Vec<(f64, usize)> { + include_str!("testdata/chrome_tap_64.csv") + .lines() + .map(|l| { + let (t, n) = l.split_once(',').expect("time,frames"); + (t.parse().expect("time"), n.parse().expect("frames")) + }) + .collect() + } + + #[test] + fn a_small_tap_buffer_brings_the_queue_down_to_a_few_ms() { + let trace = recorded_chrome_tap_64(); + let end = trace.last().expect("rows").0; + for block in [32, 64, 128] { + let h = replay_with(Cushion::locked(block, 44_100), block, &trace, end, 2.0); + assert_eq!(h.splices, 0, "{block}: {h:?}"); + // The recording holds one real tap stall (a 320-frame catch-up + // after ~6 ms of nothing): a lone stall costs one dropout rather + // than latency for the rest of the session. + assert!(h.underruns <= 1, "{block}: {h:?}"); + // Two engine blocks of floor, then ~5 ms for the tap's own buffer, + // its jitter and the safety margin. + let bound = 2.0 * block as f64 + 44.1 * 5.0; + assert!(h.mean_queued < bound, "{block}: {h:?}"); + } + } + + #[test] + fn a_locked_source_realigns_only_from_silence() { + let mut c = Cushion::locked(64, SR); + let start = c.prime(20_000).expect("started"); + assert!(start > 0, "startup backlog goes"); + // Playing along: nothing is ever corrected. + for q in [100, 5_000, 20_000, 700] { + assert_eq!(c.observe(q), Adjust::None); + } + // Ran dry, then late audio caught up: it realigns under the silence, + // dropping what arrived for time already played as silence. + c.underrun(64); + let level = c.start_level(); + assert_eq!(c.prime(level + 1_536), Some(1_536)); + } + + #[test] + fn only_the_first_start_discards() { + let mut c = Cushion::new(64, SR); + assert!( + c.prime(20_000).expect("started") > 0, + "startup backlog goes" + ); + c.underrun(64); + assert_eq!(c.prime(20_000), Some(0), "a refill keeps what arrived"); + } + #[test] fn a_splice_waits_until_the_queue_can_afford_it() { let mut c = Cushion::new(64, SR); @@ -485,3 +799,45 @@ mod tests { assert_eq!(c.observe(64 + 64 + 2 * 32), Adjust::Drop(64)); } } + +/// Records a live tap's delivery timing (seconds,frames per line) to +/// `TAP_OUT`, the source of `testdata/chrome_tap_512.csv`. Needs Chrome playing +/// and the System Audio Recording permission; `TAP_FRAMES` sets its buffer. +#[cfg(all(test, target_os = "macos"))] +mod tap_recorder { + use std::io::Write; + use std::time::{Duration, Instant}; + + #[test] + #[ignore = "records a live Chrome tap"] + fn record_chrome_tap() { + let (mut tx, rx) = crate::audio::input_bridge::broadcast_channel(); + let (prod, cons) = rtrb::RingBuffer::::new(2_000_000); + tx.add(prod).unwrap(); + let io_frames = std::env::var("TAP_FRAMES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let cap = + crate::audio::capture::Capture::start_app("com.google.Chrome", 44_100, io_frames, rx) + .expect("tap"); + let start = Instant::now(); + let mut rows = Vec::new(); + let mut seen = 0usize; + while start.elapsed() < Duration::from_secs(30) { + let n = cons.slots(); + if n > seen { + rows.push((start.elapsed().as_secs_f64(), (n - seen) / 2)); + seen = n; + } + std::thread::sleep(Duration::from_micros(100)); + } + drop(cap); + let path = std::env::var("TAP_OUT").unwrap(); + let mut f = std::fs::File::create(path).unwrap(); + for (t, n) in &rows { + writeln!(f, "{t:.6},{n}").unwrap(); + } + println!("rows {} rate {}", rows.len(), seen as f64 / 2.0 / 30.0); + } +} diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index ba189967..cbd0b24e 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -46,6 +46,22 @@ const STALL_THRESHOLD: Duration = Duration::from_millis(150); /// enough that the replayed audio reads as texture rather than an echo. pub(super) const SPLICE_FADE_FRAMES: usize = 32; +/// Fades `len` frames of interleaved `buf` starting at frame `from`, in +/// (`up`) or out, over at most `SPLICE_FADE_FRAMES`. +fn ramp(buf: &mut [f32], channels: usize, from: usize, len: usize, up: bool) { + let len = len.min(SPLICE_FADE_FRAMES); + if len == 0 { + return; + } + for f in 0..len { + let t = (f + 1) as f32 / len as f32; + let g = if up { t } else { 1.0 - t }; + for s in &mut buf[(from + f) * channels..(from + f + 1) * channels] { + *s *= g; + } + } +} + /// A splice reads its span plus a fade on each side. fn splice_samples(channels: usize) -> usize { (MAX_SPLICE_FRAMES + 2 * SPLICE_FADE_FRAMES) * channels @@ -278,6 +294,8 @@ struct SourceState { /// Keeps a live source's queue at its measured headroom. `None` for file /// sources, which are paced by backpressure and must not lose audio. cushion: Option, + /// The captured input this source reads; `None` for a ring-source. + input_id: Option, /// Smoothed frames left queued after each read: this source's latency. queue_avg: f64, /// Frames queued when the previous block finished; what the queue holds @@ -342,6 +360,7 @@ impl SourceState { return; } } + let was_primed = self.cushion.as_ref().is_some_and(Cushion::is_primed); if self.cushion.is_some() && !self.regulate() { // Filling before the first read, or refilling after running dry: // silence that is not an underrun. @@ -354,6 +373,8 @@ impl SourceState { self.publish_queue(); return; } + // Coming out of silence: the first samples ramp up rather than step. + let fade_in = self.cushion.is_some() && !was_primed; let need = self.out_buf.len(); let mut written = self.out_pending.pop_into(&mut self.out_buf[..]); while written < need { @@ -381,6 +402,19 @@ impl SourceState { let n = self.out_pending.pop_into(&mut self.out_buf[written..]); written += n; } + if self.cushion.is_some() { + let w = self.channels; + if fade_in { + ramp(&mut self.out_buf, w, 0, written / w, true); + } + if written < need { + // Running dry mid-block: the audio fades into the silence + // instead of stopping on a step. + let frames = written / w; + let len = frames.min(SPLICE_FADE_FRAMES); + ramp(&mut self.out_buf, w, frames - len, len, false); + } + } const ONE_BITS: u32 = 0x3F80_0000; let vol_bits = self.volume.load(Ordering::Relaxed); if vol_bits != ONE_BITS { @@ -1005,6 +1039,19 @@ impl OutputGraph { /// Attach a publish ring to a fan-out effect node; its `out_buf` is pushed /// there each block for another output's ring-source to read. + /// Marks the sources reading `inputs` as running on this output's clock, + /// so their queues are never spliced while they play. Call before the + /// graph goes live. + pub(super) fn lock_inputs(&mut self, inputs: &HashSet) { + for node in &mut self.nodes { + let DagNode::Source(s) = node else { continue }; + if !s.input_id.as_ref().is_some_and(|id| inputs.contains(id)) { + continue; + } + s.cushion = s.cushion.take().map(Cushion::into_locked); + } + } + pub(super) fn attach_tap(&mut self, node_idx: usize, prod: Producer) { if let Some(DagNode::Effect(e)) = self.nodes.get_mut(node_idx) { e.taps.push(prod); @@ -1427,6 +1474,7 @@ pub(super) fn build_output_graph( .then(|| Cushion::new(input_frames_per_block as usize, input_sr)), queue_avg: 0.0, queued_after: 0, + input_id: Some(id.clone()), last_pop_at: Instant::now(), volume: input_volumes .get(id) @@ -1917,6 +1965,7 @@ fn ring_source( out_buf: vec![0.0; block_frames * channels], input_samples_per_block, cushion: realtime.then(|| Cushion::new(input_frames_per_block as usize, owner_sr)), + input_id: None, queue_avg: 0.0, queued_after: 0, last_pop_at: Instant::now(), @@ -2764,6 +2813,15 @@ pub(super) mod graph_tests { } } + fn worst_delay_between(ticks: &[Tick], from: f64, to: f64) -> usize { + ticks + .iter() + .filter(|k| k.t >= from && k.t < to) + .filter_map(|k| k.real_delay) + .max() + .unwrap_or(0) + } + fn worst_delay(ticks: &[Tick], from: f64) -> usize { ticks .iter() @@ -2787,21 +2845,30 @@ pub(super) mod graph_tests { let gappy = live_capture_with_gaps(64, 480, 125.0, &gaps); let baseline = worst_delay(&steady, 3.0); let with_gaps = worst_delay(&gappy, 3.0); + // A sound resuming after silence may start up to one delivery above + // target (its onset is played, not cut); that is the whole allowance. assert!( - with_gaps <= baseline + 64, + with_gaps <= baseline + 480, "silence added latency: {with_gaps} frames vs {baseline} steady" ); - // Late in the run, after a hundred pauses, it has not crept up either. - assert!(worst_delay(&gappy, 100.0) <= baseline + 64); + // And it never accumulates: after a hundred pauses it is where the + // first few left it. + let early = worst_delay_between(&gappy, 6.0, 20.0); + let late = worst_delay_between(&gappy, 100.0, 125.0); + assert!( + late <= early + 64, + "latency crept across pauses: {early} -> {late}" + ); } #[test] fn readout_does_not_climb_across_pauses() { let gaps: Vec<(f64, f64)> = (0..40).map(|k| (5.0 + k as f64, 5.4 + k as f64)).collect(); let ticks = live_capture_with_gaps(64, 480, 45.0, &gaps); + // Measured once the pauses have begun, so both sides include a resume. let early = ticks .iter() - .filter(|k| (3.0..5.0).contains(&k.t)) + .filter(|k| (5.0..10.0).contains(&k.t)) .map(|k| k.reported_queue) .max() .unwrap(); @@ -2817,6 +2884,190 @@ pub(super) mod graph_tests { ); } + struct TapRun { + /// Captured frames never played: cut out by splices or discards. + skipped: usize, + /// Largest stretch of audio lost in one place. A splice removes at most + /// a few dozen frames under a crossfade; anything bigger is an audible + /// jump. + worst_jump: usize, + /// Output samples of silence once playing, not counting pauses. + dropouts: usize, + worst_delay: usize, + } + + /// A process-tap-like capture: `burst`-frame deliveries, each up to + /// `jitter_ms` late, plus `stalls` where deliveries are held back and then + /// arrive all at once. Every captured frame is numbered, so the output + /// shows exactly which frames were skipped. + fn tap_capture( + block: usize, + burst: usize, + seconds: f64, + settle: f64, + jitter_ms: f64, + stalls: &[(f64, f64)], + ) -> TapRun { + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, true); + let input = producers.get_mut("m").unwrap(); + let out_period = block as f64 / SR as f64; + let in_period = burst as f64 / SR as f64; + let (mut k, mut t_out, mut fed) = (0usize, 0.0, 0usize); + let mut seed: u32 = 0x9e37_79b9; + let mut due = in_period; + let mut out = vec![0.0; block * 2]; + let mut chunk = vec![0.0; burst * 2]; + let mut run = TapRun { + skipped: 0, + worst_jump: 0, + dropouts: 0, + worst_delay: 0, + }; + let mut last_played = 0usize; + let mut prev_raw = 0.0f32; + while t_out < seconds { + if due <= t_out { + for f in 0..burst { + let v = (fed + f + 1) as f32; + chunk[f * 2] = v; + chunk[f * 2 + 1] = v; + } + push_all(input, &chunk); + fed += burst; + k += 1; + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let nominal = (k + 1) as f64 * in_period; + let jitter = jitter_ms / 1000.0 * (seed % 1000) as f64 / 1000.0; + // Inside a stall a delivery waits for the stall to clear. + due = stalls + .iter() + .find(|&&(a, b)| nominal >= a && nominal < b) + .map_or(nominal + jitter, |&(_, b)| b); + continue; + } + built.graph.process_block(&mut out); + if t_out >= settle { + for f in 0..block { + let raw = out[f * 2]; + if raw == 0.0 { + run.dropouts += 1; + prev_raw = 0.0; + continue; + } + // Inside a fade the index is scaled and names no frame; a + // sample counts only when it continues the one before it. + let continues = raw == prev_raw + 1.0; + prev_raw = raw; + if !continues { + continue; + } + let v = raw as usize; + if last_played > 0 && v > last_played + 1 { + run.skipped += v - last_played - 1; + run.worst_jump = run.worst_jump.max(v - last_played - 1); + } + last_played = v; + run.worst_delay = run.worst_delay.max(fed.saturating_sub(v)); + } + } else { + last_played = out[(block - 1) * 2] as usize; + } + t_out += out_period; + } + run + } + + #[test] + fn dropouts_and_restarts_never_click() { + // A 60 Hz sine through a locked live source whose capture pauses for + // 300 ms and, separately, stalls for 20 ms and catches up. Wherever + // audio stops or starts, it ramps: no sample-to-sample step beyond a + // 32-frame fade of the full amplitude. + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = build_with_block(Some("s"), SR, 64, &valid, SR, true); + built.graph.lock_inputs(&HashSet::from(["m".to_string()])); + let input = producers.get_mut("m").unwrap(); + let sine = |f: usize| (f as f32 * std::f32::consts::TAU * 60.0 / SR as f32).sin() * 0.9; + let burst = 64usize; + let (mut fed, mut t_out, mut due) = (0usize, 0.0f64, 0.0f64); + let period_in = burst as f64 / SR as f64; + let mut out = vec![0.0; 64 * 2]; + let mut played: Vec = Vec::new(); + let mut chunk = vec![0.0; burst * 2]; + while t_out < 4.0 { + if due <= t_out { + let paused = (1.5..1.8).contains(&due); + if !paused { + for f in 0..burst { + let v = sine(fed + f); + chunk[f * 2] = v; + chunk[f * 2 + 1] = v; + } + push_all(input, &chunk); + } + fed += burst; + let next = due + period_in; + // A 20 ms stall at 3 s: deliveries wait, then arrive at once. + due = if (3.0..3.02).contains(&next) { + 3.02 + } else { + next + }; + continue; + } + built.graph.process_block(&mut out); + played.extend(out.iter().step_by(2)); + t_out += 64.0 / SR as f64; + } + let worst = played + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max); + assert!( + worst < 0.9 / SPLICE_FADE_FRAMES as f32 + 0.01, + "a {worst} step" + ); + } + + #[test] + fn a_jittery_tap_plays_every_frame() { + for block in [32, 64, 256] { + let r = tap_capture(block, 512, 60.0, 10.0, 3.0, &[]); + assert_eq!(r.dropouts, 0, "{block}: dropouts"); + // Splices may trim drift, never whole stretches of audio. + assert!( + r.skipped < 50 * 64, + "{block}: {} frames skipped in 50 s", + r.skipped + ); + } + } + + #[test] + fn a_stalled_tap_resumes_without_skipping_audio() { + // Every 5 s the tap holds its deliveries for 25 ms, then catches up. + let stalls: Vec<(f64, f64)> = (0..12) + .map(|k| (5.0 + k as f64 * 5.0, 5.025 + k as f64 * 5.0)) + .collect(); + let r = tap_capture(32, 512, 65.0, 3.0, 1.0, &stalls); + // What came late is played, not thrown away: no stretch of audio + // vanishes at once, only crossfaded splices trim the excess later. + let splice = super::MAX_SPLICE_FRAMES + 2 * SPLICE_FADE_FRAMES; + assert!( + r.worst_jump <= splice, + "{} frames vanished at once", + r.worst_jump + ); + assert!( + r.worst_delay < 4_800, + "delay grew to {} frames", + r.worst_delay + ); + } + #[test] fn reported_queue_tracks_the_real_delay() { let ticks = live_capture_with_gaps(64, 480, 55.0, &[(10.0, 15.0), (25.0, 30.0)]); diff --git a/src-tauri/src/audio/pipeline/input/linux.rs b/src-tauri/src/audio/pipeline/input/linux.rs index 93a93d97..3ff1c382 100644 --- a/src-tauri/src/audio/pipeline/input/linux.rs +++ b/src-tauri/src/audio/pipeline/input/linux.rs @@ -53,12 +53,19 @@ pub(in crate::audio::pipeline) fn configure_io( None } +/// Clock sharing is not detected here yet, so every input is treated as +/// drifting against the speaker. +pub(in crate::audio::pipeline) fn same_clock(_input: &InputSpec, _speaker_device: &str) -> bool { + false +} + pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, bridge: BroadcastRx, paused: Option>, meter: Option, + _io_frames: u32, app: &AppHandle, ) -> AppResult { match resolved { diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs index c9717cc1..45535a85 100644 --- a/src-tauri/src/audio/pipeline/input/macos.rs +++ b/src-tauri/src/audio/pipeline/input/macos.rs @@ -95,12 +95,34 @@ pub(in crate::audio::pipeline) fn configure_io( }) } +/// Whether `input` runs off the same clock as the speaker `speaker_device`. +/// A process tap is driven by the default output device; a microphone shares +/// a clock with the speaker when it is the same device or reports the same +/// non-zero clock domain. +pub(in crate::audio::pipeline) fn same_clock(input: &InputSpec, speaker_device: &str) -> bool { + match input { + InputSpec::AppAudio { .. } | InputSpec::SystemAudio { .. } => { + crate::audio::capture::uses_taps() + && macos_hal::default_output_name().as_deref() == Some(speaker_device) + } + InputSpec::Microphone { device_id } => { + device_id == speaker_device || { + let mic = macos_hal::clock_domain(DeviceKind::Input, device_id); + mic.is_some() && mic == macos_hal::clock_domain(DeviceKind::Output, speaker_device) + } + } + _ => false, + } +} + +/// `io_frames` is the capture buffer to ask for, in the source's own frames. pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, bridge: BroadcastRx, paused: Option>, meter: Option, + io_frames: u32, app: &AppHandle, ) -> AppResult { match resolved { @@ -144,6 +166,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( let capture = crate::audio::capture::Capture::start_system( exclude_current_app, sample_rate, + io_frames, bridge, )?; check_capture_format(&capture)?; @@ -153,8 +176,12 @@ pub(in crate::audio::pipeline) fn start_input_stream( sample_rate, bundle_id, } => { - let capture = - crate::audio::capture::Capture::start_app(&bundle_id, sample_rate, bridge)?; + let capture = crate::audio::capture::Capture::start_app( + &bundle_id, + sample_rate, + io_frames, + bridge, + )?; check_capture_format(&capture)?; Ok(InputHandle::Capture(capture)) } diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index d6703d22..341228e1 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -18,6 +18,7 @@ use crate::error::{AppError, AppResult}; use super::dag::{ring_capacity_frames, RESAMPLE_CHUNK}; use super::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader}; +use super::output::device_block; #[cfg(target_os = "macos")] mod macos; @@ -33,7 +34,7 @@ mod windows; use windows as platform; use platform::start_input_stream as start_native_input_stream; -pub(super) use platform::{configure_io, resolve_input}; +pub(super) use platform::{configure_io, resolve_input, same_clock}; /// RAII handle held only for its `Drop` -- stops the cpal stream, tears /// down the capture, or signals + joins the file reader thread. @@ -233,13 +234,16 @@ pub(super) fn start_input_stream( target_sample_rate: u32, paused: Option>, meter: Option, + block_frames: usize, app: &AppHandle, ) -> AppResult { + // One capture buffer per engine block: deliveries no burstier than reads. + let io_frames = device_block(block_frames, target_sample_rate, resolved.sample_rate()); // Audio files are decoded offline and paced by downstream consumer backpressure. // They must not be run through the capture normalizer thread (which drops frames // on overflow and breaks backpressure). DAG nodes resample file audio directly. if matches!(resolved, ResolvedInput::AudioFile { .. }) { - return start_native_input_stream(node_id, resolved, bridge, paused, meter, app); + return start_native_input_stream(node_id, resolved, bridge, paused, meter, io_frames, app); } // A device already at the pipeline rate needs no normalizing: its callback // feeds the graphs' rings directly, with no thread hop in between. Only a @@ -248,7 +252,7 @@ pub(super) fn start_input_stream( if matches!(resolved, ResolvedInput::Cpal { .. }) && resolved.sample_rate() == target_sample_rate { - return start_native_input_stream(node_id, resolved, bridge, paused, meter, app); + return start_native_input_stream(node_id, resolved, bridge, paused, meter, io_frames, app); } let sample_rate = resolved.sample_rate(); let channels = resolved.native_channels() as usize; @@ -256,7 +260,7 @@ pub(super) fn start_input_stream( RingBuffer::::new(ring_capacity_frames(sample_rate) * channels.max(1)); let (mut raw_tx, raw_rx) = broadcast_channel(); raw_tx.add(raw_producer)?; - let input = start_native_input_stream(node_id, resolved, raw_rx, paused, None, app)?; + let input = start_native_input_stream(node_id, resolved, raw_rx, paused, None, io_frames, app)?; #[cfg(any(target_os = "macos", target_os = "linux"))] let rate_probe = input.rate_probe(); let stop = Arc::new(AtomicBool::new(false)); @@ -265,6 +269,13 @@ pub(super) fn start_input_stream( let join = thread::Builder::new() .name(format!("normalize:{label}")) .spawn(move || { + // Sits between a capture callback and the speaker callback, so its + // wake-ups are audio latency: an ordinary thread adds scheduling jitter. + let _rt = super::worker::RtThread::promote( + "normalize", + RESAMPLE_CHUNK as u32, + target_sample_rate, + ); let mut bridge = bridge; let mut input_buf = vec![0.0; RESAMPLE_CHUNK * channels]; #[cfg(any(target_os = "macos", target_os = "linux"))] diff --git a/src-tauri/src/audio/pipeline/input/windows.rs b/src-tauri/src/audio/pipeline/input/windows.rs index f9c41828..e78e84f7 100644 --- a/src-tauri/src/audio/pipeline/input/windows.rs +++ b/src-tauri/src/audio/pipeline/input/windows.rs @@ -60,12 +60,19 @@ pub(in crate::audio::pipeline) fn configure_io( None } +/// Clock sharing is not detected here yet, so every input is treated as +/// drifting against the speaker. +pub(in crate::audio::pipeline) fn same_clock(_input: &InputSpec, _speaker_device: &str) -> bool { + false +} + pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, bridge: BroadcastRx, paused: Option>, meter: Option, + _io_frames: u32, app: &AppHandle, ) -> AppResult { match resolved { diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index 106a9536..ad786b36 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -454,6 +454,11 @@ impl ActivePipeline { } } + // Before any output is dismantled: a tick landing mid-teardown would + // count a speaker already gone against the old snapshot and report an + // orphan stream and a stalled output that are neither. + self.xrun_thread = None; + let mut all_old: Vec = Vec::new(); all_old.extend(self.speakers.keys().cloned()); all_old.extend(self.recorders.keys().cloned()); @@ -509,7 +514,6 @@ impl ActivePipeline { self.meter_thread = None; self.source_stats.clear(); self.output_stats.clear(); - self.xrun_thread = None; // Inputs whose spec changed (or vanished) drop here. Consumers // listed them in `OutputSig.inputs`, so spec change => sig change @@ -1071,6 +1075,7 @@ impl ActivePipeline { pipeline_sr, paused.clone(), None, + graph.buffer_frames as usize, &app, )?; self.inputs.insert( @@ -1134,6 +1139,7 @@ impl ActivePipeline { pipeline_sr, paused.clone(), Some(meter), + graph.buffer_frames as usize, &app, )?; self.inputs.insert( @@ -1178,6 +1184,18 @@ impl ActivePipeline { ResolvedOutput::Speaker(spec) => { let out_channels = spec.out_channels; og.set_out_channels(out_channels); + if let OutputSpec::Speaker { device_id } = &out.spec { + let locked: HashSet = graph + .inputs + .iter() + .filter(|i| input::same_clock(&i.spec, device_id)) + .map(|i| i.id.clone()) + .collect(); + if !locked.is_empty() { + info!(output = %out.id, ?locked, "inputs on the speaker's clock"); + } + og.lock_inputs(&locked); + } if let Some(state) = self.speakers.get_mut(&out.id) { if state.sample_rate == spec.sample_rate { state.ctrl.send_graph(og)?; diff --git a/src-tauri/src/audio/streams/mod.rs b/src-tauri/src/audio/streams/mod.rs index 473ac057..a25fe514 100644 --- a/src-tauri/src/audio/streams/mod.rs +++ b/src-tauri/src/audio/streams/mod.rs @@ -16,10 +16,11 @@ mod cpal_stream; pub use cpal_stream::{build_input_stream, build_output_stream}; /// Bulk push via one `write_chunk` reservation -- one atomic-CAS per block -/// instead of one per sample. On overflow only the head fits and the rest is -/// dropped (consumer is behind anyway; staying RT-safe beats blocking). -/// Returns the number of samples actually written, so callers tracking ring -/// fill level stay in step with the ring after a dropped tail. +/// instead of one per sample. A block that does not fit is dropped whole +/// (the consumer is behind anyway; staying RT-safe beats blocking): a partial +/// write would split a frame, and every later frame would land a channel off. +/// Returns the number of samples written, so callers tracking fill level stay +/// in step with the ring. /// /// `counter` names which ring this call site is feeding, so overruns can be /// told apart by call site instead of summing into one global total. @@ -28,23 +29,70 @@ pub fn bulk_push_counted(prod: &mut Producer, samples: &[f32], counter: &At if want == 0 { return 0; } - let avail = prod.slots(); - // Even count only: a partial (odd) write on overflow would shift every - // later frame's L/R by one sample, permanently desyncing the stereo pair. - let to_write = want.min(avail) & !1; - health::bump(counter, (want - to_write) as u64); - if to_write == 0 { + if prod.slots() < want { + health::bump(counter, want as u64); return 0; } - if let Ok(mut chunk) = prod.write_chunk(to_write) { + if let Ok(mut chunk) = prod.write_chunk(want) { let (first, second) = chunk.as_mut_slices(); let n1 = first.len(); first.copy_from_slice(&samples[..n1]); - let n2 = second.len(); - if n2 > 0 { - second.copy_from_slice(&samples[n1..n1 + n2]); - } + second.copy_from_slice(&samples[n1..]); + chunk.commit_all(); + } + want +} + +#[cfg(test)] +mod tests { + use super::*; + use rtrb::RingBuffer; + use std::sync::atomic::Ordering; + + fn drain(cons: &mut rtrb::Consumer) -> Vec { + let n = cons.slots(); + let chunk = cons.read_chunk(n).unwrap(); + let (a, b) = chunk.as_slices(); + let out: Vec = a.iter().chain(b).copied().collect(); chunk.commit_all(); + out + } + + // A mono source hands over odd sample counts all the time (a resampler + // emitting 235 then 236 frames, 441-sample packets at 44.1 kHz). With room + // in the ring every sample must land. + #[test] + fn odd_blocks_with_room_are_written_whole() { + let (mut prod, mut cons) = RingBuffer::::new(10_000); + let counter = AtomicU64::new(0); + let mut fed = Vec::new(); + for (k, n) in [235usize, 236, 441, 1, 3, 70].iter().enumerate() { + let block: Vec = (0..*n).map(|i| (k * 1000 + i) as f32).collect(); + assert_eq!(bulk_push_counted(&mut prod, &block, &counter), *n); + fed.extend(block); + } + assert_eq!(counter.load(Ordering::Relaxed), 0, "nothing dropped"); + assert_eq!(drain(&mut cons), fed); + } + + // On overflow a partial write would split a frame of any width and shift + // every later frame's channels; the block is dropped whole instead. + #[test] + fn overflow_drops_the_whole_block_keeping_frames_aligned() { + for width in [1usize, 2, 3, 6] { + let (mut prod, mut cons) = RingBuffer::::new(10 * width); + let counter = AtomicU64::new(0); + let first: Vec = (0..8 * width).map(|i| i as f32).collect(); + assert_eq!(bulk_push_counted(&mut prod, &first, &counter), first.len()); + let second = vec![-1.0; 4 * width]; + assert_eq!( + bulk_push_counted(&mut prod, &second, &counter), + 0, + "{width}ch" + ); + assert_eq!(counter.load(Ordering::Relaxed), (4 * width) as u64); + let got = drain(&mut cons); + assert_eq!(got, first, "{width}ch: ring holds whole frames only"); + } } - to_write } From a937548650357bc7b85e24629136c5d2d9d862ce Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:03:18 +0300 Subject: [PATCH 06/51] feat(audio): absorb clock drift with a steered resampler instead of splices --- docs/CONCEPT.md | 2 +- src-tauri/src/audio/drift_loop.rs | 53 ++- src-tauri/src/audio/input_bridge.rs | 48 +++ src-tauri/src/audio/pipeline/asrc.rs | 110 +++++ src-tauri/src/audio/pipeline/cushion.rs | 540 +++++++++++------------- src-tauri/src/audio/pipeline/dag.rs | 470 ++++++++++++++------- src-tauri/src/audio/pipeline/mod.rs | 20 +- src-tauri/src/audio/resample.rs | 46 +- 8 files changed, 822 insertions(+), 467 deletions(-) create mode 100644 src-tauri/src/audio/pipeline/asrc.rs diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index ed614aae..74a367ea 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -42,7 +42,7 @@ Without these, one chunk takes ~16 ms and the worker stalls. - Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. -- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. Excess is spliced out and shortfall stretched in, with crossfades; an underrun re-primes once instead of clicking per block. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`); a source that goes quiet (paused app, DTX sender) never does. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) is locked: never spliced while it plays, realigned only from silence, with fades at every stop and start. Capture buffers (mic, tap aggregate) are requested at the engine block. +- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block. - An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs at its own working block, declares it as latency for PDC, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/src/audio/drift_loop.rs b/src-tauri/src/audio/drift_loop.rs index bdb150b8..65817031 100644 --- a/src-tauri/src/audio/drift_loop.rs +++ b/src-tauri/src/audio/drift_loop.rs @@ -30,10 +30,10 @@ const LOWPASS_FACTOR: f64 = 20.0; /// Largest correction: 1000 ppm, about 1.7 cents, beyond any real crystal. const MAX_CORRECTION: f64 = 1.0e-3; -/// How fast the producer's timing estimate follows its write times. Far -/// below the delivery rate, so single late deliveries average out; the lag it -/// leaves under drift is a constant offset the loop's integrator absorbs. -const ARRIVAL_SMOOTHING_S: f64 = 1.0; +/// How fast the producer's timing estimate follows its write times: over +/// thousands of deliveries, so single late ones average out. The lag it leaves +/// under drift is a constant offset the loop's integrator absorbs. +const ARRIVAL_SMOOTHING_S: f64 = 4.0; /// The producer's delivery timing, smoothed: the time its frame `n` was due, /// as `n / rate + offset`, with `offset` lowpassed across writes. From it the @@ -82,13 +82,16 @@ impl ArrivalClock { self.last_at = at; } - /// Frames the producer has made by `now` but not yet written, never - /// negative and never more than `cap`. + /// Frames the producer has made by `now` but not yet written, against its + /// smoothed schedule. Negative when a delivery landed ahead of that + /// schedule, as half of them do; clamping those to zero would put the + /// delivery jitter straight back. Capped at `cap` so a stalled producer + /// does not read as an ever-fuller queue. pub fn pending(&self, now: f64, cap: f64) -> f64 { if !self.primed { return 0.0; } - ((now - self.offset) * self.rate - self.last_frames as f64).clamp(0.0, cap) + ((now - self.offset) * self.rate - self.last_frames as f64).min(cap) } } @@ -157,10 +160,12 @@ impl DriftLoop { if proposed.abs() < MAX_CORRECTION { self.integral += ki * x * self.dt; } - self.correction = ((kp * x + self.integral) / self.rate).clamp(-MAX_CORRECTION, MAX_CORRECTION); + self.correction = + ((kp * x + self.integral) / self.rate).clamp(-MAX_CORRECTION, MAX_CORRECTION); self.correction } + #[cfg(test)] pub fn correction(&self) -> f64 { self.correction } @@ -258,7 +263,11 @@ mod tests { "{ppm} ppm: settled at {:.1} ppm", r.tail_u * 1e6 ); - assert!(r.tail_error < 8.0, "{ppm} ppm: error {:.1} frames", r.tail_error); + assert!( + r.tail_error < 8.0, + "{ppm} ppm: error {:.1} frames", + r.tail_error + ); } } @@ -267,21 +276,39 @@ mod tests { // 512-frame deliveries saw the queue by 512 frames at ~94 Hz; after the // lowpass the ratio must hold within a fraction of a ppm. let r = run(50.0, 512, 64, 0.0, 240.0, 20.0); - assert!(r.tail_u_ripple < 0.5e-6, "ripple {:.3} ppm", r.tail_u_ripple * 1e6); + assert!( + r.tail_u_ripple < 0.5e-6, + "ripple {:.3} ppm", + r.tail_u_ripple * 1e6 + ); } #[test] fn delivery_jitter_does_not_modulate_the_ratio() { // A normalizer thread polling every 1 ms: each delivery up to 1 ms late. + // 10 ppm peak to peak is under 0.02 cents, far below hearing, and it + // moves no faster than the error lowpass lets it. let r = run_jittered(50.0, 256, 64, 0.0, 240.0, 20.0, 0.001); - assert!(r.tail_u_ripple < 1.0e-6, "ripple {:.3} ppm", r.tail_u_ripple * 1e6); - assert!((r.tail_u - 50e-6).abs() < 3e-6, "settled at {:.1} ppm", r.tail_u * 1e6); + assert!( + r.tail_u_ripple < 10.0e-6, + "ripple {:.3} ppm", + r.tail_u_ripple * 1e6 + ); + assert!( + (r.tail_u - 50e-6).abs() < 3e-6, + "settled at {:.1} ppm", + r.tail_u * 1e6 + ); } #[test] fn an_initial_offset_clears_while_settling() { let r = run(0.0, 256, 64, 800.0, 30.0, 5.0); - assert!(r.tail_error < 40.0, "error {:.1} frames after 25 s", r.tail_error); + assert!( + r.tail_error < 40.0, + "error {:.1} frames after 25 s", + r.tail_error + ); } #[test] diff --git a/src-tauri/src/audio/input_bridge.rs b/src-tauri/src/audio/input_bridge.rs index 178af3b3..75f90c44 100644 --- a/src-tauri/src/audio/input_bridge.rs +++ b/src-tauri/src/audio/input_bridge.rs @@ -73,6 +73,43 @@ enum BroadcastCmd { }, } +/// Seconds since a process-wide epoch. Producers and consumers of audio +/// stamp their timing on this one clock. +pub fn now_secs() -> f64 { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + EPOCH + .get_or_init(std::time::Instant::now) + .elapsed() + .as_secs_f64() +} + +/// When a producer last wrote and how much it has written in total. A +/// consumer reads the queue as a smooth function of time from it (see +/// `drift_loop::ArrivalClock`): the level alone saws with every delivery. +#[derive(Default)] +pub struct WriteClock { + samples: AtomicU64, + /// `now_secs()` of the last write, as f64 bits. + at: AtomicU64, +} + +impl WriteClock { + /// RT-safe: two relaxed stores. + #[inline] + pub fn record(&self, samples: usize, at: f64) { + self.at.store(at.to_bits(), Ordering::Relaxed); + self.samples.fetch_add(samples as u64, Ordering::Release); + } + + /// Total samples written and when the last of them was. The two are read + /// separately; a torn pair is one block's glitch the consumer lowpasses. + #[inline] + pub fn read(&self) -> (u64, f64) { + let samples = self.samples.load(Ordering::Acquire); + (samples, f64::from_bits(self.at.load(Ordering::Relaxed))) + } +} + /// Main-thread side. Tracks slot allocations and pushes Add/Remove /// commands to the RT callback. pub struct BroadcastTx { @@ -83,6 +120,7 @@ pub struct BroadcastTx { /// RT returns removed producers here so they drop on main, not on the /// audio callback thread. discarded_rx: Consumer, + clock: Arc, } /// RT-thread side. Owns the Producer slot vec; lives inside the input @@ -91,9 +129,11 @@ pub struct BroadcastRx { cmds: Consumer, slots: Vec>, discarded_tx: Producer, + clock: Arc, } pub fn broadcast_channel() -> (BroadcastTx, BroadcastRx) { + let clock = Arc::new(WriteClock::default()); let (cmd_tx, cmd_rx) = RingBuffer::::new(CMD_QUEUE_CAPACITY); let (disc_tx, disc_rx) = RingBuffer::::new(CMD_QUEUE_CAPACITY); let mut slots = Vec::with_capacity(BRIDGE_CAPACITY); @@ -107,16 +147,23 @@ pub fn broadcast_channel() -> (BroadcastTx, BroadcastRx) { cmds: cmd_tx, used, discarded_rx: disc_rx, + clock: clock.clone(), }, BroadcastRx { cmds: cmd_rx, slots, discarded_tx: disc_tx, + clock, }, ) } impl BroadcastTx { + /// The input's write timing, shared with every graph that reads it. + pub fn write_clock(&self) -> Arc { + self.clock.clone() + } + /// Register `producer` for broadcast. Returns the slot index used to /// remove it later, plus that slot's capture-side counters -- the /// caller hands these to the matching SourceMeta so the tick thread can @@ -201,6 +248,7 @@ impl BroadcastRx { /// reserves via one CAS per slot and never blocks. #[inline] pub fn broadcast(&mut self, samples: &[f32]) { + self.clock.record(samples.len(), now_secs()); for slot in self.slots.iter_mut() { if let Some((p, stats)) = slot { let written = bulk_push_counted(p, samples, &health::CAPTURE_RING_OVERRUN_SAMPLES); diff --git a/src-tauri/src/audio/pipeline/asrc.rs b/src-tauri/src/audio/pipeline/asrc.rs new file mode 100644 index 00000000..0c063dcb --- /dev/null +++ b/src-tauri/src/audio/pipeline/asrc.rs @@ -0,0 +1,110 @@ +//! The asynchronous resampler a live source reads through when its producer +//! runs on a clock other than the graph's: a fixed-output sinc resampler +//! whose ratio `DriftLoop` steers, fed the queue as a smooth function of time +//! (`ArrivalClock` over the producer's `WriteClock`). Drift is absorbed by a +//! ratio that moves a few ppm, never by cutting or repeating audio. + +use std::sync::Arc; + +use crate::audio::drift_loop::{ArrivalClock, DriftLoop}; +use crate::audio::input_bridge::WriteClock; +use crate::audio::resample::MultiResamplerOut; +use crate::error::AppResult; + +pub(super) struct Asrc { + resampler: MultiResamplerOut, + /// Output over input frames at nominal rates. + base_ratio: f64, + last_ratio: f64, + drift: DriftLoop, + arrival: ArrivalClock, + clock: Option>, + channels: usize, + /// Interleaved input for one call, sized for the largest. + pub(super) input: Vec, + output: Vec, +} + +impl Asrc { + /// `frames` output frames per call at `out_rate` from a producer at + /// `in_rate`. + pub(super) fn new( + in_rate: u32, + out_rate: u32, + frames: usize, + channels: usize, + ) -> AppResult { + let resampler = MultiResamplerOut::for_drift(in_rate, out_rate, frames, channels)?; + let input = Vec::with_capacity(resampler.input_frames_max() * channels); + // The loop counts the queue in the producer's frames: one block of + // output takes `frames * in / out` of them. + let in_per_block = (frames as u64 * in_rate as u64 / out_rate.max(1) as u64).max(1); + let base_ratio = out_rate as f64 / in_rate.max(1) as f64; + Ok(Self { + resampler, + base_ratio, + last_ratio: base_ratio, + drift: DriftLoop::new(in_rate, in_per_block as usize), + arrival: ArrivalClock::new(in_rate), + clock: None, + channels, + input, + output: Vec::with_capacity(frames * channels), + }) + } + + pub(super) fn set_clock(&mut self, clock: Arc) { + self.clock = Some(clock); + } + + /// Frames of input the next block will read. + pub(super) fn need(&self) -> usize { + self.resampler.input_frames_next() + } + + /// Frames the filter itself delays by, in the producer's rate like the + /// queue in front of it. + pub(super) fn delay_frames(&self) -> usize { + (self.resampler.delay_frames() as f64 / self.base_ratio).round() as usize + } + + /// After the queue was moved by something other than the loop (realigned + /// under silence after a gap, or the startup correction): the loop starts + /// settling again and the producer's timing is learned afresh. The drift + /// already learned is kept; the clocks have not changed. + pub(super) fn restart(&mut self) { + self.drift.restart(); + self.arrival.reset(); + } + + /// Once per block before reading: steer the ratio so the queue settles + /// where the cushion wants it. Seen through the producer's clock, the + /// queue plus what the producer has made but not yet written is the queue + /// as it stands right after a delivery, the top of its saw: that settles + /// on `start`, which also bounds the unwritten part while the producer + /// stalls. Without a clock the loop sees the sawing queue itself, whose + /// mean settles on `target`. + pub(super) fn steer(&mut self, queued: usize, target: usize, start: usize, now: f64) { + let error = match &self.clock { + Some(clock) => { + let (samples, at) = clock.read(); + self.arrival.observe(samples / self.channels as u64, at); + queued as f64 + self.arrival.pending(now, start as f64) - start as f64 + } + None => queued as f64 - target as f64, + }; + let u = self.drift.update(error); + let ratio = self.base_ratio / (1.0 + u); + if (ratio - self.last_ratio).abs() > 1e-12 { + self.resampler.set_ratio(ratio); + self.last_ratio = ratio; + } + } + + /// Resample `input` (exactly `need()` frames) into one output block. + pub(super) fn process(&mut self) -> AppResult<&[f32]> { + self.output.clear(); + self.resampler.process(&self.input, &mut self.output)?; + Ok(&self.output) + } +} diff --git a/src-tauri/src/audio/pipeline/cushion.rs b/src-tauri/src/audio/pipeline/cushion.rs index cd50ace8..dba4669c 100644 --- a/src-tauri/src/audio/pipeline/cushion.rs +++ b/src-tauri/src/audio/pipeline/cushion.rs @@ -1,21 +1,23 @@ -//! How much captured audio a live source keeps queued ahead of the graph. +//! How much live audio a source keeps queued ahead of the graph. //! //! A capture device hands audio over in bursts on its own clock while the //! graph takes one block per output callback, so the queue saws up and down. -//! `DepthEstimator` sizes the headroom from that saw; `Cushion` holds the -//! queue's mean there by splicing a few frames out (capture clock faster, or -//! startup backlog) or stretching a few in (capture clock slower), sparsely -//! and with crossfades, so the correction is inaudible and never adds latency -//! of its own. +//! `DepthEstimator` sizes the headroom from that saw and the delivery jitter +//! on top of it. `Cushion` decides when the source plays: it holds silence +//! until the queue reaches its start level, and after running dry it realigns +//! under the silence it is already playing, never while audio is heard. //! -//! Running dry deepens the queue only by the audio that turns out to have -//! been late (`OutageJudge`). A source that goes quiet -- a paused app, a -//! muted device -- leaves the depth where it was. +//! It never corrects the queue while the source plays, with one exception: +//! the depth guessed before anything was measured is corrected once, by a +//! splice, as soon as it is measured. Drift between clocks is the resampler's +//! to absorb (`asrc`), and it holds still until that correction is done, so +//! the two never act on the queue at once. A source on the output's own clock +//! reads without one. //! -//! A source on the output's own clock (`Cushion::locked`) has no drift to -//! correct, so it is never spliced: any correction there could only chase -//! delivery jitter, and every one is audible. Its queue is set once, when it -//! starts or restarts from silence, and left alone while it plays. +//! Running dry deepens the queue only by audio that turns out to have been +//! late (`OutageJudge`), and only when it happens again within a minute: a +//! source that goes quiet leaves the depth where it was, and a lone stall +//! costs one dropout rather than latency for everything after it. use crate::audio::adaptive_depth::{DepthEstimator, OutageJudge}; @@ -25,8 +27,6 @@ pub(super) enum Adjust { None, /// Splice this many frames out of the queue. Drop(usize), - /// Stretch the queue by repeating this many frames. - Insert(usize), } /// Largest single splice. Bigger corrections are spread over several. @@ -41,16 +41,13 @@ const PRIOR_MS: f64 = 10.0; /// Kept above the measured dip at all times. A process tap's p99.9 delivery /// jitter is already ~1.8 ms, so a thinner margin clicks every few seconds. const SAFETY_MS: f64 = 2.0; -/// One splice per this much audio at most, so time is never compressed or -/// stretched by more than ~6% while a correction runs. +/// One splice per this much audio at most while the startup correction runs. const SPLICE_EVERY_MS: f64 = 21.0; /// Ceiling on the measured headroom. Jitter beyond this is a broken capture, /// and a deeper target would outgrow the one-second input ring and never /// finish priming. const MAX_DEPTH_MS: f64 = 250.0; -/// On a locked source, a late delivery deepens the queue only if another came -/// within this long. A lone stall costs one dropout; paying for it with -/// latency for the next minute would cost every sound after it. +/// A late delivery deepens the queue only if another came within this long. const LATE_REPEAT_MS: f64 = 60_000.0; pub(super) struct Cushion { @@ -60,17 +57,13 @@ pub(super) struct Cushion { sum: f64, blocks: usize, low: usize, - /// Correction still owed: negative drops, positive inserts. - owed: i64, + /// Startup correction still owed, as frames to drop. + owed: usize, cooldown: usize, cooldown_blocks: usize, primed: bool, - /// Has played before: a re-prime refills a stream in progress. - started: bool, - /// Same clock as the output: never splice while playing. - locked: bool, - /// A locked queue starts from the prior; the first measurement corrects - /// it once, the way a startup offset is removed, and never again. + /// The queue starts from the prior; the first measurement corrects it + /// once, the way a startup offset is removed, and never again. settled: bool, judge: OutageJudge, max_depth: usize, @@ -96,8 +89,6 @@ impl Cushion { cooldown: 0, cooldown_blocks: (frames(SPLICE_EVERY_MS) / need.max(1)).max(1), primed: false, - started: false, - locked: false, settled: false, judge: OutageJudge::new(sample_rate), max_depth: frames(MAX_DEPTH_MS), @@ -107,18 +98,6 @@ impl Cushion { } } - /// For a source on the output's own clock. - pub(super) fn locked(need: usize, sample_rate: u32) -> Self { - Self::new(need, sample_rate).into_locked() - } - - pub(super) fn into_locked(self) -> Self { - Self { - locked: true, - ..self - } - } - /// Lowest the queue may sit just before a read. One block is the read; /// a second covers phase: the queue is only sampled at reads, so as the /// two clocks slide past each other its sampled low jumps by up to a @@ -150,7 +129,7 @@ impl Cushion { } if let Some(late) = self.judge.delivered(frames, self.need) { if late > 0 { - if !self.locked || self.since_late <= self.late_repeat_blocks { + if self.since_late <= self.late_repeat_blocks { self.depth.underrun(late); } self.since_late = 0; @@ -163,13 +142,9 @@ impl Cushion { /// whatever jitter the measured dip shows beyond an ideal saw (whose dip /// is half its height). Counting the dip twice would double the jitter /// allowance along with the saw. - /// - /// Only a locked queue starts this tight: it is never corrected while it - /// plays, so the level it starts at is the level it keeps. A drifting one - /// starts a whole dip higher and lets its splices settle it. - fn start_level(&self) -> usize { + pub(super) fn start_level(&self) -> usize { let dip = self.depth.depth().min(self.max_depth); - if !self.locked || self.quantum == 0 { + if self.quantum == 0 { return self.target() + dip; } let jitter = dip.saturating_sub(self.quantum / 2); @@ -179,26 +154,15 @@ impl Cushion { /// Before the source's first read, and again after it ran dry. `None` /// while the queue is still filling: play silence for this block. /// - /// Returns the frames to discard. The first start always has them: what - /// piled up before anything played is backlog nobody has heard. - /// - /// After running dry it depends on the clock. A locked source realigns - /// here, under the silence it just played: with no drift, anything beyond - /// the start level is audio that arrived late for time already filled - /// with silence, and dropping exactly that keeps its latency where it was. - /// A drifting source keeps what arrived (it is the stream picking up where - /// it stopped) and lets the ordinary splices trim the excess. + /// Returns the frames to discard: anything beyond the start level. On the + /// first start that is backlog nobody has heard; after running dry it is + /// audio that arrived late for time already played as silence, and + /// dropping exactly that, under the silence, keeps the latency where it was. pub(super) fn prime(&mut self, queued: usize) -> Option { let start = self.start_level(); if queued < start { return None; } - let excess = if self.started && !self.locked { - 0 - } else { - queued - start - }; - self.started = true; self.primed = true; self.owed = 0; self.sum = 0.0; @@ -207,13 +171,19 @@ impl Cushion { // Deliveries right after a gap come in catch-up bursts; measuring them // would read the gap's aftermath as the source's everyday jitter. self.depth.discard_window(); - Some(excess) + Some(queued - start) } pub(super) fn is_primed(&self) -> bool { self.primed } + /// The startup correction is done: from here on only the resampler moves + /// the queue. + pub(super) fn is_settled(&self) -> bool { + self.settled && self.owed == 0 + } + /// The graph ran `missing` frames dry mid-block: refill before playing /// on, rather than limp along a starved queue one click per block. pub(super) fn underrun(&mut self, missing: usize) { @@ -223,28 +193,10 @@ impl Cushion { self.depth.discard_window(); } - /// Asymmetric on purpose. Running short is an audible dropout, so any - /// window whose emptiest point ate into half the safety is topped up. - /// Running long only costs latency, so the mean may overshoot by a quarter - /// of the headroom before frames are dropped; the saw never triggers it. - fn correction(&self, mean: f64, low: usize) -> i64 { - let floor = self.floor(); - if low + self.safety / 2 < floor { - return (floor - low) as i64; - } - let over = mean - self.target() as f64; - let slack = (self.depth.depth() / 4).max(self.safety) as f64; - if over > slack { - // Never cut below the floor at the window's emptiest point. - return -(over.min((low - floor) as f64).round() as i64); - } - 0 - } - /// Call once per block with the frames queued before the read. Returns the - /// splice to perform now, if one is due and the queue can afford it. + /// startup correction's next splice, if one is due. pub(super) fn observe(&mut self, queued: usize) -> Adjust { - // While a correction runs the queue moves by design, and a window + // While the correction runs the queue moves by design, and a window // holding that move would read it as jitter. if self.owed != 0 { self.depth.discard_window(); @@ -255,62 +207,190 @@ impl Cushion { self.sum += queued as f64; self.blocks += 1; self.low = self.low.min(queued); - } - if self.owed == 0 && self.depth.observe(queued) { - let mean = self.sum / self.blocks as f64; - let low = self.low; - self.sum = 0.0; - self.blocks = 0; - self.low = usize::MAX; - if self.owed == 0 && !self.locked { - self.owed = self.correction(mean, low); - } else if self.locked && !self.settled && self.depth.is_measured() { - self.settled = true; - self.owed = self.correction(mean, low); + if self.depth.observe(queued) { + let mean = self.sum / self.blocks as f64; + let low = self.low; + self.sum = 0.0; + self.blocks = 0; + self.low = usize::MAX; + if !self.settled && self.depth.is_measured() { + self.settled = true; + self.owed = self.startup_excess(mean, low); + } } } if self.cooldown > 0 { self.cooldown -= 1; return Adjust::None; } - let fade = super::dag::SPLICE_FADE_FRAMES; - if self.owed < 0 { - let n = ((-self.owed) as usize).min(MAX_SPLICE_FRAMES); + if self.owed > 0 { + let n = self.owed.min(MAX_SPLICE_FRAMES); // A drop reads a fade on each side of the cut and must leave the // block itself behind. - if queued >= n + 2 * fade + self.need { - self.owed += n as i64; + if queued >= n + 2 * super::dag::SPLICE_FADE_FRAMES + self.need { + self.owed -= n; self.cooldown = self.cooldown_blocks; return Adjust::Drop(n); } - } else if self.owed > 0 { - let n = (self.owed as usize).min(MAX_SPLICE_FRAMES); - if queued >= n + fade { - self.owed -= n as i64; - self.cooldown = self.cooldown_blocks; - return Adjust::Insert(n); - } } Adjust::None } + + /// How far the first measured window sat above target, never cutting the + /// window's emptiest point below the floor. + fn startup_excess(&self, mean: f64, low: usize) -> usize { + let over = mean - self.target() as f64; + let slack = (self.depth.depth() / 4).max(self.safety) as f64; + if over <= slack { + return 0; + } + over.min(low.saturating_sub(self.floor()) as f64).round() as usize + } } #[cfg(test)] mod tests { use super::*; + use crate::audio::drift_loop::{ArrivalClock, DriftLoop}; const SR: u32 = 48_000; - struct Run { - late_underruns: usize, - mean_queued: f64, + /// Audible events a listener would notice, after settling. + #[derive(Debug, Default)] + struct Heard { splices: usize, + underruns: usize, + mean_queued: f64, max_queued: usize, } - /// Event-driven model of a capture device feeding the queue in bursts on - /// its own clock while an output device drains one block at a time. - /// `jitter` delays each capture burst by a pseudo-random share of it. + /// How the graph reads the queue: a block at a time, either straight (a + /// source on the output's clock) or through the drift resampler, which + /// takes `block * (1 + u)` with `u` from the loop. + #[derive(Clone, Copy, PartialEq)] + enum Read { + Locked, + Steered, + } + + /// Plays `deliveries` (time in seconds, frames) through a cushion read in + /// `block`-frame blocks. Dropouts inside a `quiet` span (give or take + /// 50 ms) are the source's own silence and are not counted. + #[allow(clippy::too_many_arguments)] + fn play( + mut c: Cushion, + rate: u32, + block: usize, + read: Read, + deliveries: &[(f64, usize)], + seconds: f64, + settle: f64, + quiet: &[(f64, f64)], + ) -> Heard { + let period = block as f64 / rate as f64; + let mut drift = DriftLoop::new(rate, block); + let mut arrival = ArrivalClock::new(rate); + let (mut t, mut i, mut queued, mut arrived, mut total) = (0.0, 0, 0, 0, 0u64); + let mut frac = 0.0; + let mut steering = false; + let mut heard = Heard::default(); + let mut samples = 0usize; + while t < seconds { + while i < deliveries.len() && deliveries[i].0 <= t { + let (at, n) = deliveries[i]; + queued += n; + arrived += n; + total += n as u64; + arrival.observe(total, at); + i += 1; + } + c.arrived(std::mem::take(&mut arrived)); + let quiet_now = quiet.iter().any(|&(a, b)| t >= a - 0.05 && t < b + 0.05); + let counting = t >= settle && !quiet_now; + if !c.is_primed() { + match c.prime(queued) { + Some(excess) => { + queued -= excess; + drift.restart(); + arrival.reset(); + } + None => { + t += period; + continue; + } + } + } + if let Adjust::Drop(n) = c.observe(queued) { + queued -= n; + heard.splices += counting as usize; + } + let take = match read { + Read::Locked => block, + Read::Steered if !c.is_settled() => { + steering = false; + block + } + Read::Steered => { + if !steering { + steering = true; + drift.restart(); + } + let start = c.start_level() as f64; + let u = drift.update(queued as f64 + arrival.pending(t, start) - start); + frac += block as f64 * (1.0 + u); + let take = frac as usize; + frac -= take as f64; + take + } + }; + if queued < take { + heard.underruns += counting as usize; + c.underrun(take - queued); + queued = 0; + } else { + queued -= take; + } + if counting { + heard.mean_queued += queued as f64; + heard.max_queued = heard.max_queued.max(queued); + samples += 1; + } + t += period; + } + heard.mean_queued /= samples.max(1) as f64; + heard + } + + /// A capture device delivering `burst` frames at a clock `capture_ppm` + /// off ours, each delivery late by a pseudo-random share `jitter` of its + /// period, nothing inside a `quiet` span, and `initial_backlog` frames + /// waiting at the start. + fn capture( + burst: usize, + capture_ppm: f64, + jitter: f64, + initial_backlog: usize, + seconds: f64, + quiet: &[(f64, f64)], + ) -> Vec<(f64, usize)> { + let in_period = burst as f64 / (SR as f64 * (1.0 + capture_ppm * 1e-6)); + let mut seed: u32 = 0x2545_f491; + let mut out = vec![(0.0, initial_backlog)]; + let mut next = in_period * 0.37; + while next < seconds { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let late = jitter * in_period * (seed % 1000) as f64 / 1000.0; + if !quiet.iter().any(|&(a, b)| next >= a && next < b) { + out.push((next + late, burst)); + } + next += in_period; + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + out + } + fn simulate( block: usize, burst: usize, @@ -319,7 +399,7 @@ mod tests { initial_backlog: usize, seconds: f64, settle_s: f64, - ) -> Run { + ) -> Heard { simulate_with_quiet( block, burst, @@ -332,8 +412,6 @@ mod tests { ) } - /// As `simulate`, with spans where the source delivers nothing at all - /// (its audio does not exist, as with a paused app). #[allow(clippy::too_many_arguments)] fn simulate_with_quiet( block: usize, @@ -344,78 +422,18 @@ mod tests { seconds: f64, settle_s: f64, quiet: &[(f64, f64)], - ) -> Run { - let mut c = Cushion::new(block, SR); - let out_period = block as f64 / SR as f64; - let in_period = burst as f64 / (SR as f64 * (1.0 + capture_ppm * 1e-6)); - let mut queued = initial_backlog; - let mut next_in = in_period * 0.37; - let mut late = 0.0; - let mut t_out = 0.0; - let mut seed: u32 = 0x2545_f491; - let mut run = Run { - late_underruns: 0, - mean_queued: 0.0, - splices: 0, - max_queued: 0, - }; - let mut samples = 0usize; - let mut arrived = 0usize; - while t_out < seconds { - if next_in + late <= t_out { - if !quiet.iter().any(|&(a, b)| next_in >= a && next_in < b) { - queued += burst; - arrived += burst; - } - next_in += in_period; - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - late = jitter * in_period * (seed % 1000) as f64 / 1000.0; - continue; - } - c.arrived(std::mem::take(&mut arrived)); - if !c.is_primed() { - match c.prime(queued) { - Some(excess) => queued -= excess, - None => { - t_out += out_period; - continue; - } - } - } - match c.observe(queued) { - Adjust::Drop(n) => { - queued -= n; - run.splices += 1; - } - Adjust::Insert(n) => { - queued += n; - run.splices += 1; - } - Adjust::None => {} - } - if queued < block { - let quiet_now = quiet - .iter() - .any(|&(a, b)| t_out >= a - 0.05 && t_out < b + 0.05); - if t_out > settle_s && !quiet_now { - run.late_underruns += 1; - } - c.underrun(block - queued); - queued = 0; - } else { - queued -= block; - } - if t_out > settle_s { - run.mean_queued += queued as f64; - run.max_queued = run.max_queued.max(queued); - samples += 1; - } - t_out += out_period; - } - run.mean_queued /= samples.max(1) as f64; - run + ) -> Heard { + let deliveries = capture(burst, capture_ppm, jitter, initial_backlog, seconds, quiet); + play( + Cushion::new(block, SR), + SR, + block, + Read::Steered, + &deliveries, + seconds, + settle_s, + quiet, + ) } #[test] @@ -432,10 +450,7 @@ mod tests { fn same_clock_settles_near_one_capture_burst() { for (block, burst) in [(32, 512), (64, 512), (128, 256), (256, 480), (1024, 512)] { let r = simulate(block, burst, 0.0, 0.0, 4_800, 30.0, 10.0); - assert_eq!( - r.late_underruns, 0, - "{block}/{burst}: underruns once settled" - ); + assert_eq!(r.underruns, 0, "{block}/{burst}: underruns once settled"); let bound = (burst + 2 * block + 2 * 48) as f64; assert!( r.mean_queued < bound, @@ -447,16 +462,17 @@ mod tests { #[test] fn drifting_capture_clock_never_starves_or_accumulates() { - // 200 ppm is far beyond real crystal error; 100 s drifts ~1000 frames. + // 500 ppm is far beyond real crystal error; 100 s drifts ~2400 frames, + // all of which the resampler's ratio has to absorb without a splice. for block in [32, 64, 256, 1024] { for burst in [256, 512, 960] { - for ppm in [-200.0, -50.0, 50.0, 200.0] { + for ppm in [-500.0, -50.0, 50.0, 500.0] { let r = simulate(block, burst, ppm, 0.0, 0, 100.0, 10.0); let case = format!("{block}/{burst} @ {ppm} ppm"); - assert_eq!(r.late_underruns, 0, "{case}: underran"); - let bound = burst + 3 * block + 1_024; + assert_eq!(r.underruns, 0, "{case}: underran"); + assert_eq!(r.splices, 0, "{case}: spliced"); + let bound = burst + 3 * block + 2 * 48 + 256; assert!(r.max_queued < bound, "{case}: max queue {}", r.max_queued); - assert!(r.splices < 200, "{case}: {} splices", r.splices); } } } @@ -466,18 +482,20 @@ mod tests { fn jittery_capture_buys_headroom_instead_of_glitching() { // A loaded VM: every burst lands up to 80% of its period late. let r = simulate(64, 480, 0.0, 0.8, 0, 120.0, 30.0); - assert_eq!(r.late_underruns, 0); + assert_eq!(r.underruns, 0); assert!(r.mean_queued < 3.0 * 480.0, "mean queue {}", r.mean_queued); } #[test] - fn corrections_stay_sparse() { - let r = simulate(64, 512, 0.0, 0.0, 0, 60.0, 10.0); - assert!( - r.splices < 10, - "{} splices under matching clocks", - r.splices - ); + fn nothing_is_spliced_after_the_startup_correction() { + // The one correction of the guessed start depth runs within the first + // two seconds; after that nothing is ever cut. + for ppm in [-100.0, 0.0, 100.0] { + let deliveries = capture(512, ppm, 0.3, 4_800, 60.0, &[]); + let c = Cushion::new(64, SR); + let r = play(c, SR, 64, Read::Steered, &deliveries, 60.0, 2.0, &[]); + assert_eq!(r.splices, 0, "{ppm} ppm"); + } } /// A second of ordinary flow (`burst` frames every `burst` frames of @@ -511,7 +529,12 @@ mod tests { let mut c = Cushion::new(32, SR); c.prime(c.start_level()).expect("primed"); let before = c.target(); - // 20 ms held back, then delivered all at once with the next burst. + // 20 ms held back, then delivered all at once with the next burst. A + // lone stall costs its dropout and nothing more. + gap_then(&mut c, 32, 960, 960, 960); + assert_eq!(c.target(), before, "one stall deepened the queue"); + // The same again within a minute: this source does stall, so the + // queue covers it from now on. gap_then(&mut c, 32, 960, 960, 960); let grown = c.target() - before; assert!(grown >= 900, "depth must cover the lateness: +{grown}"); @@ -553,7 +576,7 @@ mod tests { .collect(); let steady = simulate(64, 480, 0.0, 0.0, 0, 120.0, 10.0); let r = simulate_with_quiet(64, 480, 0.0, 0.0, 0, 120.0, 10.0, &quiet); - assert_eq!(r.late_underruns, 0, "underran while the app was playing"); + assert_eq!(r.underruns, 0, "underran while the app was playing"); // One delivery of overshoot on resuming is the onset being played, // not cut; anything beyond is silence turned into latency. assert!( @@ -571,84 +594,18 @@ mod tests { .map(|k| (k as f64 * 2.0 + 1.0, k as f64 * 2.0 + 1.3)) .collect(); let r = simulate_with_quiet(64, 480, 0.0, 0.8, 0, 120.0, 30.0, &quiet); - assert_eq!(r.late_underruns, 0); - } - - /// Audible events a listener would notice, after settling. - #[derive(Debug, Default)] - struct Heard { - splices: usize, - underruns: usize, - mean_queued: f64, - } - - /// Plays `deliveries` (time in seconds, frames) through a cushion read - /// in `block`-frame blocks at 44.1 kHz. - fn replay(block: usize, deliveries: &[(f64, usize)], seconds: f64, settle: f64) -> Heard { - replay_with( - Cushion::new(block, 44_100), - block, - deliveries, - seconds, - settle, - ) + assert_eq!(r.underruns, 0); } - fn replay_with( - mut c: Cushion, + fn replay( block: usize, + read: Read, deliveries: &[(f64, usize)], seconds: f64, settle: f64, ) -> Heard { - let rate = 44_100u32; - let period = block as f64 / rate as f64; - let (mut t, mut i, mut queued, mut arrived) = (0.0, 0usize, 0usize, 0usize); - let mut heard = Heard::default(); - let mut samples = 0usize; - while t < seconds { - while i < deliveries.len() && deliveries[i].0 <= t { - queued += deliveries[i].1; - arrived += deliveries[i].1; - i += 1; - } - c.arrived(std::mem::take(&mut arrived)); - let counting = t >= settle; - if !c.is_primed() { - match c.prime(queued) { - Some(excess) => queued -= excess, - None => { - t += period; - continue; - } - } - } - match c.observe(queued) { - Adjust::Drop(n) => { - queued -= n; - heard.splices += counting as usize; - } - Adjust::Insert(n) => { - queued += n; - heard.splices += counting as usize; - } - Adjust::None => {} - } - if queued < block { - heard.underruns += counting as usize; - c.underrun(block - queued); - queued = 0; - } else { - queued -= block; - } - if counting { - heard.mean_queued += queued as f64; - samples += 1; - } - t += period; - } - heard.mean_queued /= samples.max(1) as f64; - heard + let c = Cushion::new(block, 44_100); + play(c, 44_100, block, read, deliveries, seconds, settle, &[]) } /// A Core Audio process tap as it reaches a graph: 512-frame buffers on @@ -693,7 +650,7 @@ mod tests { fn a_process_tap_on_the_output_clock_plays_clean() { for seed in [1, 7, 42] { let deliveries = tap_through_normalizer(180.0, seed); - let h = replay(64, &deliveries, 180.0, 20.0); + let h = replay(64, Read::Steered, &deliveries, 180.0, 20.0); assert!( h.splices + h.underruns <= 2, "seed {seed}: heard {} splices and {} dropouts in 160 s", @@ -725,7 +682,7 @@ mod tests { let trace = recorded_chrome_tap(); // Counting from 2 s: the one startup correction lands in the first. for block in [32, 64, 128, 256] { - let h = replay_with(Cushion::locked(block, 44_100), block, &trace, 23.0, 2.0); + let h = replay(block, Read::Locked, &trace, 23.0, 2.0); assert_eq!(h.splices, 0, "{block}: {h:?}"); assert!(h.underruns <= 2, "{block}: {h:?}"); // About one delivery plus its jitter, well under the 28 ms the @@ -751,7 +708,7 @@ mod tests { let trace = recorded_chrome_tap_64(); let end = trace.last().expect("rows").0; for block in [32, 64, 128] { - let h = replay_with(Cushion::locked(block, 44_100), block, &trace, end, 2.0); + let h = replay(block, Read::Locked, &trace, end, 2.0); assert_eq!(h.splices, 0, "{block}: {h:?}"); // The recording holds one real tap stall (a 320-frame catch-up // after ~6 ms of nothing): a lone stall costs one dropout rather @@ -765,8 +722,8 @@ mod tests { } #[test] - fn a_locked_source_realigns_only_from_silence() { - let mut c = Cushion::locked(64, SR); + fn the_queue_realigns_only_from_silence() { + let mut c = Cushion::new(64, SR); let start = c.prime(20_000).expect("started"); assert!(start > 0, "startup backlog goes"); // Playing along: nothing is ever corrected. @@ -780,21 +737,10 @@ mod tests { assert_eq!(c.prime(level + 1_536), Some(1_536)); } - #[test] - fn only_the_first_start_discards() { - let mut c = Cushion::new(64, SR); - assert!( - c.prime(20_000).expect("started") > 0, - "startup backlog goes" - ); - c.underrun(64); - assert_eq!(c.prime(20_000), Some(0), "a refill keeps what arrived"); - } - #[test] fn a_splice_waits_until_the_queue_can_afford_it() { let mut c = Cushion::new(64, SR); - c.owed = -64; + c.owed = 64; assert_eq!(c.observe(64), Adjust::None, "not enough queued to cut"); assert_eq!(c.observe(64 + 64 + 2 * 32), Adjust::Drop(64)); } diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index cbd0b24e..73b16e9f 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant}; use rtrb::{Consumer, Producer, RingBuffer}; +use super::asrc::Asrc; use super::cushion::{Adjust, Cushion, MAX_SPLICE_FRAMES}; use super::latency::NodeTiming; use crate::audio::effects::{ @@ -13,7 +14,7 @@ use crate::audio::effects::{ }; use crate::audio::graph::{EdgeKind, EffectSpec, InputSpec, NetCodec, OutputSpec, ValidGraph}; use crate::audio::health; -use crate::audio::input_bridge::CaptureStats; +use crate::audio::input_bridge::{now_secs, CaptureStats, WriteClock}; use crate::audio::netaudio::packet::Format; use crate::audio::resample::MultiResampler; use crate::audio::stream_recv::ChannelReceiver; @@ -213,8 +214,6 @@ pub(super) struct SourceStats { /// Smoothed frames queued ahead of the graph: the latency this source /// adds. A gauge. pub queue_frames: Arc, - /// Samples the cushion stretched in to cover a slower capture clock. - pub stretched: Arc, /// Resampler chunks that failed and were dropped. pub failed: Arc, /// Set once the source has delivered audio; the tick thread logs it. @@ -230,7 +229,6 @@ impl SourceStats { consumed: Arc::new(AtomicU64::new(0)), level: Arc::new(AtomicU64::new(0)), queue_frames: Arc::new(AtomicU64::new(0)), - stretched: Arc::new(AtomicU64::new(0)), failed: Arc::new(AtomicU64::new(0)), online: Arc::new(AtomicBool::new(false)), } @@ -287,6 +285,10 @@ struct SourceState { input_staging: Vec, /// Holds a splice's crossfaded join until the refill path picks it up. splice_tmp: Vec, + /// Reads a live source whose producer runs on another clock, absorbing + /// the drift in its ratio. Replaces `resampler`; `None` for file sources + /// and for sources on this output's own clock. + asrc: Option>, out_pending: StagingRing, chunk_tmp: Vec, out_buf: Vec, @@ -329,7 +331,7 @@ impl SourceState { } } - fn fill_block(&mut self) { + fn fill_block(&mut self, now: f64) { if let Some(p) = &self.paused { if p.load(Ordering::SeqCst) { let avail = self.consumer.slots(); @@ -375,6 +377,48 @@ impl SourceState { } // Coming out of silence: the first samples ramp up rather than step. let fade_in = self.cushion.is_some() && !was_primed; + if self.asrc.is_some() { + self.fill_resampled(now, fade_in); + } else { + self.fill_direct(fade_in); + } + const ONE_BITS: u32 = 0x3F80_0000; + let vol_bits = self.volume.load(Ordering::Relaxed); + if vol_bits != ONE_BITS { + let vol = f32::from_bits(vol_bits); + for s in self.out_buf.iter_mut() { + *s *= vol; + } + } + if let Some(m) = &self.meter { + update_meter(m, &self.out_buf, self.channels); + } + if !self.handle_bufs.is_empty() { + let w = self.channels; + for (h, buf) in self.handle_bufs.iter_mut() { + if let Some(a) = parse_stereo(h) { + let c0 = (a - 1).min(w - 1); + let c1 = a.min(w - 1); + for f in 0..self.frames { + buf[f * 2] = self.out_buf[f * w + c0]; + buf[f * 2 + 1] = self.out_buf[f * w + c1]; + } + } else { + let c = parse_ch(h).map(|k| (k - 1).min(w - 1)).unwrap_or(0); + for f in 0..self.frames { + buf[f] = self.out_buf[f * w + c]; + } + } + } + } + self.stats + .level + .store(self.consumer.slots() as u64, Ordering::Relaxed); + self.publish_queue(); + } + + /// Reads the ring straight, or through the fixed-rate resampler. + fn fill_direct(&mut self, fade_in: bool) { let need = self.out_buf.len(); let mut written = self.out_pending.pop_into(&mut self.out_buf[..]); while written < need { @@ -415,39 +459,76 @@ impl SourceState { ramp(&mut self.out_buf, w, frames - len, len, false); } } - const ONE_BITS: u32 = 0x3F80_0000; - let vol_bits = self.volume.load(Ordering::Relaxed); - if vol_bits != ONE_BITS { - let vol = f32::from_bits(vol_bits); - for s in self.out_buf.iter_mut() { - *s *= vol; + } + + /// Reads the ring through the drift-steered resampler. Fades are applied + /// to its input, so they pass through the filter like any other audio. + fn fill_resampled(&mut self, now: f64, fade_in: bool) { + let queued = self.queued_frames(); + let (target, start, settled) = self.cushion.as_ref().map_or((queued, queued, true), |c| { + (c.target(), c.start_level(), c.is_settled()) + }); + let Some(asrc) = self.asrc.as_mut() else { + return; + }; + let w = self.channels; + if fade_in || !settled { + asrc.restart(); + } + if settled { + asrc.steer(queued, target, start, now); + } + let need = asrc.need() * w; + asrc.input.clear(); + // A splice staged its joined frames ahead of the ring. + let staged = self.input_staging.len().min(need); + asrc.input.extend_from_slice(&self.input_staging[..staged]); + self.input_staging.drain(..staged); + let avail = self.consumer.slots().min(need - staged); + let avail = avail - avail % w; + if avail > 0 { + if let Ok(chunk) = self.consumer.read_chunk(avail) { + let (first, second) = chunk.as_slices(); + asrc.input.extend_from_slice(first); + asrc.input.extend_from_slice(second); + chunk.commit_all(); + self.stats + .consumed + .fetch_add(avail as u64, Ordering::Relaxed); + self.last_pop_at = Instant::now(); } } - if let Some(m) = &self.meter { - update_meter(m, &self.out_buf, self.channels); + let got = asrc.input.len(); + if got > 0 { + self.stats.online.store(true, Ordering::Relaxed); } - if !self.handle_bufs.is_empty() { - let w = self.channels; - for (h, buf) in self.handle_bufs.iter_mut() { - if let Some(a) = parse_stereo(h) { - let c0 = (a - 1).min(w - 1); - let c1 = a.min(w - 1); - for f in 0..self.frames { - buf[f * 2] = self.out_buf[f * w + c0]; - buf[f * 2 + 1] = self.out_buf[f * w + c1]; - } - } else { - let c = parse_ch(h).map(|k| (k - 1).min(w - 1)).unwrap_or(0); - for f in 0..self.frames { - buf[f] = self.out_buf[f * w + c]; - } + if fade_in { + ramp(&mut asrc.input, w, 0, got / w, true); + } + if got < need { + let frames = got / w; + let len = frames.min(SPLICE_FADE_FRAMES); + ramp(&mut asrc.input, w, frames - len, len, false); + asrc.input.resize(need, 0.0); + let missing = need - got; + if self.last_pop_at.elapsed() > STALL_THRESHOLD { + self.stats + .stalled + .fetch_add(missing as u64, Ordering::Relaxed); + } else { + self.stats.xrun.fetch_add(missing as u64, Ordering::Relaxed); + if let Some(c) = &mut self.cushion { + c.underrun(missing / w); } } } - self.stats - .level - .store(self.consumer.slots() as u64, Ordering::Relaxed); - self.publish_queue(); + match asrc.process() { + Ok(out) if out.len() == self.out_buf.len() => self.out_buf.copy_from_slice(out), + _ => { + self.stats.failed.fetch_add(1, Ordering::Relaxed); + self.out_buf.fill(0.0); + } + } } /// Frames waiting ahead of the graph: the ring plus anything already @@ -458,7 +539,8 @@ impl SourceState { fn publish_queue(&mut self) { self.queued_after = self.queued_frames(); - let queued = self.queued_after as f64; + let filter = self.asrc.as_ref().map_or(0, |a| a.delay_frames()); + let queued = (self.queued_after + filter) as f64; self.queue_avg += (queued - self.queue_avg) * 0.02; self.stats .queue_frames @@ -484,10 +566,11 @@ impl SourceState { } let queued = self.queued_frames(); let fade = SPLICE_FADE_FRAMES * self.channels; - match self.cushion.as_mut().map(|c| c.observe(queued)) { - Some(Adjust::Drop(n)) => self.splice_trim(n * self.channels, fade), - Some(Adjust::Insert(n)) => self.splice_stretch(n * self.channels, fade), - _ => {} + if let Some(Adjust::Drop(n)) = self.cushion.as_mut().map(|c| c.observe(queued)) { + // The crossfade overlaps a fade's worth on each side of the cut + // into one, so that much of the `n` goes with it. + let fade = fade.min(n * self.channels); + self.splice_trim(n * self.channels - fade, fade); } true } @@ -512,40 +595,6 @@ impl SourceState { } } - /// Lengthens the stream by `insert` samples without a step: the next - /// `insert` samples play, then the stream replays from the start of that - /// stretch, the `fade` samples after the stretch crossfading into the - /// replay's first `fade`. Only the `fade` samples replayed are consumed. - fn splice_stretch(&mut self, insert: usize, fade: usize) { - self.splice_tmp.clear(); - { - let Ok(ahead) = self.consumer.read_chunk(insert + fade) else { - return; - }; - let (first, second) = ahead.as_slices(); - self.splice_tmp.extend_from_slice(first); - self.splice_tmp.extend_from_slice(second); - // Dropped uncommitted: peeked, not consumed. - } - let span = (SPLICE_FADE_FRAMES - 1).max(1) as f32; - for i in 0..fade { - let w = ((i / self.channels) as f32 / span).min(1.0); - let out = self.splice_tmp[insert + i]; - self.splice_tmp[insert + i] = out * (1.0 - w) + self.splice_tmp[i] * w; - } - if let Ok(replayed) = self.consumer.read_chunk(fade) { - replayed.commit_all(); - } - self.input_staging.extend_from_slice(&self.splice_tmp); - self.stats - .consumed - .fetch_add(fade as u64, Ordering::Relaxed); - self.stats - .stretched - .fetch_add(insert as u64, Ordering::Relaxed); - self.last_pop_at = Instant::now(); - } - /// Removes `drop` samples from the input ring, crossfading the `fade` /// samples before the cut into the `fade` after it. The joined slice leads /// the stream through `input_staging`, so the listener hears one short @@ -668,7 +717,8 @@ struct EffectState { // When this node fans out to several outputs it is computed once (here, in // its owning output's graph) and its `out_buf` is published each block into // one ring per other consuming output, which reads it via a ring-source. - taps: Vec>, + // The clock tells that reader when each block was written. + taps: Vec<(Producer, Arc)>, } impl EffectState { @@ -1037,35 +1087,58 @@ impl OutputGraph { .clamp(1, self.out_channels) } - /// Attach a publish ring to a fan-out effect node; its `out_buf` is pushed - /// there each block for another output's ring-source to read. - /// Marks the sources reading `inputs` as running on this output's clock, - /// so their queues are never spliced while they play. Call before the - /// graph goes live. + /// Marks the sources reading `inputs` as running on this output's clock: + /// they have no drift to absorb and read without the drift resampler. + /// Call before the graph goes live. pub(super) fn lock_inputs(&mut self, inputs: &HashSet) { for node in &mut self.nodes { let DagNode::Source(s) = node else { continue }; - if !s.input_id.as_ref().is_some_and(|id| inputs.contains(id)) { + if s.input_id.as_ref().is_some_and(|id| inputs.contains(id)) { + s.asrc = None; + } + } + } + + /// Gives the sources reading `input` its producer's write timing, which + /// the drift loop reads the queue against. + pub(super) fn attach_write_clock(&mut self, input: &str, clock: &Arc) { + for node in &mut self.nodes { + let DagNode::Source(s) = node else { continue }; + if s.input_id.as_deref() != Some(input) { continue; } - s.cushion = s.cushion.take().map(Cushion::into_locked); + if let Some(asrc) = &mut s.asrc { + asrc.set_clock(clock.clone()); + } } } - pub(super) fn attach_tap(&mut self, node_idx: usize, prod: Producer) { + /// Attach a publish ring to a fan-out effect node; its `out_buf` is pushed + /// there each block for another output's ring-source to read. + pub(super) fn attach_tap( + &mut self, + node_idx: usize, + prod: Producer, + clock: Arc, + ) { if let Some(DagNode::Effect(e)) = self.nodes.get_mut(node_idx) { - e.taps.push(prod); + e.taps.push((prod, clock)); } } /// Fill `output` (`block_frames * out_channels` long) with one block of /// mixed audio at `sample_rate`. pub(super) fn process_block(&mut self, output: &mut [f32]) { + self.process_block_at(output, now_secs()); + } + + /// `process_block` at `now` seconds on `input_bridge::now_secs`'s clock. + pub(super) fn process_block_at(&mut self, output: &mut [f32], now: f64) { let frames = self.block_frames; self.blocks.fetch_add(1, Ordering::Relaxed); for node in &mut self.nodes { match node { - DagNode::Source(s) => s.fill_block(), + DagNode::Source(s) => s.fill_block(now), DagNode::Producer(p) => p.process(), DagNode::Effect(_) | DagNode::Consumer(_) => {} } @@ -1154,7 +1227,8 @@ impl OutputGraph { } } // Publish the processed block to every consuming output's ring. - for prod in eff.taps.iter_mut() { + for (prod, clock) in eff.taps.iter_mut() { + clock.record(eff.out_buf.len(), now); bulk_push_counted(prod, &eff.out_buf, &health::TAP_RING_OVERRUN_SAMPLES); } } @@ -1222,7 +1296,7 @@ pub(super) fn build_output_graph( // Effect nodes provided by a ring instead of built here: each is computed // once in its owning output's graph and read back as a ring-source. Maps // node id -> (ring consumer, owner-graph sample rate, channel width). - mut cut_leaves: HashMap, u32, usize)>, + mut cut_leaves: HashMap, ) -> AppResult { let cut_leaf_ids: HashSet = cut_leaves.keys().cloned().collect(); let reachable: HashSet = match output_id { @@ -1300,10 +1374,11 @@ pub(super) fn build_output_graph( for id in &topo { // A fan-out node owned by an earlier output: read its published block // from the ring instead of rebuilding the whole upstream chain. - if let Some((consumer, owner_sr, width)) = cut_leaves.remove(id) { + if let Some((consumer, owner_sr, width, clock)) = cut_leaves.remove(id) { let source = ring_source( id, consumer, + clock, owner_sr, output_sr, block_frames, @@ -1466,6 +1541,10 @@ pub(super) fn build_output_graph( resampler, input_staging: Vec::with_capacity(staging_samples(source_channels)), splice_tmp: Vec::with_capacity(splice_samples(source_channels)), + asrc: source_realtime + .then(|| Asrc::new(input_sr, output_sr, block_frames, source_channels)) + .transpose()? + .map(Box::new), out_pending: StagingRing::with_capacity(staging_cap), chunk_tmp: Vec::with_capacity(out_max * source_channels), out_buf: vec![0.0; block_frames * source_channels], @@ -1901,6 +1980,10 @@ pub(super) fn build_output_graph( }) } +/// A fan-out node's published ring as another output reads it: the ring, the +/// owner's rate and width, and when the owner wrote to it. +pub(super) type CutLeaf = (Consumer, u32, usize, Arc); + /// Builds a `SourceState` that reads a fan-out node's published block from a /// ring (written at `owner_sr`) and resamples it to this graph's `output_sr`. /// Reuses the source machinery so per-channel taps and backlog-dropping behave @@ -1909,6 +1992,7 @@ pub(super) fn build_output_graph( fn ring_source( id: &str, consumer: Consumer, + clock: Arc, owner_sr: u32, output_sr: u32, block_frames: usize, @@ -1952,6 +2036,14 @@ fn ring_source( }) .collect(); + let asrc = if realtime { + let mut asrc = Asrc::new(owner_sr, output_sr, block_frames, channels)?; + asrc.set_clock(clock); + Some(Box::new(asrc)) + } else { + None + }; + Ok(SourceState { label: format!("cut:{id}"), channels, @@ -1960,6 +2052,7 @@ fn ring_source( resampler, input_staging: Vec::with_capacity(staging_samples(channels)), splice_tmp: Vec::with_capacity(splice_samples(channels)), + asrc, out_pending: StagingRing::with_capacity(staging_cap), chunk_tmp: Vec::with_capacity(out_max * channels), out_buf: vec![0.0; block_frames * channels], @@ -2692,7 +2785,9 @@ pub(super) mod graph_tests { /// Drives a live passthrough source the way a capture device and an output /// device do, each on its own clock: `burst`-frame deliveries against - /// `block`-frame reads. Returns the output after `settle` seconds. + /// `block`-frame reads, the source read through the drift resampler with + /// the capture's write timing, as the pipeline wires it. Returns the + /// output after `settle` seconds and the frames spliced out after it. fn live_capture( block: usize, burst: usize, @@ -2700,9 +2795,11 @@ pub(super) mod graph_tests { seconds: f64, settle: f64, signal: impl Fn(usize) -> f32, - ) -> (Vec, SourceStats) { + ) -> (Vec, SourceStats, u64) { let (valid, _) = passthrough_graph(); let (mut built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, true); + let clock = Arc::new(WriteClock::default()); + built.graph.attach_write_clock("m", &clock); let stats = built.sources[0].stats.clone(); let input = producers.get_mut("m").unwrap(); let out_period = block as f64 / SR as f64; @@ -2711,6 +2808,7 @@ pub(super) mod graph_tests { let mut out = vec![0.0; block * 2]; let mut kept = Vec::new(); let mut chunk = vec![0.0; burst * 2]; + let mut trimmed_at_settle = 0; while t_out < seconds { if t_in <= t_out { for f in 0..burst { @@ -2718,18 +2816,23 @@ pub(super) mod graph_tests { chunk[f * 2] = v; chunk[f * 2 + 1] = v; } + clock.record(chunk.len(), t_in); assert_eq!(push_all(input, &chunk), chunk.len()); fed += burst; t_in += in_period; continue; } - built.graph.process_block(&mut out); + if t_out < settle { + trimmed_at_settle = stats.trimmed.load(Ordering::Relaxed); + } + built.graph.process_block_at(&mut out, t_out); if t_out >= settle { kept.extend(out.iter().step_by(2)); } t_out += out_period; } - (kept, stats) + let spliced = stats.trimmed.load(Ordering::Relaxed) - trimmed_at_settle; + (kept, stats, spliced / 2) } /// One observation per output block of a live source whose capture goes @@ -2742,14 +2845,25 @@ pub(super) mod graph_tests { reported_queue: usize, } + /// `locked`: the capture runs on the output's clock and every played + /// sample names its frame. Otherwise it is read through the drift + /// resampler, whose samples are interpolated, and the delay is what it has + /// taken from the ring. fn live_capture_with_gaps( block: usize, burst: usize, seconds: f64, gaps: &[(f64, f64)], + locked: bool, ) -> Vec { let (valid, _) = passthrough_graph(); let (mut built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, true); + let clock = Arc::new(WriteClock::default()); + if locked { + built.graph.lock_inputs(&HashSet::from(["m".to_string()])); + } else { + built.graph.attach_write_clock("m", &clock); + } let stats = built.sources[0].stats.clone(); let input = producers.get_mut("m").unwrap(); let out_period = block as f64 / SR as f64; @@ -2768,17 +2882,20 @@ pub(super) mod graph_tests { chunk[f * 2] = v; chunk[f * 2 + 1] = v; } + clock.record(chunk.len(), t_in); push_all(input, &chunk); fed += burst; } t_in += in_period; continue; } - built.graph.process_block(&mut out); + built.graph.process_block_at(&mut out, t_out); let last = out[(block - 1) * 2]; + let consumed = stats.consumed.load(Ordering::Relaxed) as usize / 2; + let played = if locked { last as usize } else { consumed }; ticks.push(Tick { t: t_out, - real_delay: (last > 0.0).then(|| fed - last as usize), + real_delay: (last > 0.0).then(|| fed.saturating_sub(played)), reported_queue: stats.queue_frames.load(Ordering::Relaxed) as usize, }); t_out += out_period; @@ -2789,7 +2906,12 @@ pub(super) mod graph_tests { #[test] fn a_paused_app_resumes_at_low_latency_every_time() { let gaps = [(10.0, 15.0), (25.0, 30.0), (40.0, 41.0)]; - let ticks = live_capture_with_gaps(64, 480, 55.0, &gaps); + for locked in [true, false] { + resumes_at_low_latency(&live_capture_with_gaps(64, 480, 55.0, &gaps, locked)); + } + } + + fn resumes_at_low_latency(ticks: &[Tick]) { for (resume, until) in [(15.0, 25.0), (30.0, 40.0), (41.0, 55.0)] { let window: Vec<&Tick> = ticks .iter() @@ -2841,15 +2963,21 @@ pub(super) mod graph_tests { (start, start + 0.06 + (k % 7) as f64 * 0.05) }) .collect(); - let steady = live_capture_with_gaps(64, 480, 125.0, &[]); - let gappy = live_capture_with_gaps(64, 480, 125.0, &gaps); + for locked in [true, false] { + silence_adds_no_latency(&gaps, locked); + } + } + + fn silence_adds_no_latency(gaps: &[(f64, f64)], locked: bool) { + let steady = live_capture_with_gaps(64, 480, 125.0, &[], locked); + let gappy = live_capture_with_gaps(64, 480, 125.0, gaps, locked); let baseline = worst_delay(&steady, 3.0); let with_gaps = worst_delay(&gappy, 3.0); // A sound resuming after silence may start up to one delivery above // target (its onset is played, not cut); that is the whole allowance. assert!( with_gaps <= baseline + 480, - "silence added latency: {with_gaps} frames vs {baseline} steady" + "locked {locked}: silence added latency: {with_gaps} frames vs {baseline} steady" ); // And it never accumulates: after a hundred pauses it is where the // first few left it. @@ -2857,14 +2985,14 @@ pub(super) mod graph_tests { let late = worst_delay_between(&gappy, 100.0, 125.0); assert!( late <= early + 64, - "latency crept across pauses: {early} -> {late}" + "locked {locked}: latency crept across pauses: {early} -> {late}" ); } #[test] fn readout_does_not_climb_across_pauses() { let gaps: Vec<(f64, f64)> = (0..40).map(|k| (5.0 + k as f64, 5.4 + k as f64)).collect(); - let ticks = live_capture_with_gaps(64, 480, 45.0, &gaps); + let ticks = live_capture_with_gaps(64, 480, 45.0, &gaps, false); // Measured once the pauses have begun, so both sides include a resume. let early = ticks .iter() @@ -2891,6 +3019,9 @@ pub(super) mod graph_tests { /// a few dozen frames under a crossfade; anything bigger is an audible /// jump. worst_jump: usize, + /// Largest stretch lost while audio was playing, rather than under a + /// dropout's silence. + worst_jump_heard: usize, /// Output samples of silence once playing, not counting pauses. dropouts: usize, worst_delay: usize, @@ -2910,6 +3041,7 @@ pub(super) mod graph_tests { ) -> TapRun { let (valid, _) = passthrough_graph(); let (mut built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, true); + built.graph.lock_inputs(&HashSet::from(["m".to_string()])); let input = producers.get_mut("m").unwrap(); let out_period = block as f64 / SR as f64; let in_period = burst as f64 / SR as f64; @@ -2921,11 +3053,13 @@ pub(super) mod graph_tests { let mut run = TapRun { skipped: 0, worst_jump: 0, + worst_jump_heard: 0, dropouts: 0, worst_delay: 0, }; let mut last_played = 0usize; let mut prev_raw = 0.0f32; + let mut silent_since = false; while t_out < seconds { if due <= t_out { for f in 0..burst { @@ -2955,6 +3089,7 @@ pub(super) mod graph_tests { if raw == 0.0 { run.dropouts += 1; prev_raw = 0.0; + silent_since = true; continue; } // Inside a fade the index is scaled and names no frame; a @@ -2966,14 +3101,21 @@ pub(super) mod graph_tests { } let v = raw as usize; if last_played > 0 && v > last_played + 1 { - run.skipped += v - last_played - 1; - run.worst_jump = run.worst_jump.max(v - last_played - 1); + let jump = v - last_played - 1; + run.skipped += jump; + run.worst_jump = run.worst_jump.max(jump); + if !silent_since { + run.worst_jump_heard = run.worst_jump_heard.max(jump); + } } last_played = v; + silent_since = false; run.worst_delay = run.worst_delay.max(fed.saturating_sub(v)); } } else { - last_played = out[(block - 1) * 2] as usize; + // Counting starts from the first frame played after settling. + prev_raw = out[(block - 1) * 2]; + last_played = 0; } t_out += out_period; } @@ -3047,30 +3189,36 @@ pub(super) mod graph_tests { } #[test] - fn a_stalled_tap_resumes_without_skipping_audio() { + fn a_stalled_tap_resumes_without_piling_up_latency() { // Every 5 s the tap holds its deliveries for 25 ms, then catches up. let stalls: Vec<(f64, f64)> = (0..12) .map(|k| (5.0 + k as f64 * 5.0, 5.025 + k as f64 * 5.0)) .collect(); + let steady = tap_capture(32, 512, 65.0, 3.0, 1.0, &[]); let r = tap_capture(32, 512, 65.0, 3.0, 1.0, &stalls); - // What came late is played, not thrown away: no stretch of audio - // vanishes at once, only crossfaded splices trim the excess later. - let splice = super::MAX_SPLICE_FRAMES + 2 * SPLICE_FADE_FRAMES; + // The stall is heard as a dropout. What arrived late for the time + // already played as silence goes under that silence: never more than + // the stall plus one delivery, and never while audio plays. + let stall = SR as usize / 40 + 512; assert!( - r.worst_jump <= splice, + r.worst_jump <= stall, "{} frames vanished at once", r.worst_jump ); + assert_eq!(r.worst_jump_heard, 0, "audio skipped while playing"); + // A tap that stalls again within a minute gets a queue deep enough to + // cover its stalls, once; nothing piles up beyond that. assert!( - r.worst_delay < 4_800, - "delay grew to {} frames", - r.worst_delay + r.worst_delay <= steady.worst_delay + stall, + "delay grew to {} frames from {}", + r.worst_delay, + steady.worst_delay ); } #[test] fn reported_queue_tracks_the_real_delay() { - let ticks = live_capture_with_gaps(64, 480, 55.0, &[(10.0, 15.0), (25.0, 30.0)]); + let ticks = live_capture_with_gaps(64, 480, 55.0, &[(10.0, 15.0), (25.0, 30.0)], true); for k in ticks.iter().filter(|k| k.t > 2.0) { if let Some(real) = k.real_delay { assert!( @@ -3087,50 +3235,78 @@ pub(super) mod graph_tests { #[test] fn live_source_runs_at_capture_latency_without_glitches() { - for ppm in [-200.0, 0.0, 200.0] { - let xruns_before; - let (out, stats) = { - let r = live_capture(64, 512, ppm, 40.0, 5.0, |_| 0.5); - xruns_before = r.1.xrun.load(Ordering::Relaxed); - r - }; - assert!( - out.iter().all(|&s| (s - 0.5).abs() < 1e-6), - "{ppm} ppm: gap in a constant signal" - ); + for ppm in [-500.0, -200.0, 0.0, 200.0, 500.0] { + let (out, stats, spliced) = live_capture(64, 512, ppm, 40.0, 5.0, |_| 0.5); + let worst = out.iter().map(|s| (s - 0.5).abs()).fold(0.0_f32, f32::max); + assert!(worst < 1e-3, "{ppm} ppm: constant signal off by {worst}"); let queue = stats.queue_frames.load(Ordering::Relaxed) as usize; assert!( queue < 512 + 3 * 64 + 256, "{ppm} ppm: {queue} frames queued" ); - assert_eq!(stats.xrun.load(Ordering::Relaxed), xruns_before); + assert_eq!(stats.xrun.load(Ordering::Relaxed), 0, "{ppm} ppm: ran dry"); + assert_eq!(spliced, 0, "{ppm} ppm: spliced once settled"); } } #[test] - fn drift_corrections_are_seamless() { - // A 60 Hz sine moves < 0.007 per frame. A splice shifts it by up to 96 - // frames (~0.66); spread across the 32-frame crossfade that is ~0.02 - // per frame, while a hard cut would jump the whole 0.66 in one. - let sine = |f: usize| (f as f32 * std::f32::consts::TAU * 60.0 / SR as f32).sin() * 0.9; - let hard_cut = (0..SR as usize) - .map(|f| (sine(f + 96) - sine(f)).abs()) - .fold(0.0_f32, f32::max); - assert!( - hard_cut > 0.5, - "the threshold below must tell a cut from a fade" - ); - for ppm in [-300.0, 300.0] { - let (out, stats) = live_capture(64, 480, ppm, 60.0, 5.0, sine); - let corrected = - stats.trimmed.load(Ordering::Relaxed) + stats.stretched.load(Ordering::Relaxed); - assert!(corrected > 0, "{ppm} ppm: drift never corrected"); - let worst = out - .windows(2) - .map(|w| (w[1] - w[0]).abs()) - .fold(0.0_f32, f32::max); - assert!(worst < 0.06, "{ppm} ppm: step of {worst} at a splice"); + fn drift_is_absorbed_without_a_step() { + // A 60 Hz sine's second difference never exceeds 0.9 * (2 pi 60 / SR)^2, + // about 6e-5. The resampler moving its ratio by a few ppm keeps it + // there; a splice, a repeated frame or a dropout is orders above. + // 800 frames is one period: the phase stays exact however long it runs. + let sine = |f: usize| ((f % 800) as f64 * std::f64::consts::TAU / 800.0).sin() as f32 * 0.9; + let ideal = 0.9 * (std::f32::consts::TAU * 60.0 / SR as f32).powi(2); + for block in [32, 256] { + for ppm in [-500.0, -300.0, 300.0, 500.0] { + let (out, stats, spliced) = live_capture(block, 480, ppm, 60.0, 5.0, sine); + assert_eq!(spliced, 0, "{block}/{ppm} ppm: spliced"); + assert_eq!( + stats.xrun.load(Ordering::Relaxed), + 0, + "{block}/{ppm} ppm: ran dry" + ); + let worst = out + .windows(3) + .map(|w| (w[2] - 2.0 * w[1] + w[0]).abs()) + .fold(0.0_f32, f32::max); + assert!( + worst < 4.0 * ideal, + "{block}/{ppm} ppm: second difference {worst}, a clean sine peaks at {ideal}" + ); + } + } + } + + #[test] + fn a_steered_source_never_touches_the_heap() { + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = build_with_block(Some("s"), SR, 64, &valid, SR, true); + let clock = Arc::new(WriteClock::default()); + built.graph.attach_write_clock("m", &clock); + let input = producers.get_mut("m").unwrap(); + let chunk = vec![0.25_f32; 480 * 2]; + let mut out = vec![0.0; 64 * 2]; + let mut t = 0.0; + // Warm up: prime, settle, run dry once and restart. + for k in 0..2_000 { + if k % 7 == 0 && !(900..960).contains(&k) { + clock.record(chunk.len(), t); + push_all(input, &chunk); + } + built.graph.process_block_at(&mut out, t); + t += 64.0 / SR as f64; } + crate::audio::rt_guard::assert_no_alloc("steered source", || { + for k in 0..4_000 { + if k % 7 == 0 && !(1_000..1_060).contains(&k) { + clock.record(chunk.len(), t); + push_all(input, &chunk); + } + built.graph.process_block_at(&mut out, t); + t += 64.0 / SR as f64; + } + }); } #[test] @@ -3269,7 +3445,7 @@ pub(super) mod graph_tests { assert_eq!(width, 2); let (prod, cons) = RingBuffer::::new(SR as usize * 2); let mut cut_leaves = HashMap::new(); - cut_leaves.insert("g".to_string(), (cons, SR, 2)); + cut_leaves.insert("g".to_string(), (cons, SR, 2, Arc::default())); let mut producer_pairs = Vec::new(); let native = valid.inputs.iter().map(|i| (i.id.clone(), SR)).collect(); @@ -3296,7 +3472,7 @@ pub(super) mod graph_tests { assert_eq!(built_b.sources[0].channels, 2); // A publishes a block into the tap ring; B reads it back. - built_a.graph.attach_tap(node_idx, prod); + built_a.graph.attach_tap(node_idx, prod, Arc::default()); let published = stereo_ramp(1024, 0.0); push_all(producers_a.get_mut("m").unwrap(), &published); let mut out_a = vec![0.0; TIMER_BLOCK_FRAMES * 2]; @@ -3894,7 +4070,7 @@ pub(super) mod graph_tests { let (node_idx, _) = built_a.node_meta["g"]; let (prod, cons) = RingBuffer::::new(SR as usize * 2); let mut cut_leaves = HashMap::new(); - cut_leaves.insert("g".to_string(), (cons, 44_100, 2)); + cut_leaves.insert("g".to_string(), (cons, 44_100, 2, Arc::default())); let mut producer_pairs = Vec::new(); let native = valid @@ -3927,7 +4103,7 @@ pub(super) mod graph_tests { // exactly what A's effect produced. push_all(producers_a.get_mut("m").unwrap(), &stereo_ramp(4096, 0.0)); let mut out_a = vec![0.0; TIMER_BLOCK_FRAMES * 2]; - built_a.graph.attach_tap(node_idx, prod); + built_a.graph.attach_tap(node_idx, prod, Arc::default()); built_a.graph.process_block(&mut out_a); // A block published at 44.1k resampled to 48k: the value must land // in B's output, finite and near the source level. diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index ad786b36..f2a9ca92 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -16,7 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; -use rtrb::{Consumer, Producer}; +use rtrb::Producer; use tauri::AppHandle; use tracing::{info, warn}; @@ -26,9 +26,10 @@ use crate::audio::effects::{ use crate::audio::graph::{ EffectSpec, InputSpec, NetCodec, OutputSpec, RecordingFormat, ValidGraph, }; -use crate::audio::input_bridge::{broadcast_channel, BroadcastTx, CaptureStats}; +use crate::audio::input_bridge::{broadcast_channel, BroadcastTx, CaptureStats, WriteClock}; use crate::error::{AppError, AppResult}; +mod asrc; mod cue; mod cushion; pub use cue::play as play_cue; @@ -843,8 +844,7 @@ impl ActivePipeline { self.effect_registry.begin_reconcile(); // Ring consumers stashed by an owner build, keyed by the consuming // output then node id; the consumer's build reads them as ring-sources. - let mut pending_cuts: HashMap, u32, usize)>> = - HashMap::new(); + let mut pending_cuts: HashMap> = HashMap::new(); for out in dag::owner_order(graph) { if !output_runtime.contains_key(&out.id) { continue; @@ -903,11 +903,12 @@ impl ActivePipeline { for o2 in cons { let (prod, consumer) = rtrb::RingBuffer::::new(ring_capacity_frames(output_sr) * width); - built.graph.attach_tap(idx, prod); + let clock = Arc::new(WriteClock::default()); + built.graph.attach_tap(idx, prod, clock.clone()); pending_cuts .entry(o2.clone()) .or_default() - .insert(node.clone(), (consumer, output_sr, width)); + .insert(node.clone(), (consumer, output_sr, width, clock)); } } for (inp_id, prod) in my_pairs { @@ -1096,6 +1097,13 @@ impl ActivePipeline { } } + for (input_id, state) in &self.inputs { + let clock = state.bridge_tx.write_clock(); + for og in output_graphs.values_mut().chain(monitor_graph.as_mut()) { + og.attach_write_clock(input_id, &clock); + } + } + for (input_id, out_id, capture) in captured { if let Some(meta) = self .source_stats diff --git a/src-tauri/src/audio/resample.rs b/src-tauri/src/audio/resample.rs index 76b57e99..632c53b6 100644 --- a/src-tauri/src/audio/resample.rs +++ b/src-tauri/src/audio/resample.rs @@ -8,8 +8,13 @@ use rubato::{ use crate::error::{AppError, AppResult}; fn sinc_params() -> SincInterpolationParameters { + sinc_params_of(256) +} + +/// `sinc_len` sets quality and delay together: the filter holds half of it. +fn sinc_params_of(sinc_len: usize) -> SincInterpolationParameters { SincInterpolationParameters { - sinc_len: 256, + sinc_len, f_cutoff: 0.95, interpolation: SincInterpolationType::Cubic, oversampling_factor: 256, @@ -33,9 +38,34 @@ pub struct MultiResamplerOut { impl MultiResamplerOut { pub fn new(from_rate: u32, to_rate: u32, chunk_out: usize, channels: usize) -> AppResult { + Self::with_sinc(from_rate, to_rate, chunk_out, channels, 256) + } + + /// For clock-drift correction on a live path. At equal nominal rates the + /// ratio only ever strays a few hundred ppm from 1, where a 64-tap sinc + /// is transparent and holds 32 frames instead of 128; a real rate change + /// keeps the full filter. + pub fn for_drift( + from_rate: u32, + to_rate: u32, + chunk_out: usize, + channels: usize, + ) -> AppResult { + let sinc_len = if from_rate == to_rate { 64 } else { 256 }; + Self::with_sinc(from_rate, to_rate, chunk_out, channels, sinc_len) + } + + fn with_sinc( + from_rate: u32, + to_rate: u32, + chunk_out: usize, + channels: usize, + sinc_len: usize, + ) -> AppResult { let ratio = to_rate as f64 / from_rate as f64; - let inner = SincFixedOut::::new(ratio, 1.05, sinc_params(), chunk_out, channels) - .map_err(|e| AppError::Stream(format!("resampler init: {e}")))?; + let inner = + SincFixedOut::::new(ratio, 1.05, sinc_params_of(sinc_len), chunk_out, channels) + .map_err(|e| AppError::Stream(format!("resampler init: {e}")))?; let in_max = inner.input_frames_max(); Ok(Self { inner, @@ -51,6 +81,16 @@ impl MultiResamplerOut { self.inner.input_frames_next() } + /// Largest `input_frames_next` can grow to. + pub fn input_frames_max(&self) -> usize { + self.inner.input_frames_max() + } + + /// Output frames between a sample entering and leaving the filter. + pub fn delay_frames(&self) -> usize { + self.inner.output_delay() + } + /// Nudge the output/input ratio for clock-drift tracking (ramped, within the /// 1.05 relative bound set at construction). pub fn set_ratio(&mut self, ratio: f64) { From 883da951e33821978ba38f3782718c2f90cfa905 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:14:13 +0300 Subject: [PATCH 07/51] feat(net): steer the receive buffer with the shared drift loop, correct the start depth once --- docs/CONCEPT.md | 2 +- src-tauri/src/audio/pipeline/dag.rs | 6 +- src-tauri/src/audio/stream_recv.rs | 297 ++++++++++++++++++++++------ 3 files changed, 243 insertions(+), 62 deletions(-) diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 74a367ea..7fccdbc3 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -42,7 +42,7 @@ Without these, one chunk takes ~16 ms and the worker stalls. - Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. -- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block. +- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. The network receiver (`stream_recv.rs`) runs the same loop against its packets' write timing, holding the fill right after a packet at its start level. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block. - An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs at its own working block, declares it as latency for PDC, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 73b16e9f..49f8f83c 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -781,8 +781,8 @@ enum TapKey { } impl ProducerState { - fn process(&mut self) { - self.receiver.mix_block(&mut self.out_buf); + fn process(&mut self, now: f64) { + self.receiver.mix_block_at(&mut self.out_buf, now); for ((_, buf), key) in self.handle_bufs.iter_mut().zip(&self.wire_keys) { match key { TapKey::Channel(k) => self.receiver.channel(k, buf), @@ -1139,7 +1139,7 @@ impl OutputGraph { for node in &mut self.nodes { match node { DagNode::Source(s) => s.fill_block(now), - DagNode::Producer(p) => p.process(), + DagNode::Producer(p) => p.process(now), DagNode::Effect(_) | DagNode::Consumer(_) => {} } } diff --git a/src-tauri/src/audio/stream_recv.rs b/src-tauri/src/audio/stream_recv.rs index 31ef80c1..039879b5 100644 --- a/src-tauri/src/audio/stream_recv.rs +++ b/src-tauri/src/audio/stream_recv.rs @@ -4,10 +4,12 @@ //! *event-driven* (on packet arrival) straight into each consumer's per-channel //! ring -- there is no fan-out timer and no second buffer at a different clock. //! Each output consumer resamples on its own audio-clock thread with a -//! fixed-OUTPUT resampler (one block per callback), and a slow proportional -//! loop nudges the resample ratio to hold the ring near a target fill. So clock -//! drift is absorbed continuously by the resampler, never by dropping/inserting -//! samples -- which is what produced the "needle" discontinuities before. +//! fixed-OUTPUT resampler (one block per callback), and a slow PI loop +//! (`drift_loop`) nudges the resample ratio to hold the ring near a target +//! fill, reading it against the packets' write timing so their 20 ms arrival +//! saw is not mistaken for drift. So clock drift is absorbed continuously by +//! the resampler, never by dropping/inserting samples -- which is what +//! produced the "needle" discontinuities before. //! //! Channels of one source stay in phase by position, not by arrival: each push //! carries the `seq` it ends at, losses are pushed as concealment of the exact @@ -23,7 +25,9 @@ use std::sync::{Arc, Mutex, Weak}; use rtrb::{Consumer, Producer, RingBuffer}; use crate::audio::adaptive_depth::{DepthEstimator, OutageJudge}; +use crate::audio::drift_loop::{ArrivalClock, DriftLoop}; use crate::audio::health; +use crate::audio::input_bridge::{now_secs, WriteClock}; use crate::audio::resample::MultiResamplerOut; use crate::audio::streams::bulk_push_counted; @@ -46,25 +50,12 @@ const TARGET_SAFETY: usize = 240; // ~5 ms const JITTER_WINDOW_MS: f64 = 500.0; /// A backlog jump beyond this is a re-prime refill, not drift. const TARGET_EVENT_MAX: usize = 4_800; // ~100 ms - -/// Proportional gain: backlog error (samples) -> ratio correction. A varying -/// resample ratio IS pitch modulation, so the loop stays far slower and far -/// narrower than the jitter it rides on: corrections clamp to +-2000 ppm (~3.5 -/// cents) and jitter is absorbed by the buffer, never by the ratio -- otherwise -/// bursty links produce audible wow. Against the backlog integrator this gain -/// settles in 1/(KP*SR) ~ 21 s, slow next to the ~3 s EMA below and so stable, -/// yet quick enough to actually hand back depth the target gave up. A tenth of -/// it could only track oscillator drift: the buffer would keep whatever a spike -/// added for the rest of the session. Only realtime consumers steer the ratio, -/// so recordings never see it. -const DRIFT_KP: f64 = 1.0e-6; -const DRIFT_MIN: f64 = 0.998; -const DRIFT_MAX: f64 = 1.002; -/// EMA smoothing for the backlog the drift controller sees (~3 s time constant) -/// so it tracks real clock drift (slow) and ignores per-block fill ripple. -const DRIFT_BACKLOG_ALPHA: f64 = 0.007; -/// No correction while the smoothed backlog is this close to target. -const DRIFT_DEADBAND: f64 = 240.0; +/// The depth primed from `TARGET_INIT` is corrected once, when measured, by +/// splices of at most this many samples under a crossfade of `SPLICE_FADE`, +/// one per `SPLICE_EVERY_MS` at most. +const SPLICE_MAX: usize = 64; +const SPLICE_FADE: usize = 32; +const SPLICE_EVERY_MS: usize = 21; /// Time without a single new sample before a channel counts as gone (the same /// window the DSP sources call a stall). A channel the sender never transmits @@ -85,6 +76,8 @@ pub struct ChannelFeed { prods: Mutex>>, state: Mutex, pub sample_rate: Arc, + /// When the decode task pushed, for the consumers' drift loops. + clock: Arc, } #[derive(Default)] @@ -131,6 +124,19 @@ pub fn broadcast_push_sr( samples: &[f32], sample_rate: u32, ) { + broadcast_push_at(broadcast, seq, packets, samples, sample_rate, now_secs()); +} + +/// `broadcast_push_sr` at `at` seconds on `input_bridge::now_secs`'s clock. +fn broadcast_push_at( + broadcast: &ChannelBroadcast, + seq: u16, + packets: u16, + samples: &[f32], + sample_rate: u32, + at: f64, +) { + broadcast.clock.record(samples.len(), at); if sample_rate > 0 { broadcast.sample_rate.store(sample_rate, Ordering::Relaxed); } @@ -175,6 +181,7 @@ pub struct PlaybackTap { last_ratio: f64, realtime: bool, drift: Arc, + clock: Arc, in_buf: Vec, pub scratch: Vec, pub valid: usize, @@ -208,6 +215,7 @@ impl PlaybackTap { primed: bool, drift: Arc, source_sr: Arc, + clock: Arc, ) -> Self { let in_sr = source_sr.load(Ordering::Relaxed).max(1); let base_ratio = rate as f64 / in_sr as f64; @@ -224,7 +232,8 @@ impl PlaybackTap { last_ratio: base_ratio, realtime, drift, - in_buf: Vec::with_capacity(4096), + clock, + in_buf: Vec::with_capacity(4096 + SPLICE_MAX), scratch: Vec::with_capacity(block_frames), valid: 0, primed, @@ -295,9 +304,10 @@ impl PlaybackTap { } /// Produce one output block into `scratch` (valid = its length), resampling - /// 48 kHz -> consumer rate at the drift-adjusted ratio. Returns the sample - /// count (0 = emit silence after a sustained network underrun). - fn fill_block(&mut self) -> usize { + /// 48 kHz -> consumer rate at the drift-adjusted ratio, `splice` samples + /// cut out of it under a crossfade into the block's tail. Returns the + /// sample count (0 = emit silence after a sustained network underrun). + fn fill_block(&mut self, splice: usize) -> usize { let in_sr = self.source_sr.load(Ordering::Relaxed); if in_sr != 0 && in_sr != self.current_source_sr { self.current_source_sr = in_sr; @@ -321,14 +331,30 @@ impl PlaybackTap { // Real network underrun: conceal (fade), don't step to silence. return self.conceal(); } + let splice = if self.consumer.slots() >= need + splice { + splice + } else { + 0 + }; self.in_buf.clear(); - if let Ok(chunk) = self.consumer.read_chunk(need) { + if let Ok(chunk) = self.consumer.read_chunk(need + splice) { let (a, b) = chunk.as_slices(); self.in_buf.extend_from_slice(a); self.in_buf.extend_from_slice(b); chunk.commit_all(); - self.popped += need as u64; + self.popped += (need + splice) as u64; + } + if splice > 0 { + // The block's tail blends into the stream `splice` further on, so + // it ends exactly where the next block picks up. + let fade = SPLICE_FADE.min(need); + for i in 0..fade { + let j = need - fade + i; + let w = (i + 1) as f32 / fade as f32; + self.in_buf[j] = self.in_buf[j] * (1.0 - w) + self.in_buf[j + splice] * w; + } + self.in_buf.truncate(need); } self.scratch.clear(); if self @@ -479,6 +505,7 @@ impl FanoutRegistry { false, drift.clone(), bc.sample_rate.clone(), + bc.clock.clone(), ), ); } @@ -513,6 +540,7 @@ impl FanoutRegistry { prods: Mutex::new(Vec::new()), state: Mutex::new(FeedState::default()), sample_rate: Arc::new(AtomicU32::new(SR)), + clock: Arc::new(WriteClock::default()), }); let mut consumers = self.consumers.lock().unwrap(); consumers.retain(|c| c.taps.strong_count() > 0); @@ -568,6 +596,7 @@ impl FanoutRegistry { primed, c.drift.clone(), bc.sample_rate.clone(), + bc.clock.clone(), ), ); } @@ -635,7 +664,17 @@ pub struct ChannelReceiver { /// the arrival saw's dip, since priming ends right after a packet. start: Cell, ever_primed: Cell, - avg_backlog: Cell, + /// The startup depth correction has been decided; what is left of it. + settled: Cell, + owed: Cell, + cooldown: Cell, + cooldown_blocks: usize, + drift_loop: std::cell::RefCell, + /// Delivery timing of the source the loop follows. + arrival: std::cell::RefCell, + /// That source and its rate. Following another, or the same one after it + /// refilled, starts the loop settling afresh. + steered: Cell>, // Last emitted mix, held when the tap map is briefly locked for registration // so a lock miss is an inaudible repeat rather than a silent click. last_mix: std::cell::RefCell>, @@ -648,11 +687,18 @@ pub struct ChannelReceiver { struct GroupPlan { id: u64, min_backlog: usize, + /// The emptiest channel's write count and time, and its rate. + written: (u64, f64), + rate: u32, + /// Primed this block, after filling from empty. + refilled: bool, /// Least any channel of the source received since the last block. arrived: usize, need: usize, primed: bool, trim: usize, + /// Samples to splice out of this block under a crossfade. + splice: usize, hold: bool, conceal: bool, } @@ -679,7 +725,13 @@ impl ChannelReceiver { starving: Cell::new(false), start: Cell::new(TARGET_INIT), ever_primed: Cell::new(false), - avg_backlog: Cell::new(-1.0), + settled: Cell::new(false), + owed: Cell::new(0), + cooldown: Cell::new(0), + cooldown_blocks: (SPLICE_EVERY_MS * SR as usize / 1000 / need.max(1)).max(1), + drift_loop: std::cell::RefCell::new(DriftLoop::new(SR, need.max(1))), + arrival: std::cell::RefCell::new(ArrivalClock::new(SR)), + steered: Cell::new(None), last_mix: std::cell::RefCell::new(vec![0.0; handle.block_frames * 2]), plans: std::cell::RefCell::new(Vec::with_capacity(8)), } @@ -687,7 +739,13 @@ impl ChannelReceiver { /// Resample one block from every tap into its `scratch` and sum into `mix`. /// Real-time consumers also adapt the buffer depth and drift ratio here. + #[cfg(test)] pub fn mix_block(&self, mix: &mut [f32]) { + self.mix_block_at(mix, now_secs()); + } + + /// `mix_block` at `now` seconds on `input_bridge::now_secs`'s clock. + pub fn mix_block_at(&self, mix: &mut [f32], now: f64) { let frames = self.block_frames; // The node's width is whatever its graph resolved to, not always stereo. let width = (mix.len() / frames).max(1); @@ -720,6 +778,10 @@ impl ChannelReceiver { } match plans.iter_mut().find(|p| p.id == tap.group) { Some(p) => { + if backlog < p.min_backlog { + p.written = tap.clock.read(); + p.rate = tap.current_source_sr; + } p.min_backlog = p.min_backlog.min(backlog); p.arrived = p.arrived.min(tap.arrived); p.need = p.need.max(need); @@ -728,10 +790,14 @@ impl ChannelReceiver { None => plans.push(GroupPlan { id: tap.group, min_backlog: backlog, + written: tap.clock.read(), + rate: tap.current_source_sr, + refilled: false, arrived: tap.arrived, need, primed: tap.primed, trim: 0, + splice: 0, hold: false, conceal: false, }), @@ -750,6 +816,7 @@ impl ChannelReceiver { continue; } p.primed = true; + p.refilled = true; } // One channel short of a block stalls the whole source: a channel // that popped while a sibling concealed would sit a block ahead of @@ -791,8 +858,11 @@ impl ChannelReceiver { .min_by_key(|p| p.min_backlog) .map_or(0, |p| p.arrived); self.account(starving, live.map(|p| (p.min_backlog, p.need)), arrived); - if let Some(p) = live { - self.steer(p.min_backlog); + let live = live.map(|p| p.id); + match plans.iter_mut().find(|p| Some(p.id) == live) { + Some(p) if !p.conceal => p.splice = self.steer(p, now), + Some(_) => {} + None => self.steered.set(None), } } if plans.iter().any(|p| !p.hold) { @@ -824,7 +894,7 @@ impl ChannelReceiver { } tap.primed = true; tap.trim(plan.trim); - let n = tap.fill_block().min(frames); + let n = tap.fill_block(plan.splice).min(frames); for (frame, &v) in mix.chunks_mut(width).zip(tap.scratch[..n].iter()) { for s in frame.iter_mut() { *s += v; @@ -870,29 +940,57 @@ impl ChannelReceiver { self.start.set((target + dip).min(TARGET_MAX)); } - /// Drift ratio from the current backlog. - fn steer(&self, backlog: usize) { - let target = self.target.load(Ordering::Relaxed) as usize; - // Drive the ratio from a smoothed backlog so it tracks real drift (slow) - // and ignores per-block fill ripple (fast). A jump far beyond jitter - // scale is a re-prime refill, not drift -- restart the EMA there so the - // controller doesn't chase the refill as a huge error. - let prev = self.avg_backlog.get(); - let avg = if prev < 0.0 || (backlog as f64 - prev).abs() > TARGET_EVENT_MAX as f64 { - backlog as f64 - } else { - prev + DRIFT_BACKLOG_ALPHA * (backlog as f64 - prev) - }; - self.avg_backlog.set(avg); - - let e = avg - target as f64; - let e = if e.abs() < DRIFT_DEADBAND { - 0.0 - } else { - e - DRIFT_DEADBAND.copysign(e) - }; - let d = (1.0 - DRIFT_KP * e).clamp(DRIFT_MIN, DRIFT_MAX); + /// Drift ratio for the emptiest playing source. Its fill plus what its + /// sender has made but not yet pushed is the fill right after a packet, + /// the top of the arrival saw, which is where priming leaves it: the loop + /// holds it at the start level. Returns the samples to splice out of this + /// block, which only the one startup correction ever asks for; the loop + /// holds still while it runs. + fn steer(&self, p: &GroupPlan, now: f64) -> usize { + let mut drift = self.drift_loop.borrow_mut(); + let mut arrival = self.arrival.borrow_mut(); + let following = self.steered.get(); + if p.refilled || following != Some((p.id, p.rate)) { + if following.map(|(_, rate)| rate) != Some(p.rate) { + *arrival = ArrivalClock::new(p.rate); + } + arrival.reset(); + drift.restart(); + self.steered.set(Some((p.id, p.rate))); + } + arrival.observe(p.written.0, p.written.1); + let start = self.start.get() as f64; + let error = p.min_backlog as f64 + arrival.pending(now, start) - start; + if !self.settled.get() { + if !self.depth.borrow().is_measured() { + drift.restart(); + return 0; + } + self.settled.set(true); + self.owed.set(error.max(0.0).round() as usize); + } + if self.owed.get() > 0 { + // The queue moves by design meanwhile; a window holding that + // would read it as jitter. + self.depth.borrow_mut().discard_window(); + drift.restart(); + let wait = self.cooldown.get(); + if wait > 0 { + self.cooldown.set(wait - 1); + return 0; + } + let n = self.owed.get().min(SPLICE_MAX); + if p.min_backlog < p.need + n { + return 0; + } + self.owed.set(self.owed.get() - n); + self.cooldown.set(self.cooldown_blocks); + return n; + } + let u = drift.update(error); + let d = 1.0 / (1.0 + u); self.drift.store((d as f32).to_bits(), Ordering::Relaxed); + 0 } /// Copy one channel's already-resampled scratch into `out`. @@ -959,6 +1057,8 @@ mod tests { gaps: usize, target: usize, target_after_outage: usize, + /// Largest ring fill seen after settling. + max_fill: usize, } /// One 48 kHz channel of 20 ms packets of 0.5 into a consumer rendering @@ -999,6 +1099,7 @@ mod tests { gaps: 0, target: 0, target_after_outage: 0, + max_fill: 0, }; let (mut k, mut t) = (0u32, 0.0); let mut mix = vec![0.0f32; block]; @@ -1010,15 +1111,22 @@ mod tests { let dropped = outage.is_some_and(|(a, b)| sent >= a && sent < b) || quiet.iter().any(|&(a, b)| sent >= a && sent < b); if !dropped { - broadcast_push(&bc, k as u16, 1, &packet); + broadcast_push_at(&bc, k as u16, 1, &packet, SR, due); } k += 1; continue; } - recv.mix_block(&mut mix); + recv.mix_block_at(&mut mix, t); let in_outage = outage.is_some_and(|(a, b)| t >= a && t < b + 1.0); if t > settle && !in_outage { run.gaps += mix.iter().filter(|s| (*s - 0.5).abs() > 1e-3).count(); + let fill = recv + .taps + .lock() + .unwrap() + .get("0") + .map_or(0, |t| t.backlog()); + run.max_fill = run.max_fill.max(fill); } if let Some((_, b)) = outage { if t >= b + 0.3 && run.target_after_outage == 0 { @@ -1053,6 +1161,79 @@ mod tests { } } + #[test] + fn a_drifting_sender_is_absorbed_without_gaps_or_buildup() { + // 300 ppm is far beyond real crystal error: two minutes drift ~1700 + // samples, all of which the ratio has to take up. + for ppm in [-300.0, 300.0] { + let drift = move |k: u32| -(k as f64 * 0.02) * ppm * 1e-6; + let steady = net_sim(64, 120.0, 30.0, |_| 0.0, None); + let r = net_sim(64, 120.0, 30.0, drift, None); + assert_eq!(r.gaps, 0, "{ppm} ppm: gaps"); + assert!( + r.max_fill <= steady.max_fill + 240, + "{ppm} ppm: fill reached {} against {} steady", + r.max_fill, + steady.max_fill + ); + } + } + + #[test] + fn the_startup_depth_is_corrected_quickly_and_seamlessly() { + // A 60 Hz sine: one period is 800 samples, so its phase is exact. + // Clean, it moves < 0.0071 per sample; a hard 64-sample cut would + // jump it by up to 0.45. + const PACKET: usize = 960; + let sine = |n: usize| ((n % 800) as f64 * std::f64::consts::TAU / 800.0).sin() as f32 * 0.9; + let block = 64; + let reg = FanoutRegistry::default(); + let handle = reg.register_consumer(48_000, block, true); + let bc = reg.attach_channel("0".into(), 0); + let recv = ChannelReceiver::new(handle); + let (mut k, mut t) = (0u32, 0.0); + let mut mix = vec![0.0f32; block]; + let mut packet = vec![0.0f32; PACKET]; + let mut played = Vec::new(); + let mut late_fill = 0; + while t < 12.0 { + let due = k as f64 * PACKET as f64 / SR as f64; + if due <= t { + for (i, s) in packet.iter_mut().enumerate() { + *s = sine(k as usize * PACKET + i); + } + broadcast_push_at(&bc, k as u16, 1, &packet, SR, due); + k += 1; + continue; + } + recv.mix_block_at(&mut mix, t); + if t > 0.5 { + played.extend_from_slice(&mix); + } + if t > 6.0 { + let fill = recv + .taps + .lock() + .unwrap() + .get("0") + .map_or(0, |t| t.backlog()); + late_fill = late_fill.max(fill); + } + t += block as f64 / SR as f64; + } + let worst = played + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max); + assert!(worst < 0.03, "a step of {worst}"); + // Primed from the 60 ms prior; within seconds of measuring, the + // buffer holds about one packet plus the safety margin. + assert!( + late_fill <= PACKET + TARGET_MIN + TARGET_SAFETY, + "still {late_fill} samples buffered" + ); + } + #[test] fn jittery_link_buys_depth_instead_of_gaps() { // Wi-Fi-like: every packet up to 40 ms late. From 42fa839f4bb5d4ca6b6145c20f8a8dfe3fcadc58 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:28:32 +0300 Subject: [PATCH 08/51] chore(deps): cpal 0.18.2, device xruns no longer end a stream --- src-tauri/Cargo.lock | 242 +++++++++--------- src-tauri/Cargo.toml | 2 +- src-tauri/src/audio/device/macos.rs | 2 +- src-tauri/src/audio/device/mod.rs | 10 + src-tauri/src/audio/device/windows.rs | 6 +- src-tauri/src/audio/health.rs | 6 + src-tauri/src/audio/pipeline/input/macos.rs | 5 +- src-tauri/src/audio/pipeline/input/windows.rs | 2 +- src-tauri/src/audio/pipeline/native.rs | 6 +- src-tauri/src/audio/pipeline/output/macos.rs | 10 +- .../src/audio/pipeline/output/windows.rs | 7 +- src-tauri/src/audio/streams/cpal_stream.rs | 57 +++-- src-tauri/src/error.rs | 3 + 13 files changed, 206 insertions(+), 152 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1bbe2a0d..2af99235 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.11.1", @@ -105,9 +105,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -427,7 +427,7 @@ dependencies = [ "dbus 0.6.5", "libc", "log", - "mach2", + "mach2 0.4.3", "windows-sys 0.61.2", ] @@ -786,8 +786,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -1091,45 +1089,46 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" -dependencies = [ - "bitflags 1.3.2", - "core-foundation-sys", - "coreaudio-sys", -] - -[[package]] -name = "coreaudio-sys" -version = "0.2.17" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bindgen", + "bitflags 2.11.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] name = "cpal" -version = "0.15.3" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" dependencies = [ "alsa", - "core-foundation-sys", + "block2", "coreaudio-rs", "dasp_sample", - "jni 0.21.1", + "jni 0.22.4", "js-sys", "libc", - "mach2", - "ndk 0.8.0", + "mach2 0.6.0", + "ndk", "ndk-context", - "oboe", - "wasm-bindgen", - "wasm-bindgen-futures", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", "web-sys", - "windows 0.54.0", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -3205,16 +3204,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.98" @@ -3545,6 +3534,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3759,20 +3754,6 @@ dependencies = [ "serde", ] -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.11.1", - "jni-sys 0.3.1", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", -] - [[package]] name = "ndk" version = "0.9.0" @@ -3782,7 +3763,7 @@ dependencies = [ "bitflags 2.11.1", "jni-sys 0.3.1", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -3794,15 +3775,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys 0.3.1", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4061,6 +4033,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -4082,6 +4065,7 @@ dependencies = [ "objc2", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -4293,29 +4277,6 @@ dependencies = [ "objc", ] -[[package]] -name = "oboe" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" -dependencies = [ - "jni 0.21.1", - "ndk 0.8.0", - "ndk-context", - "num-derive", - "num-traits", - "oboe-sys", -] - -[[package]] -name = "oboe-sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" -dependencies = [ - "cc", -] - [[package]] name = "ogg" version = "0.9.2" @@ -6218,7 +6179,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk 0.9.0", + "ndk", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -6783,8 +6744,8 @@ dependencies = [ "jni 0.21.1", "libc", "log", - "ndk 0.9.0", - "ndk-sys 0.6.0+11769913", + "ndk", + "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", @@ -8837,25 +8798,27 @@ dependencies = [ [[package]] name = "windows" -version = "0.54.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", ] [[package]] name = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -8868,13 +8831,12 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.54.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core 0.62.2", ] [[package]] @@ -8887,7 +8849,20 @@ dependencies = [ "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -8898,7 +8873,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -8946,12 +8932,13 @@ dependencies = [ ] [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core 0.62.2", + "windows-link 0.2.1", ] [[package]] @@ -8963,6 +8950,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -8972,6 +8968,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -9098,6 +9103,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -9450,7 +9464,7 @@ dependencies = [ "javascriptcore-rs", "jni 0.21.1", "libc", - "ndk 0.9.0", + "ndk", "objc2", "objc2-app-kit", "objc2-core-foundation", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6dc588d5..2a787934 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -76,7 +76,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" url = "2" thiserror = "1" -cpal = "0.15" +cpal = "0.18.2" rtrb = "0.3" rubato = "0.16" audio_thread_priority = "0.36" diff --git a/src-tauri/src/audio/device/macos.rs b/src-tauri/src/audio/device/macos.rs index 52aa702c..5e1440d0 100644 --- a/src-tauri/src/audio/device/macos.rs +++ b/src-tauri/src/audio/device/macos.rs @@ -65,7 +65,7 @@ pub fn find(kind: DeviceKind, id: &str) -> AppResult { let matches: Vec = host .devices() .map_err(|e| AppError::Host(e.to_string()))? - .filter(|d| d.name().map(|n| n == id).unwrap_or(false)) + .filter(|d| super::cpal_name(d).as_deref() == Some(id)) .collect(); if matches.len() < 2 { return matches diff --git a/src-tauri/src/audio/device/mod.rs b/src-tauri/src/audio/device/mod.rs index 06b6fe0a..38dfaffb 100644 --- a/src-tauri/src/audio/device/mod.rs +++ b/src-tauri/src/audio/device/mod.rs @@ -37,6 +37,16 @@ mod windows; #[cfg(target_os = "windows")] pub use windows::{device_info, find, list_inputs, list_outputs}; +/// The name a device goes by, which is also its id in a saved graph. +/// `description().name()` reads the property cpal 0.15's `Device::name` did +/// (CoreAudio `kAudioDevicePropertyDeviceNameCFString`, WASAPI +/// `FriendlyName`), so saved ids keep resolving. +#[cfg(not(target_os = "linux"))] +pub fn cpal_name(device: &cpal::Device) -> Option { + use cpal::traits::DeviceTrait; + device.description().ok().map(|d| d.name().to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/audio/device/windows.rs b/src-tauri/src/audio/device/windows.rs index 3e6645dd..84b7ff5e 100644 --- a/src-tauri/src/audio/device/windows.rs +++ b/src-tauri/src/audio/device/windows.rs @@ -14,7 +14,7 @@ pub fn device_info(kind: DeviceKind, name: &str) -> AppResult } .map_err(|e| AppError::Device(format!("default config for {name:?}: {e}")))?; Ok(NativeDeviceInfo { - sample_rate: config.sample_rate().0, + sample_rate: config.sample_rate(), channels: config.channels(), sample_format: "f32", }) @@ -44,14 +44,14 @@ pub fn find(kind: DeviceKind, id: &str) -> AppResult { } .map_err(|e| AppError::Host(e.to_string()))?; devices - .find(|d| d.name().map(|n| n == id).unwrap_or(false)) + .find(|d| super::cpal_name(d).as_deref() == Some(id)) .ok_or_else(|| AppError::Device(format!("device not found: {id}"))) } fn unique_named(devices: impl Iterator, kind: DeviceKind) -> Vec { let mut seen = HashSet::new(); devices - .filter_map(|d| d.name().ok()) + .filter_map(|d| super::cpal_name(&d)) .filter(|n| seen.insert(n.clone())) .map(|name| DeviceInfo { id: name.clone(), diff --git a/src-tauri/src/audio/health.rs b/src-tauri/src/audio/health.rs index 2e939375..ce2321cd 100644 --- a/src-tauri/src/audio/health.rs +++ b/src-tauri/src/audio/health.rs @@ -40,6 +40,11 @@ counters! { CLOCK_LATE_MAX_US, /// Fatal cpal stream errors reported via the error callback. STREAM_ERRORS, + /// Device IO overloads cpal reported (a CoreAudio HAL overload, a WASAPI + /// capture discontinuity). The stream plays on. cpal drops a report that + /// lands while the previous one is still being handled, so this is a + /// lower bound. + DEVICE_XRUNS, /// Samples zero-filled on the RT thread because an offloaded effect's /// worker had not returned the block in time. OFFLOAD_STARVED_SAMPLES, @@ -95,6 +100,7 @@ mod tests { "CLOCK_LATE_BLOCKS", "CLOCK_LATE_MAX_US", "STREAM_ERRORS", + "DEVICE_XRUNS", "OFFLOAD_STARVED_SAMPLES", "OFFLOAD_RESYNC_DROPPED_SAMPLES", "OFFLOAD_RING_OVERRUN_SAMPLES", diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs index 45535a85..668c6ef9 100644 --- a/src-tauri/src/audio/pipeline/input/macos.rs +++ b/src-tauri/src/audio/pipeline/input/macos.rs @@ -74,7 +74,6 @@ pub(in crate::audio::pipeline) fn configure_io( block_frames: usize, pipeline_rate: u32, ) -> Option { - use cpal::traits::DeviceTrait; let ResolvedInput::Cpal { device, sample_rate, @@ -83,7 +82,7 @@ pub(in crate::audio::pipeline) fn configure_io( else { return None; }; - let name = device.name().ok()?; + let name = crate::audio::device::cpal_name(device)?; let requested = device_block(block_frames, pipeline_rate, *sample_rate); let granted = macos_hal::set_buffer_frames(DeviceKind::Input, &name, requested); tracing::info!(device = %name, requested, granted, "microphone buffer size"); @@ -137,7 +136,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( let dead_cb = dead.clone(); let app_err = app.clone(); let node_id_cb = node_id.to_string(); - let err_cb = move |e: cpal::StreamError| { + let err_cb = move |e: cpal::Error| { if dead_cb.swap(true, Ordering::Relaxed) { return; } diff --git a/src-tauri/src/audio/pipeline/input/windows.rs b/src-tauri/src/audio/pipeline/input/windows.rs index e78e84f7..2005b8f4 100644 --- a/src-tauri/src/audio/pipeline/input/windows.rs +++ b/src-tauri/src/audio/pipeline/input/windows.rs @@ -87,7 +87,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( let dead_cb = dead.clone(); let app_err = app.clone(); let node_id_cb = node_id.to_string(); - let err_cb = move |e: cpal::StreamError| { + let err_cb = move |e: cpal::Error| { if dead_cb.swap(true, Ordering::Relaxed) { return; } diff --git a/src-tauri/src/audio/pipeline/native.rs b/src-tauri/src/audio/pipeline/native.rs index a143ddb8..069a7f1a 100644 --- a/src-tauri/src/audio/pipeline/native.rs +++ b/src-tauri/src/audio/pipeline/native.rs @@ -53,7 +53,7 @@ pub(in crate::audio::pipeline) fn native_config( Ok(NativeConfig { config: cpal::StreamConfig { channels, - sample_rate: cpal::SampleRate(hal.sample_rate), + sample_rate: hal.sample_rate, buffer_size: cpal::BufferSize::Default, }, sample_format: cpal::SampleFormat::F32, @@ -78,12 +78,12 @@ pub(in crate::audio::pipeline) fn native_config( } .map_err(|e| AppError::Device(format!("default config for {name:?}: {e}")))?; - let sample_rate = supported.sample_rate().0; + let sample_rate = supported.sample_rate(); let channels = supported.channels(); Ok(NativeConfig { config: cpal::StreamConfig { channels, - sample_rate: cpal::SampleRate(sample_rate), + sample_rate, buffer_size: cpal::BufferSize::Default, }, sample_format: supported.sample_format(), diff --git a/src-tauri/src/audio/pipeline/output/macos.rs b/src-tauri/src/audio/pipeline/output/macos.rs index 56fc37e0..8461f788 100644 --- a/src-tauri/src/audio/pipeline/output/macos.rs +++ b/src-tauri/src/audio/pipeline/output/macos.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::thread; use std::time::Duration; -use cpal::traits::{DeviceTrait, StreamTrait}; +use cpal::traits::StreamTrait; use serde_json::json; use tauri::{AppHandle, Emitter}; use tracing::{error, info, warn}; @@ -71,9 +71,8 @@ pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult< }) } -// Substring match on cpal's stable Display -- AppError flattens the variant. fn is_device_not_available(e: &AppError) -> bool { - matches!(e, AppError::Stream(s) if s.contains("no longer available")) + matches!(e, AppError::DeviceUnavailable(_)) } pub(in crate::audio::pipeline) fn start_speaker_stream( @@ -83,7 +82,8 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( meter: crate::audio::effects::MeterHandle, app: &AppHandle, ) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc, SpeakerIo)> { - let device_name = spec.device.name().unwrap_or_else(|_| "".into()); + let device_name = + crate::audio::device::cpal_name(&spec.device).unwrap_or_else(|| "".into()); info!( device = %device_name, sample_rate = spec.sample_rate, @@ -127,7 +127,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( let app_err = app.clone(); let dead_cb = dead.clone(); let node_id_cb = node_id.to_string(); - let err_cb = move |e: cpal::StreamError| { + let err_cb = move |e: cpal::Error| { if dead_cb.swap(true, Ordering::Relaxed) { return; } diff --git a/src-tauri/src/audio/pipeline/output/windows.rs b/src-tauri/src/audio/pipeline/output/windows.rs index e5e67484..91ca0a52 100644 --- a/src-tauri/src/audio/pipeline/output/windows.rs +++ b/src-tauri/src/audio/pipeline/output/windows.rs @@ -1,7 +1,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use cpal::traits::{DeviceTrait, StreamTrait}; +use cpal::traits::StreamTrait; use serde_json::json; use tauri::{AppHandle, Emitter}; use tracing::{error, info, warn}; @@ -63,7 +63,8 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( meter: crate::audio::effects::MeterHandle, app: &AppHandle, ) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc, SpeakerIo)> { - let device_name = spec.device.name().unwrap_or_else(|_| "".into()); + let device_name = + crate::audio::device::cpal_name(&spec.device).unwrap_or_else(|| "".into()); info!( device = %device_name, sample_rate = spec.sample_rate, @@ -78,7 +79,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( let app_err = app.clone(); let dead_cb = dead.clone(); let node_id_cb = node_id.to_string(); - let err_cb = move |e: cpal::StreamError| { + let err_cb = move |e: cpal::Error| { if dead_cb.swap(true, Ordering::Relaxed) { return; } diff --git a/src-tauri/src/audio/streams/cpal_stream.rs b/src-tauri/src/audio/streams/cpal_stream.rs index 4f604b90..ffcd2e85 100644 --- a/src-tauri/src/audio/streams/cpal_stream.rs +++ b/src-tauri/src/audio/streams/cpal_stream.rs @@ -15,6 +15,7 @@ use cpal::{Sample, SampleFormat, StreamConfig}; use tracing::error; use crate::audio::effects::{update_meter, MeterHandle}; +use crate::audio::health; use crate::audio::input_bridge::BroadcastRx; use crate::error::{AppError, AppResult}; @@ -28,7 +29,7 @@ pub fn build_input_stream( src_channels: usize, bridge: BroadcastRx, meter: Option, - err_cb: impl FnMut(cpal::StreamError) + Send + 'static, + err_cb: impl FnMut(cpal::Error) + Send + 'static, ) -> AppResult { match sample_format { SampleFormat::F32 => { @@ -67,7 +68,7 @@ fn build_input_typed( src_channels: usize, mut bridge: BroadcastRx, meter: Option, - err_cb: impl FnMut(cpal::StreamError) + Send + 'static, + err_cb: impl FnMut(cpal::Error) + Send + 'static, ) -> AppResult where T: Sample + cpal::SizedSample + Send + 'static, @@ -76,7 +77,7 @@ where let mut staging: Vec = vec![0.0; 16384]; let stream = device .build_input_stream::( - config, + *config, move |data, _| { bridge.apply_commands(); if src_channels == 0 || data.is_empty() { @@ -94,13 +95,11 @@ where } bridge.broadcast(&staging[..needed]); }, - err_cb, + fatal_only(err_cb), None, ) - .map_err(|e| AppError::Stream(format!("input build: {e}")))?; - stream - .play() - .map_err(|e| AppError::Stream(format!("input play: {e}")))?; + .map_err(|e| stream_error("input build", e))?; + stream.play().map_err(|e| stream_error("input play", e))?; Ok(stream) } @@ -111,7 +110,7 @@ pub fn build_output_stream( sample_format: SampleFormat, out_channels: usize, fill: F, - err_cb: impl FnMut(cpal::StreamError) + Send + 'static, + err_cb: impl FnMut(cpal::Error) + Send + 'static, ) -> AppResult where F: FnMut(&mut [f32], usize) + Send + 'static, @@ -148,7 +147,7 @@ fn build_output_typed( config: &StreamConfig, out_channels: usize, mut fill: F, - err_cb: impl FnMut(cpal::StreamError) + Send + 'static, + err_cb: impl FnMut(cpal::Error) + Send + 'static, ) -> AppResult where T: Sample + cpal::SizedSample + cpal::FromSample + Send + 'static, @@ -163,7 +162,7 @@ where let mut buf: Vec = vec![0.0; scratch_samples]; let stream = device .build_output_stream::( - config, + *config, move |data, _| { if out_channels == 0 || data.is_empty() { return; @@ -177,24 +176,46 @@ where } } }, - err_cb, + fatal_only(err_cb), None, ) .map_err(|e| { - let device_name = device.name().unwrap_or_else(|_| "".into()); + let device_name = + crate::audio::device::cpal_name(device).unwrap_or_else(|| "".into()); error!( device = %device_name, - requested_sample_rate = config.sample_rate.0, + requested_sample_rate = config.sample_rate, requested_channels = config.channels, buffer_size = ?config.buffer_size, cpal_error_variant = ?e, cpal_error_display = %e, "build_output_stream failed" ); - AppError::Stream(format!("output build: {e}")) + stream_error("output build", e) })?; - stream - .play() - .map_err(|e| AppError::Stream(format!("output play: {e}")))?; + stream.play().map_err(|e| stream_error("output play", e))?; Ok(stream) } + +/// Passes on only the errors that end the stream. A device overload leaves it +/// playing and is counted instead; on WASAPI capture it is reported from the +/// audio thread, so counting is all that path may do. +fn fatal_only( + mut err_cb: impl FnMut(cpal::Error) + Send + 'static, +) -> impl FnMut(cpal::Error) + Send + 'static { + move |e| { + if matches!(e.kind(), cpal::ErrorKind::Xrun) { + health::bump(&health::DEVICE_XRUNS, 1); + return; + } + err_cb(e) + } +} + +fn stream_error(what: &str, e: cpal::Error) -> AppError { + if matches!(e.kind(), cpal::ErrorKind::DeviceNotAvailable) { + AppError::DeviceUnavailable(format!("{what}: {e}")) + } else { + AppError::Stream(format!("{what}: {e}")) + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index f8076c34..19512220 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -11,6 +11,9 @@ pub enum AppError { #[error("audio stream error: {0}")] Stream(String), + #[error("audio device no longer available: {0}")] + DeviceUnavailable(String), + #[error("plugin error: {0}")] Plugin(String), From 83bb659425127c84c22234e9f572dfdcf693640f Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:26:00 +0300 Subject: [PATCH 09/51] fix(audio): offload gathers an effect's own blocks, noise suppressor reports its real latency --- docs/CONCEPT.md | 4 +- .../src/audio/effects/noise_suppressor.rs | 435 +++++++++++++----- src-tauri/src/audio/effects/offload.rs | 300 ++++++++++-- src-tauri/src/audio/plugins/host_api.rs | 6 +- 4 files changed, 579 insertions(+), 166 deletions(-) diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 7fccdbc3..7e29c93e 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -21,6 +21,8 @@ Inside cpal / SCK callbacks and `DspWorker::run`: - Syscalls (I/O, logging, IPC) - Non-deterministic latency ops +One exception: waking a sleeping helper thread with `Thread::unpark` (an offload worker after its input was pushed). It never blocks; when the thread is awake it is a single atomic swap, and otherwise one futex/ulock/`WakeByAddress` wake. Polling with `sleep` instead costs the worker up to a poll period of its deadline. + Shared RT↔UI state: `Arc` (f32-as-bits, `Ordering::Relaxed`). See `MeterHandle` / `EffectControl` in `audio/effects.rs`. Ring buffers: `rtrb` SPSC. Use `bulk_pop` / `bulk_push`, never per-sample loops. @@ -43,7 +45,7 @@ Without these, one chunk takes ~16 ms and the worker stalls. - Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. - Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. The network receiver (`stream_recv.rs`) runs the same loop against its packets' write timing, holding the fill right after a packet at its start level. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block. -- An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs at its own working block, declares it as latency for PDC, and the node shows it. +- An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs on an offload thread (`effects/offload.rs`). One that can only step in whole blocks of its own (a model hop) names that block, and the offload gathers exactly those before calling it; the effect never pads or buffers for it itself. The offload's pad is the least that never starves at the worst alignment of those blocks against the engine's, plus a 5 ms turnaround. The effect declares only its own latency (a model's algorithmic delay, a resampler's queues), the offload adds its pad, PDC aligns against the sum, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/src/audio/effects/noise_suppressor.rs b/src-tauri/src/audio/effects/noise_suppressor.rs index 502de1f9..234f4dc5 100644 --- a/src-tauri/src/audio/effects/noise_suppressor.rs +++ b/src-tauri/src/audio/effects/noise_suppressor.rs @@ -53,7 +53,8 @@ mod model { pub struct DfTract { pub hop_size: usize, - pub lookahead: usize, + pub fft_size: usize, + pub conv_lookahead: usize, pub min_db_thresh: f32, pub max_db_erb_thresh: f32, pub max_db_df_thresh: f32, @@ -73,9 +74,9 @@ use ndarray::Array2; use crate::audio::graph::NoiseSuppressorData; use crate::audio::graph::MAX_BUFFER_FRAMES; -use crate::audio::resample::MultiResampler; +use crate::audio::resample::{MultiResampler, MultiResamplerOut}; -use super::offload::{pad_frames, BlockProcessor, Offload}; +use super::offload::{BlockProcessor, Offload}; use super::util::load_f32; use super::{Effect, EffectControl}; @@ -83,7 +84,16 @@ const MODEL_SR: u32 = 48_000; // DfTract is mono-only here (df_states hardcoded to len 1); stereo corrupts the // signal proportionally to attenuation. Downmix in, fan mono back out to L/R. const CHANNELS: usize = 1; -const DOWN_CHUNK: usize = 512; +/// Slack each resampler's +-1 frame of jitter per hop needs, on the way in and +/// on the way out. +const RESAMPLE_SLACK: usize = 4; +/// DeepFilterNet treats a hop whose mean square is under this as silence: it +/// writes zeros and does not step its buffers, so what they held comes out at +/// the next louder hop, shifted in time. +const DF_SILENCE_GATE: f32 = 1e-7; +/// Below this limit DeepFilterNet passes the input through undelayed, which +/// would move the output by the model's latency whenever the limit crossed it. +const MIN_ATTEN_DB: f32 = 0.01; const CAP: usize = 8192; #[derive(Clone)] @@ -140,54 +150,37 @@ impl Params { struct SendModel(DfTract); unsafe impl Send for SendModel {} -// Resamples the output-rate signal to 48k for the model and back. Present only -// when the output rate isn't already 48k. +// Resamples the output-rate signal to 48k for the model and back, a hop at a +// time. Present only when the output rate isn't already 48k. struct Resample { - down: MultiResampler, + /// Exactly one model hop out per call; its input need wanders by a frame. + down: MultiResamplerOut, + /// One model hop in per call; its output wanders by a frame. up: MultiResampler, - down_in: VecDeque, - up_in: VecDeque, chunk: Vec, - scratch: Vec, -} - -impl Resample { - fn to_model(&mut self, block: &[f32], dst: &mut Vec) { - self.down_in.extend(block.iter().copied()); - while self.down_in.len() >= DOWN_CHUNK * 2 { - self.chunk.clear(); - for _ in 0..DOWN_CHUNK * 2 { - self.chunk.push(self.down_in.pop_front().unwrap()); - } - let _ = self.down.process_chunk(&self.chunk, dst); - } - } - - fn from_model(&mut self, enh48: &[f32], hop: usize, dst: &mut VecDeque) { - self.up_in.extend(enh48.iter().copied()); - while self.up_in.len() >= hop * 2 { - self.chunk.clear(); - for _ in 0..hop * 2 { - self.chunk.push(self.up_in.pop_front().unwrap()); - } - self.scratch.clear(); - let _ = self.up.process_chunk(&self.chunk, &mut self.scratch); - dst.extend(self.scratch.iter().copied()); - } - } } struct ModelState { model: SendModel, + /// The model's hop at 48 kHz. hop: usize, + /// Output frames this state delays by: the model's own latency, the + /// resamplers', and the slack queued ahead on either side. latency: usize, - in_mono: VecDeque, + /// The model hop in output frames: what one call normally carries. + hop_out: usize, + /// Interleaved stereo at the output rate, waiting to fill a hop. + in_q: VecDeque, noisy: Array2, enh: Array2, resample: Option, + /// One model hop, interleaved stereo at 48 kHz. mid48: Vec, enh48: Vec, out: VecDeque, + /// Output samples asked for before they were ready. + #[cfg(test)] + short: usize, } impl NoiseSuppressorEffect { @@ -220,17 +213,15 @@ impl NoiseSuppressorEffect { realtime: bool, ) -> Self { let initial = Params::load(&ctl); - let Some(state) = ModelState::build(initial, sample_rate) else { + let Some(state) = ModelState::build(initial, sample_rate, realtime) else { return Self { backend: None, latency: 0, working_block: None, }; }; - let model_latency = state.latency; - // The model steps in whole hops at 48 kHz: that is its working block. - let hop = (state.hop * sample_rate as usize).div_ceil(MODEL_SR as usize); - let working_block = (hop > block_frames).then_some(hop); + let hop_out = state.hop_out; + let working_block = (hop_out > block_frames).then_some(hop_out); let worker = ModelWorker { ctl, state, @@ -239,25 +230,32 @@ impl NoiseSuppressorEffect { if !realtime { let out = Vec::with_capacity(MAX_BUFFER_FRAMES * 2); return Self { + latency: worker.state.latency, backend: Some(Backend::Inline { worker, out }), - latency: model_latency, working_block, }; } - // A worker that computes a hop at a time needs a hop of runway to do - // it in, so the pad never drops below one. - let pad = pad_frames(sample_rate, block_frames, hop); - match Offload::spawn("noise_suppressor", worker, 2, pad, sample_rate) { + let model_latency = worker.state.latency; + // The offload hands the model exactly one hop at a time. + match Offload::spawn( + "noise_suppressor", + worker, + 2, + Some(hop_out), + block_frames, + sample_rate, + ) { Ok(offload) => Self { latency: model_latency + offload.latency_frames(), backend: Some(Backend::Offloaded(offload)), working_block, }, - Err(worker) => { + Err(mut worker) => { + worker.state.feed_any_size(); let out = Vec::with_capacity(MAX_BUFFER_FRAMES * 2); Self { + latency: worker.state.latency, backend: Some(Backend::Inline { worker, out }), - latency: model_latency, working_block, } } @@ -270,11 +268,13 @@ impl NoiseSuppressorEffect { self.working_block } } - impl ModelState { - fn build(p: Params, output_sr: u32) -> Option { + /// `hop_fed`: every call carries exactly one hop of output frames, as the + /// offload delivers them. Otherwise calls come in any size and a hop of + /// output is queued ahead to cover the one still being gathered. + fn build(p: Params, output_sr: u32, hop_fed: bool) -> Option { let mut rp = RuntimeParams::default_with_ch(CHANNELS); - rp.atten_lim_db = p.atten; + rp.atten_lim_db = p.atten.max(MIN_ATTEN_DB); rp.post_filter_beta = p.pf_beta; rp.post_filter = p.pf_beta > 0.0; rp.min_db_thresh = p.min_thresh; @@ -288,62 +288,146 @@ impl ModelState { } }; let hop = model.hop_size; - let lookahead = model.lookahead; - - let resample = if output_sr == MODEL_SR { - None + // Overlap-add holds back a window less a hop, and the model answers + // for the frame `conv_lookahead` hops behind the newest. + let model_delay48 = model.fft_size - hop + model.conv_lookahead * hop; + let to_out = |frames48: usize| { + (frames48 as u64 * output_sr as u64).div_ceil(MODEL_SR as u64) as usize + }; + let hop_out = to_out(hop); + let exact = (hop as u64 * output_sr as u64).is_multiple_of(MODEL_SR as u64); + + // Both resamplers align their output to their input, so their filters + // show up only as the queues below: the way in reads a filter's + // half-length ahead on its first hop, the way out holds that much + // back on its first. + let (resample, preroll, queued) = if output_sr == MODEL_SR { + (None, 0, 0) } else { - let down = MultiResampler::new(output_sr, MODEL_SR, DOWN_CHUNK, 2); + let down = MultiResamplerOut::new(output_sr, MODEL_SR, hop, 2); let up = MultiResampler::new(MODEL_SR, output_sr, hop, 2); - match (down, up) { - (Ok(down), Ok(up)) => Some(Resample { - down, - up, - down_in: VecDeque::with_capacity(CAP), - up_in: VecDeque::with_capacity(CAP), - chunk: Vec::with_capacity(DOWN_CHUNK * 2), - scratch: Vec::with_capacity(CAP), - }), + let (down, up) = match (down, up) { + (Ok(down), Ok(up)) => (down, up), (down, up) => { let e = down.err().or(up.err()).unwrap(); tracing::error!("NoiseSuppressor resampler init failed: {e}"); return None; } - } + }; + let preroll = down.input_frames_next().saturating_sub(hop_out) + RESAMPLE_SLACK; + let queued = up.delay_frames() + 2 * RESAMPLE_SLACK; + let chunk = Vec::with_capacity(down.input_frames_max() * 2); + (Some(Resample { down, up, chunk }), preroll, queued) }; + let mut queued = queued; + // A rate whose hop is not a whole number of frames steps a hop more + // or less per call now and then, as does a caller of any block size. + if !hop_fed || !exact { + queued += hop_out; + } - let prime = if resample.is_some() { - hop + DOWN_CHUNK - } else { - hop - }; + let mut in_q = VecDeque::with_capacity(CAP); + in_q.extend(std::iter::repeat_n(0.0, preroll * 2)); let mut out = VecDeque::with_capacity(CAP); - // Prime so a block read never outruns the hop-aligned producer. - out.extend(std::iter::repeat(0.0).take(prime * 2)); + out.extend(std::iter::repeat_n(0.0, queued * 2)); Some(Self { model: SendModel(model), hop, - latency: prime + lookahead, - in_mono: VecDeque::with_capacity(CAP), + latency: to_out(model_delay48) + preroll + queued, + hop_out, + in_q, noisy: Array2::zeros((CHANNELS, hop)), enh: Array2::zeros((CHANNELS, hop)), resample, - mid48: Vec::with_capacity(CAP), - enh48: Vec::with_capacity(CAP), + mid48: Vec::with_capacity(hop * 2), + enh48: Vec::with_capacity(hop * 2), out, + #[cfg(test)] + short: 0, }) } + + /// The offload could not start: calls now come in engine blocks. + fn feed_any_size(&mut self) { + self.out.extend(std::iter::repeat_n(0.0, self.hop_out * 2)); + self.latency += self.hop_out; + } + + /// Runs every whole hop `in_q` holds. + fn step(&mut self) { + loop { + let need = self + .resample + .as_ref() + .map_or(self.hop, |r| r.down.input_frames_next()); + if self.in_q.len() < need * 2 { + return; + } + self.mid48.clear(); + match self.resample.as_mut() { + None => self.mid48.extend(self.in_q.drain(..need * 2)), + Some(r) => { + r.chunk.clear(); + r.chunk.extend(self.in_q.drain(..need * 2)); + if r.down.process(&r.chunk, &mut self.mid48).is_err() { + self.mid48.clear(); + self.mid48.resize(self.hop * 2, 0.0); + } + } + } + self.run_model(); + match self.resample.as_mut() { + None => self.out.extend(self.enh48.iter().copied()), + Some(r) => { + r.chunk.clear(); + if r.up.process_chunk(&self.enh48, &mut r.chunk).is_err() { + r.chunk.resize(self.hop_out * 2, 0.0); + } + self.out.extend(r.chunk.iter().copied()); + } + } + } + } + + /// One hop of `mid48` through the model into `enh48`. A hop under the + /// model's silence gate is raised just over it and the result lowered + /// back, so the model steps its buffers on every hop; digital silence + /// gets a dither far below hearing first, since no gain lifts zero. + fn run_model(&mut self) { + let mut power = 0.0f32; + for (i, f) in self.mid48.chunks_exact(2).enumerate() { + let m = 0.5 * (f[0] + f[1]); + self.noisy[[0, i]] = m; + power += m * m; + } + let mut gain = 1.0f32; + if power / (self.hop as f32) < DF_SILENCE_GATE { + for i in 0..self.hop { + let dither = if i % 2 == 0 { 1e-7 } else { -1e-7 }; + self.noisy[[0, i]] += dither; + } + let power: f32 = self.noisy.iter().map(|x| x * x).sum(); + gain = (2.0 * DF_SILENCE_GATE * self.hop as f32 / power).sqrt(); + self.noisy.mapv_inplace(|x| x * gain); + } + let _ = self.model.0.process(self.noisy.view(), self.enh.view_mut()); + self.enh48.clear(); + for i in 0..self.hop { + let m = self.enh[[0, i]] / gain; + self.enh48.push(m); + self.enh48.push(m); + } + } } impl BlockProcessor for ModelWorker { fn process(&mut self, input: &[f32], output: &mut Vec) { - let s = &mut self.state; - let now = Params::load(&self.ctl); + let s = &mut self.state; if now != self.last { if now.atten != self.last.atten { - s.model.0.set_atten_lim(now.atten); + s.model.0.set_atten_lim(now.atten.max(MIN_ATTEN_DB)); } if now.pf_beta != self.last.pf_beta { s.model.0.set_pf_beta(now.pf_beta); @@ -353,37 +437,15 @@ impl BlockProcessor for ModelWorker { s.model.0.max_db_df_thresh = now.max_df; self.last = now; } - - s.mid48.clear(); - match s.resample.as_mut() { - None => s.mid48.extend_from_slice(input), - Some(r) => r.to_model(input, &mut s.mid48), - } - - s.enh48.clear(); - for f in s.mid48.chunks_exact(2) { - s.in_mono.push_back(0.5 * (f[0] + f[1])); - } - while s.in_mono.len() >= s.hop { - for i in 0..s.hop { - s.noisy[[0, i]] = s.in_mono.pop_front().unwrap(); - } - let _ = s.model.0.process(s.noisy.view(), s.enh.view_mut()); - for i in 0..s.hop { - let m = s.enh[[0, i]]; - s.enh48.push(m); - s.enh48.push(m); + s.in_q.extend(input.iter().copied()); + s.step(); + for _ in 0..input.len() { + let v = s.out.pop_front(); + #[cfg(test)] + if v.is_none() { + s.short += 1; } - } - - match s.resample.as_mut() { - None => s.out.extend(s.enh48.iter().copied()), - Some(r) => r.from_model(&s.enh48, s.hop, &mut s.out), - } - - for _ in 0..input.len() / 2 { - output.push(s.out.pop_front().unwrap_or(0.0)); - output.push(s.out.pop_front().unwrap_or(0.0)); + output.push(v.unwrap_or(0.0)); } } } @@ -478,6 +540,157 @@ mod tests { assert!(buf.iter().all(|s| s.is_finite())); } + fn controls(atten_db: f32) -> NoiseSuppressorControls { + let (_, c) = NoiseSuppressorEffect::new( + NoiseSuppressorData { + attenuation_limit_db: atten_db, + ..data() + }, + 48_000, + 1024, + false, + ); + let EffectControl::NoiseSuppressor { controls } = c else { + panic!("variant") + }; + controls + } + + /// A worker fed the way the offload feeds it: one hop of output frames + /// per call. + fn hop_fed(atten_db: f32, sample_rate: u32) -> ModelWorker { + let ctl = controls(atten_db); + let last = Params::load(&ctl); + let state = ModelState::build(last, sample_rate, true).expect("model"); + ModelWorker { ctl, state, last } + } + + fn noise(frames: usize) -> Vec { + let mut x: u32 = 0x1234_5678; + (0..frames) + .flat_map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + let v = (x % 20_000) as f32 / 100_000.0 - 0.1; + [v, v] + }) + .collect() + } + + /// The lag at which `out` matches `input` best, over `max_lag` frames. + fn best_lag(input: &[f32], out: &[f32], max_lag: usize) -> usize { + let frames = input.len() / 2; + (0..max_lag) + .max_by(|&a, &b| { + let score = |lag: usize| -> f64 { + (lag..frames) + .map(|n| out[n * 2] as f64 * input[(n - lag) * 2] as f64) + .sum() + }; + score(a).total_cmp(&score(b)) + }) + .unwrap() + } + + #[test] + fn the_declared_latency_is_the_real_delay() { + for sample_rate in [48_000, 44_100] { + for atten in [0.0, 6.0] { + let case = format!("{sample_rate} Hz, {atten} dB"); + let input = noise(sample_rate as usize); + + // As the offload drives it. + let mut w = hop_fed(atten, sample_rate); + let hop = w.state.hop_out; + let mut out = Vec::new(); + for chunk in input.chunks_exact(hop * 2) { + w.process(chunk, &mut out); + } + let fed = out.len(); + let lag = best_lag(&input[..fed], &out, 4_000); + assert!( + lag.abs_diff(w.state.latency) <= 1, + "{case}, hop-fed: declared {}, measured {lag}", + w.state.latency + ); + assert_eq!(w.state.short, 0, "{case}, hop-fed: output ran short"); + + // Inline, in engine blocks of any size. + for block in [64, 1024] { + let d = NoiseSuppressorData { + attenuation_limit_db: atten, + ..data() + }; + let (mut e, _) = NoiseSuppressorEffect::new(d, sample_rate, block, false); + let mut out = input.clone(); + for chunk in out.chunks_exact_mut(block * 2) { + e.process(chunk, block); + } + let n = input.len() / (block * 2) * block * 2; + let lag = best_lag(&input[..n], &out[..n], 4_000); + assert!( + lag.abs_diff(e.latency_frames()) <= 1, + "{case}, inline {block}: declared {}, measured {lag}", + e.latency_frames() + ); + } + } + } + } + + #[test] + fn a_hop_fed_worker_never_runs_short() { + for sample_rate in [44_100, 48_000, 88_200, 96_000] { + let mut w = hop_fed(15.0, sample_rate); + let hop = w.state.hop_out; + let input = vec![0.1f32; hop * 2]; + let mut out = Vec::new(); + for _ in 0..3_000 { + out.clear(); + w.process(&input, &mut out); + } + assert_eq!(w.state.short, 0, "{sample_rate}: output ran short"); + assert!( + w.state.in_q.len() < 2 * (hop + 2 * RESAMPLE_SLACK) * 2, + "{sample_rate}: input piles up" + ); + } + } + + #[test] + fn a_pause_brings_back_nothing_from_before_it() { + // A burst, then silence the model would skip, then another burst. + // Whatever sat in the model's buffers when the first burst ended + // must come out right after it, not when the second one starts. + let mut w = hop_fed(6.0, 48_000); + let hop = w.state.hop_out; + let tone = |n: usize| ((n % 100) as f32 / 100.0 * std::f32::consts::TAU).sin() * 0.3; + let mut input = Vec::new(); + for n in 0..48_000 * 3 { + let v = if (48_000..96_000).contains(&n) { + 0.0 + } else { + tone(n) + }; + input.push(v); + input.push(v); + } + let mut out = Vec::new(); + for chunk in input.chunks_exact(hop * 2) { + w.process(chunk, &mut out); + } + // Stale audio would come out as soon as the second burst reaches the + // model, well before that burst's own delayed onset (the last few + // hundred frames before it are the analysis window reaching ahead). + let onset = 96_000 + w.state.latency; + let quiet: f32 = out[96_000 * 2..(onset - 600) * 2] + .iter() + .map(|x| x.abs()) + .fold(0.0, f32::max); + assert!(quiet < 1e-3, "the first burst came back: peak {quiet}"); + } + #[test] fn zero_frames_is_noop() { let (mut e, _) = NoiseSuppressorEffect::new(data(), 48_000, 1024, false); diff --git a/src-tauri/src/audio/effects/offload.rs b/src-tauri/src/audio/effects/offload.rs index 43e888a9..9d34ff4f 100644 --- a/src-tauri/src/audio/effects/offload.rs +++ b/src-tauri/src/audio/effects/offload.rs @@ -3,14 +3,15 @@ //! and bulk-pops out of a pair of SPSC rings; the return ring's prefill (the //! pad) is declared as latency so PDC aligns parallel branches against it. //! -//! The pad is the effect's working block: it is how long the worker thread -//! may take to hand a block back. It is never below the engine block (the RT -//! side reads a whole block at once) and never below the effect's own floor. +//! An effect that can only step in whole blocks of its own (a model hop) says +//! so, and the worker gathers exactly those before calling it; the effect +//! itself never buffers or pads for it. The pad is then what the gathering +//! and the worker's turnaround cost, at the worst alignment of the effect's +//! blocks against the engine's (see `pad_frames`). use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::thread::{self, JoinHandle, Thread}; use rtrb::{Consumer, Producer, RingBuffer}; use tracing::warn; @@ -18,19 +19,43 @@ use tracing::warn; use crate::audio::graph::MAX_BUFFER_FRAMES; use crate::audio::health; -const POLL_INTERVAL: Duration = Duration::from_millis(1); -/// Floor for a worker's turnaround: its 1 ms poll plus scheduling slack plus -/// the processing itself. +/// Floor for a worker's turnaround: waking it, scheduling slack, and the +/// processing itself. const MIN_TURNAROUND_MS: f64 = 5.0; -/// Pad for an offloaded effect at `sample_rate` driven in `block_frames` -/// blocks, given the effect's own processing floor. A power of two, so it -/// reads as a buffer size. -pub fn pad_frames(sample_rate: u32, block_frames: usize, floor_frames: usize) -> usize { +/// Smallest pad that never starves the RT side, for an effect stepping in +/// `working_frames` blocks driven by the engine in `block_frames` blocks. +/// +/// The engine pushes a block and at once reads one back, `pad` frames older. +/// The effect's block `j` covers frames `[j*W, (j+1)*W)`; it is complete once +/// the engine block holding its last frame is pushed, and back a turnaround +/// later. Its first frame is read `pad` frames after it was pushed, on a block +/// boundary, so the pad must cover, for every `j`, the wait from pushing that +/// frame to the first engine block starting after the result is back. The +/// alignment of effect blocks against engine blocks repeats every +/// `lcm(W, B)` frames, so that one period is the whole story. +pub fn pad_frames(sample_rate: u32, block_frames: usize, working_frames: usize) -> usize { + let b = block_frames.max(1); + let w = working_frames.max(1); let turnaround = (sample_rate as f64 * MIN_TURNAROUND_MS / 1000.0).ceil() as usize; - block_frames - .max(turnaround.next_power_of_two()) - .max(floor_frames) + let turnaround_blocks = turnaround.div_ceil(b); + let period = w / gcd(w, b) * b; + (0..period / w) + .map(|j| { + let done = ((j + 1) * w - 1) / b; + (done + turnaround_blocks) * b - j * w + }) + .max() + .unwrap_or(b) + .max(b) +} + +fn gcd(a: usize, b: usize) -> usize { + if b == 0 { + a + } else { + gcd(b, a % b) + } } /// Interleaved block processing, run on the offload thread. @@ -43,7 +68,16 @@ pub struct Offload { to_worker: Producer, from_worker: Consumer, stop: Arc, + /// Times the worker woke to find nothing to do. + #[cfg(test)] + idle_wakes: Arc, + /// Samples the worker has taken in and, for whole effect blocks, answered. + #[cfg(test)] + taken: Arc, join: Option>, + /// Woken after every push, so it sleeps until there is work instead of + /// polling for it. + worker: Thread, width: usize, pad_frames: usize, } @@ -78,22 +112,25 @@ fn push_aligned(prod: &mut Producer, samples: &[f32], width: usize) -> usiz } impl Offload { - /// `pad_frames` comes from [`pad_frames`]; `sample_rate` sizes the - /// worker's real-time scheduling. + /// `working_frames` is the only block size `processor` can step in, if it + /// has one; otherwise it is handed each engine block as it comes. pub fn spawn( name: &'static str, processor: P, width: usize, - pad_frames: usize, + working_frames: Option, + block_frames: usize, sample_rate: u32, ) -> Result { if width == 0 { tracing::error!(name, "offload: width must be at least 1"); return Err(processor); } + let working = working_frames.unwrap_or(block_frames).max(1); + let pad_frames = pad_frames(sample_rate, block_frames, working); // Room for the pad, a block in flight each way, and a stall's worth. - let ring_frames = (pad_frames + MAX_BUFFER_FRAMES) * 4; + let ring_frames = (pad_frames + working + MAX_BUFFER_FRAMES) * 4; let (to_worker, mut worker_in) = RingBuffer::::new(ring_frames * width); let (mut worker_out, from_worker) = RingBuffer::::new(ring_frames * width); @@ -112,6 +149,14 @@ impl Offload { let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); + #[cfg(test)] + let idle_wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + #[cfg(test)] + let idle_wakes_thread = idle_wakes.clone(); + #[cfg(test)] + let taken = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + #[cfg(test)] + let taken_thread = taken.clone(); // Handoff cell rather than moving `processor` straight into the closure: // a failed `Builder::spawn` drops its closure internally with no way to // recover a moved value, so the cell is how the caller gets it back. @@ -132,29 +177,41 @@ impl Offload { pad_frames as u32, sample_rate, ); - let mut scratch = vec![0.0f32; MAX_BUFFER_FRAMES * width]; - let mut out = Vec::with_capacity(MAX_BUFFER_FRAMES * width); + // One of the effect's blocks, gathered across engine blocks. + let mut gathered = vec![0.0f32; working * width]; + let mut filled = 0; + let mut out = Vec::with_capacity(working * width); while !stop_thread.load(Ordering::Relaxed) { let avail = worker_in.slots(); let avail = avail - avail % width; // whole frames only if avail == 0 { - thread::sleep(POLL_INTERVAL); + // A wake that lands before this parks leaves the + // token set, so the park returns at once. + thread::park(); + #[cfg(test)] + idle_wakes_thread.fetch_add(1, Ordering::Relaxed); continue; } - let n = avail.min(scratch.len()); + let n = avail.min(gathered.len() - filled); if let Ok(chunk) = worker_in.read_chunk(n) { let (first, second) = chunk.as_slices(); let n1 = first.len(); - scratch[..n1].copy_from_slice(first); + gathered[filled..filled + n1].copy_from_slice(first); let n2 = second.len(); if n2 > 0 { - scratch[n1..n1 + n2].copy_from_slice(second); + gathered[filled + n1..filled + n1 + n2].copy_from_slice(second); } chunk.commit_all(); } - out.clear(); - processor.process(&scratch[..n], &mut out); - push_aligned(&mut worker_out, &out, width); + filled += n; + if filled == gathered.len() { + out.clear(); + processor.process(&gathered, &mut out); + push_aligned(&mut worker_out, &out, width); + filled = 0; + } + #[cfg(test)] + taken_thread.fetch_add(n, Ordering::Relaxed); } }) { Ok(j) => j, @@ -174,19 +231,26 @@ impl Offload { to_worker, from_worker, stop, + #[cfg(test)] + idle_wakes, + #[cfg(test)] + taken, + worker: join.thread().clone(), join: Some(join), width, pad_frames, }) } - /// RT thread only: no allocations, locks, or syscalls. + /// RT thread only: no allocations or locks. The one syscall is waking the + /// worker when it sleeps, which never blocks. pub fn process(&mut self, samples: &mut [f32]) { let want = samples.len(); if want == 0 { return; } push_aligned(&mut self.to_worker, samples, self.width); + self.worker.unpark(); // A starve leaves the return ring permanently deeper than the pad; trim // back so the declared latency stays true. @@ -231,6 +295,7 @@ impl Offload { impl Drop for Offload { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); + self.worker.unpark(); if let Some(j) = self.join.take() { if j.join().is_err() { warn!("offload: worker thread panicked"); @@ -242,11 +307,27 @@ impl Drop for Offload { #[cfg(test)] mod tests { use super::*; + use std::time::{Duration, Instant}; + + /// Hands its input straight back, checking it only ever gets whole blocks + /// of `frames` when it has a block size of its own. + struct Passthrough { + frames: Option, + width: usize, + } - struct Passthrough; + fn passthrough() -> Passthrough { + Passthrough { + frames: None, + width: 2, + } + } impl BlockProcessor for Passthrough { fn process(&mut self, input: &[f32], output: &mut Vec) { + if let Some(frames) = self.frames { + assert_eq!(input.len(), frames * self.width, "a partial block"); + } output.extend_from_slice(input); } } @@ -254,26 +335,42 @@ mod tests { // Waits for the worker to hand back the block just pushed, so a slow // runner cannot starve the next `process` into zeros. fn wait_for_return(offload: &Offload, want: usize) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); + let deadline = Instant::now() + Duration::from_secs(5); while offload.from_worker.slots() < want { - assert!( - std::time::Instant::now() < deadline, - "offload worker stalled" - ); + assert!(Instant::now() < deadline, "offload worker stalled"); thread::sleep(Duration::from_millis(1)); } } - fn roundtrip(width: usize, block: usize, pad: usize) { - let Ok(mut offload) = Offload::spawn("test", Passthrough, width, pad, 48_000) else { + /// Waits until the worker has taken everything pushed so far and answered + /// every whole block of it. + fn wait_until_taken(offload: &Offload, pushed: usize) { + let deadline = Instant::now() + Duration::from_secs(5); + while offload.taken.load(Ordering::Relaxed) < pushed { + assert!(Instant::now() < deadline, "offload worker stalled"); + thread::yield_now(); + } + } + + fn roundtrip(width: usize, block: usize, working: Option, sample_rate: u32) { + let processor = Passthrough { + frames: working, + width, + }; + let Ok(mut offload) = Offload::spawn("test", processor, width, working, block, sample_rate) + else { panic!("spawn offload") }; - assert_eq!(offload.latency_frames(), pad); + let pad = offload.latency_frames(); + assert_eq!( + pad, + pad_frames(sample_rate, block, working.unwrap_or(block)) + ); let mut fed = Vec::new(); let mut got = Vec::new(); let mut counter = 1.0f32; - let blocks = (4 * pad / block).max(8); + let blocks = (4 * (pad + working.unwrap_or(0)) / block).max(8); for _ in 0..blocks { let mut data = vec![0.0f32; block * width]; for frame in data.chunks_exact_mut(width) { @@ -283,34 +380,137 @@ mod tests { fed.extend_from_slice(&data); offload.process(&mut data); got.extend_from_slice(&data); - wait_for_return(&offload, data.len()); + wait_until_taken(&offload, fed.len()); } let shift = pad * width; - assert!(got[..shift].iter().all(|&v| v == 0.0), "pad is silence"); + let case = format!("{block}/{working:?}"); + assert!( + got[..shift].iter().all(|&v| v == 0.0), + "{case}: pad is silence" + ); for i in shift..got.len() { - assert_eq!(got[i], fed[i - shift], "{block}/{pad}: mismatch at {i}"); + assert_eq!(got[i], fed[i - shift], "{case}: mismatch at {i}"); } } #[test] fn offload_delays_by_exactly_its_pad_at_every_block_size() { for block in crate::audio::graph::BUFFER_FRAME_OPTIONS.map(|n| n as usize) { - roundtrip(2, block, pad_frames(48_000, block, 0)); + roundtrip(2, block, None, 48_000); + } + } + + #[test] + fn a_block_stepping_effect_gets_whole_blocks_and_an_exact_delay() { + for block in [32, 64, 256, 1024] { + roundtrip(2, block, Some(441), 44_100); + roundtrip(2, block, Some(480), 48_000); } } #[test] fn offload_roundtrip_handles_wide_blocks() { - roundtrip(6, 1024, 1024); + roundtrip(6, 1024, None, 48_000); + roundtrip(6, 64, Some(480), 48_000); + } + + /// Whether reading with `pad` ever finds a frame not yet back, frame by + /// frame, with the worker taking exactly `turnaround` frames of time. + fn starves(pad: usize, block: usize, working: usize, turnaround: usize) -> bool { + // Past the pad, then two full alignment periods. + let blocks = (pad + working) / block + working / gcd(working, block) * 2 + 8; + (0..blocks).any(|k| { + (0..block).any(|i| { + let Some(frame) = (k * block + i).checked_sub(pad) else { + return false; + }; + let j = frame / working; + let done = ((j + 1) * working - 1) / block; + done * block + turnaround > k * block + }) + }) } #[test] - fn pad_never_drops_below_the_block_or_the_floor() { - assert_eq!(pad_frames(48_000, 32, 0), 256, "5 ms turnaround at 48k"); - assert_eq!(pad_frames(96_000, 32, 0), 512); - assert_eq!(pad_frames(44_100, 64, 0), 256); - assert_eq!(pad_frames(48_000, 2048, 0), 2048, "never below the block"); - assert_eq!(pad_frames(48_000, 64, 480), 480, "effect's own floor"); + fn the_pad_is_the_least_that_never_starves() { + for sample_rate in [44_100, 48_000, 96_000] { + let turnaround = (sample_rate as f64 * MIN_TURNAROUND_MS / 1000.0).ceil() as usize; + for block in crate::audio::graph::BUFFER_FRAME_OPTIONS.map(|n| n as usize) { + for working in [block, 441, 480, 960, 100, 1_500] { + let pad = pad_frames(sample_rate, block, working); + let case = format!("{sample_rate}/{block}/{working}"); + assert!( + !starves(pad, block, working, turnaround), + "{case}: {pad} starves" + ); + if pad > block { + assert!( + starves(pad - 1, block, working, turnaround), + "{case}: {pad} is more than needed" + ); + } + } + } + } + } + + #[test] + fn a_model_hop_costs_its_gathering_and_a_turnaround() { + // DeepFilterNet at 44.1 kHz: 441-frame hops against 64-frame blocks. + // The hop gathered, at worst misaligned by a block, plus 5 ms. + let pad = pad_frames(44_100, 64, 441); + assert!(pad <= 441 + 64 + 256, "{pad}"); + // An effect that takes any block pays the turnaround alone. + assert_eq!(pad_frames(48_000, 32, 32), 256, "5 ms turnaround at 48k"); + assert_eq!( + pad_frames(48_000, 2048, 2048), + 2048, + "never below the block" + ); + } + + #[test] + fn an_idle_worker_sleeps_until_woken() { + // A 1 ms poll wakes ~100 times in 100 ms; a parked worker only when a + // push wakes it (or, rarely, spuriously). + let Ok(mut offload) = Offload::spawn("test", passthrough(), 2, None, 64, 48_000) else { + panic!("spawn offload") + }; + thread::sleep(Duration::from_millis(100)); + let idle = offload.idle_wakes.load(Ordering::Relaxed); + assert!(idle <= 2, "woke {idle} times with nothing to do"); + // And a push is answered without any poll to pick it up. + let mut data = vec![0.5f32; 64 * 2]; + offload.process(&mut data); + wait_for_return(&offload, data.len()); + } + + #[test] + fn processing_never_touches_the_heap() { + let Ok(mut offload) = Offload::spawn("test", passthrough(), 2, Some(441), 64, 44_100) + else { + panic!("spawn offload") + }; + let mut data = vec![0.25f32; 64 * 2]; + crate::audio::rt_guard::assert_no_alloc("offload process", || { + for _ in 0..2_000 { + offload.process(&mut data); + } + }); + } + + #[test] + fn dropping_wakes_a_sleeping_worker() { + let Ok(offload) = Offload::spawn("test", passthrough(), 2, None, 64, 48_000) else { + panic!("spawn offload") + }; + thread::sleep(Duration::from_millis(20)); + let started = Instant::now(); + drop(offload); + assert!( + started.elapsed() < Duration::from_millis(500), + "join waited on a parked worker" + ); } } diff --git a/src-tauri/src/audio/plugins/host_api.rs b/src-tauri/src/audio/plugins/host_api.rs index 9cfcfca1..6a19b43e 100644 --- a/src-tauri/src/audio/plugins/host_api.rs +++ b/src-tauri/src/audio/plugins/host_api.rs @@ -16,7 +16,6 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD, Engine as _}; use super::{ParamRing, PluginFormat, PluginNode}; -use crate::audio::effects::offload::pad_frames; use crate::audio::effects::offload::{BlockProcessor, Offload}; use crate::audio::effects::Effect; use crate::audio::graph::MAX_BUFFER_FRAMES; @@ -174,13 +173,12 @@ impl HostedEffect { width, scratch: Vec::with_capacity(MAX_BUFFER_FRAMES * width), }; - let pad = pad_frames(sample_rate, block_frames, 0); - match Offload::spawn("plugin", processor, width, pad, sample_rate) { + match Offload::spawn("plugin", processor, width, None, block_frames, sample_rate) { Ok(o) => Self { latency: latency + o.latency_frames(), + working_block: (o.latency_frames() > block_frames).then_some(o.latency_frames()), backend: HostedBackend::Offloaded(o), channels: width, - working_block: (pad > block_frames).then_some(pad), }, Err(p) => Self { backend: HostedBackend::Inline { node: p.node }, From 9b7dbd9d1377d7254b6092db40bd7632ea0ce4e7 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:34:40 +0300 Subject: [PATCH 10/51] fix(audio): fade speakers in and out, crossfade graph swaps, no false rate alarms at startup --- docs/CONCEPT.md | 1 + src-tauri/src/audio/health.rs | 3 - src-tauri/src/audio/pipeline/dag.rs | 5 +- src-tauri/src/audio/pipeline/latency.rs | 14 +- src-tauri/src/audio/pipeline/meter.rs | 12 +- src-tauri/src/audio/pipeline/output/render.rs | 106 +++++++++++++-- src-tauri/src/audio/pipeline/worker.rs | 122 +++++++++++++++++- 7 files changed, 238 insertions(+), 25 deletions(-) diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 7e29c93e..b65fc4b8 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -45,6 +45,7 @@ Without these, one chunk takes ~16 ms and the worker stalls. - Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. - Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. The network receiver (`stream_recv.rs`) runs the same loop against its packets' write timing, holding the fill right after a packet at its start level. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block. +- Nothing steps the output: a speaker fades in over 10 ms when it starts and out over 10 ms before it retires, and a graph swap renders the old and new graphs together for 10 ms, crossfading from one to the other (`worker.rs`). - An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs on an offload thread (`effects/offload.rs`). One that can only step in whole blocks of its own (a model hop) names that block, and the offload gathers exactly those before calling it; the effect never pads or buffers for it itself. The offload's pad is the least that never starves at the worst alignment of those blocks against the engine's, plus a 5 ms turnaround. The effect declares only its own latency (a model's algorithmic delay, a resampler's queues), the offload adds its pad, PDC aligns against the sum, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/src/audio/health.rs b/src-tauri/src/audio/health.rs index ce2321cd..6422e60f 100644 --- a/src-tauri/src/audio/health.rs +++ b/src-tauri/src/audio/health.rs @@ -30,8 +30,6 @@ counters! { /// Speaker blocks the output resampler failed to convert; the device /// played silence for them (pipeline/output/render.rs). SPEAKER_RENDER_FAILED_BLOCKS, - /// Input samples discarded by a source's backlog trim (SourceState::fill_block). - SOURCE_TRIM_DROPPED_SAMPLES, /// Samples dropped by a StagingRing overrun (producer outran the drain). STAGING_OVERRUN_SAMPLES, /// Worker blocks whose wall-clock deadline had already passed on wake. @@ -95,7 +93,6 @@ mod tests { "NET_RING_OVERRUN_SAMPLES", "TAP_RING_OVERRUN_SAMPLES", "SPEAKER_RENDER_FAILED_BLOCKS", - "SOURCE_TRIM_DROPPED_SAMPLES", "STAGING_OVERRUN_SAMPLES", "CLOCK_LATE_BLOCKS", "CLOCK_LATE_MAX_US", diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 49f8f83c..d48a9955 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -204,7 +204,9 @@ pub(super) struct SourceStats { /// Samples silenced because the source delivered nothing for longer than /// `STALL_THRESHOLD`. Silent by design, but it is still missing audio. pub stalled: Arc, - /// Samples discarded by the backlog trim in `fill_block`. + /// Samples removed by design and never played: the startup backlog and + /// the one startup depth correction, and audio that arrived late for time + /// already played as silence. pub trimmed: Arc, /// Samples actually read out of the source ring. pub consumed: Arc, @@ -626,7 +628,6 @@ impl SourceState { let removed = (drop + fade) as u64; self.stats.consumed.fetch_add(popped, Ordering::Relaxed); self.stats.trimmed.fetch_add(removed, Ordering::Relaxed); - health::bump(&health::SOURCE_TRIM_DROPPED_SAMPLES, removed); self.last_pop_at = Instant::now(); } diff --git a/src-tauri/src/audio/pipeline/latency.rs b/src-tauri/src/audio/pipeline/latency.rs index 97d6fbb3..e7c9864d 100644 --- a/src-tauri/src/audio/pipeline/latency.rs +++ b/src-tauri/src/audio/pipeline/latency.rs @@ -107,10 +107,12 @@ pub(super) fn breakdown(inputs: &[PathInput], out: &PathOutput) -> LatencyBreakd ) }); let queue_ms = ms(i.queue_frames + i.normalizer_frames, i.rate); + // A source with no capture device (a process tap, a file, the + // network) has no hardware of its own to report. ( device_ms, queue_ms, - i.device.and_then(|d| d.hardware_frames).is_some(), + i.device.is_none_or(|d| d.hardware_frames.is_some()), ) }) .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1))); @@ -205,6 +207,16 @@ mod tests { assert!(!breakdown(&[mic(64, None, 0)], &out(64, Some(0))).hardware_included); assert!(!breakdown(&[mic(64, Some(0), 0)], &out(64, None)).hardware_included); assert!(breakdown(&[], &out(64, Some(0))).hardware_included); + let tap = PathInput { + device: None, + queue_frames: 300, + normalizer_frames: 0, + rate: 48_000, + }; + assert!( + breakdown(&[tap], &out(64, Some(0))).hardware_included, + "a tap has no device to leave unreported" + ); } #[test] diff --git a/src-tauri/src/audio/pipeline/meter.rs b/src-tauri/src/audio/pipeline/meter.rs index 02579552..716537bf 100644 --- a/src-tauri/src/audio/pipeline/meter.rs +++ b/src-tauri/src/audio/pipeline/meter.rs @@ -177,7 +177,11 @@ pub(super) fn spawn_xrun_thread( }); let dropped_delta = capture_delta.map_or(0, |(_, d)| d); - let consumed_frames = consumed_delta / s.channels.max(1) as u64; + // Audio removed by design (the startup backlog and depth + // correction, late audio realigned under silence already + // played) was consumed but never played. + let consumed_frames = + consumed_delta.saturating_sub(trimmed_delta) / s.channels.max(1) as u64; let wallclock_frames = s.native_sr as f64 * elapsed_secs; // A capture-backed source is measured against what its // producer actually delivered: an app playing nothing feeds @@ -202,11 +206,7 @@ pub(super) fn spawn_xrun_thread( if !warmup && !producer_short - && (xrun_delta > 0 - || stalled_delta > 0 - || trimmed_delta > 0 - || off_rate - || dropped_delta > 0) + && (xrun_delta > 0 || stalled_delta > 0 || off_rate || dropped_delta > 0) { let ring_level_samples = s.stats.level.load(Ordering::Relaxed); match capture_delta { diff --git a/src-tauri/src/audio/pipeline/output/render.rs b/src-tauri/src/audio/pipeline/output/render.rs index 3359e291..9b618f5b 100644 --- a/src-tauri/src/audio/pipeline/output/render.rs +++ b/src-tauri/src/audio/pipeline/output/render.rs @@ -29,6 +29,10 @@ use super::super::worker::{dsp_worker, DspWorker, WorkerCtrl}; /// device calls back within one buffer; a dead one never will. const RETIRE_TIMEOUT: Duration = Duration::from_millis(500); +/// A starting speaker fades in, and a retiring one fades out, over this, so +/// starting or stopping never steps a waveform mid-cycle. +const STOP_FADE_MS: u32 = 10; + /// Blocks rendered in a row without output before a callback gives up. The /// FFT resampler holds input until it has a whole FFT frame (160 frames for /// 48 -> 44.1 kHz), so several small blocks may go in before any comes out. @@ -227,20 +231,63 @@ pub(in crate::audio::pipeline) struct SpeakerCallback { outgoing: Producer, retire: Arc, renderer: Option, + /// Frames of the stop fade still to play, once a retire is asked for. + fade_left: Option, + /// Frames played since the renderer arrived, while the start fade runs. + faded_in: usize, } impl SpeakerCallback { pub(in crate::audio::pipeline) fn fill(&mut self, out: &mut [f32], callback_frames: usize) { if self.retire.load(Ordering::Acquire) { - if let Some(r) = self.renderer.take() { - let _ = self.outgoing.push(r); - } - } else if self.renderer.is_none() { + self.fade_out(out, callback_frames); + return; + } + if self.renderer.is_none() { self.renderer = self.incoming.pop().ok(); } - match &mut self.renderer { - Some(r) => r.render(out, callback_frames), - None => out.fill(0.0), + let Some(r) = self.renderer.as_mut() else { + out.fill(0.0); + return; + }; + r.render(out, callback_frames); + let total = (r.io.sample_rate * STOP_FADE_MS / 1000).max(1) as usize; + if self.faded_in < total { + for frame in out.chunks_exact_mut(r.channels) { + let gain = (self.faded_in as f32 / total as f32).min(1.0); + for s in frame.iter_mut() { + *s *= gain; + } + self.faded_in += 1; + } + } + } + + /// Plays the stop fade, then hands the renderer back and goes silent. + fn fade_out(&mut self, out: &mut [f32], callback_frames: usize) { + let Some(r) = self.renderer.as_mut() else { + out.fill(0.0); + return; + }; + let total = (r.io.sample_rate * STOP_FADE_MS / 1000).max(1) as usize; + let left = self.fade_left.get_or_insert(total); + if *left > 0 { + r.render(out, callback_frames); + for frame in out.chunks_exact_mut(r.channels) { + let gain = *left as f32 / total as f32; + for s in frame.iter_mut() { + *s *= gain; + } + *left = left.saturating_sub(1); + } + if *left > 0 { + return; + } + } else { + out.fill(0.0); + } + if let Some(r) = self.renderer.take() { + let _ = self.outgoing.push(r); } } } @@ -267,6 +314,8 @@ pub(in crate::audio::pipeline) fn speaker_link() -> (SpeakerLink, SpeakerCallbac outgoing: out_tx, retire, renderer: None, + fade_left: None, + faded_in: 0, }, ) } @@ -429,8 +478,13 @@ mod tests { t.ctrl.send_graph(next).expect("swap"); t.r.render(&mut out, 64); assert_eq!(blocks.load(Ordering::Relaxed), 1, "new graph renders"); - assert_eq!(t.blocks.load(Ordering::Relaxed), 1, "old graph stopped"); - assert_eq!(t.io.callbacks.load(Ordering::Relaxed), 2); + assert_eq!(t.blocks.load(Ordering::Relaxed), 2, "old graph fades out"); + // 10 ms at 48 kHz is 7.5 blocks of 64: the old graph renders 8 more. + for _ in 0..20 { + t.r.render(&mut out, 64); + } + assert_eq!(t.blocks.load(Ordering::Relaxed), 9, "old graph stopped"); + assert_eq!(t.io.callbacks.load(Ordering::Relaxed), 22); } #[test] @@ -477,7 +531,8 @@ mod tests { link.attach(t.r); cb.fill(&mut out, 64); - assert_eq!(out[0], 1.0, "renderer arrived and plays the input"); + assert_eq!(out[0], 0.0, "renderer arrived and fades in"); + assert!(out[127] > 0.0, "renderer arrived and plays the input"); // Retire on another thread while the "device" keeps calling back. let handle = std::thread::spawn(move || link.retire().is_some()); @@ -494,6 +549,37 @@ mod tests { assert!(out.iter().all(|&s| s == 0.0), "retired callback is silent"); } + #[test] + fn starting_and_stopping_fade_instead_of_stepping() { + let (mut link, mut cb) = speaker_link(); + let mut t = rig(64, SR); + feed(&mut t.input, &vec![0.5; 48_000]); + link.attach(t.r); + let mut out = vec![0.0; 64 * 2]; + let mut played: Vec = Vec::new(); + for _ in 0..20 { + cb.fill(&mut out, 64); + played.extend(out.iter().step_by(2)); + } + assert_eq!(*played.last().unwrap(), 0.5, "faded in"); + cb.retire.store(true, Ordering::Release); + for _ in 0..20 { + cb.fill(&mut out, 64); + played.extend(out.iter().step_by(2)); + } + let fade = (SR * STOP_FADE_MS / 1000) as f32; + let worst = played + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max); + assert!(worst <= 0.5 / fade + 1e-6, "a step of {worst}"); + assert_eq!(*played.last().unwrap(), 0.0); + assert!( + link.retire().is_some(), + "renderer handed back after the fade" + ); + } + #[test] fn retire_gives_up_on_a_dead_device() { let (mut link, cb) = speaker_link(); diff --git a/src-tauri/src/audio/pipeline/worker.rs b/src-tauri/src/audio/pipeline/worker.rs index a6e8afb1..3db0ea57 100644 --- a/src-tauri/src/audio/pipeline/worker.rs +++ b/src-tauri/src/audio/pipeline/worker.rs @@ -47,8 +47,17 @@ impl Drop for RtThread { } } +/// A swapped-in graph fades in over this while the one it replaces fades out, +/// so an edit to a running pipeline never steps the output. +const SWAP_FADE_MS: usize = 10; + pub(super) struct DspWorker { pub graph: OutputGraph, + /// The graph being replaced, still rendering while it fades out. + fading: Option, + fade_pos: usize, + /// Holds the fading graph's block. + fade_block: Box<[f32]>, /// Hot-swap channel: main thread pushes a freshly-built `OutputGraph` /// here; worker takes ownership at the next block boundary. cmd_rx: Consumer, @@ -81,10 +90,15 @@ impl WorkerCtrl { pub(super) fn dsp_worker(graph: OutputGraph) -> (DspWorker, WorkerCtrl) { let (cmd_tx, cmd_rx) = RingBuffer::::new(2); - let (old_tx, old_rx) = RingBuffer::::new(2); + // Room for a graph queued behind one still fading out. + let (old_tx, old_rx) = RingBuffer::::new(4); + let fade_block = vec![0.0; graph.block_frames() * graph.out_channels()].into_boxed_slice(); ( DspWorker { graph, + fading: None, + fade_pos: 0, + fade_block, cmd_rx, old_graph_tx: old_tx, }, @@ -105,7 +119,12 @@ impl DspWorker { while let Ok(new_graph) = self.cmd_rx.pop() { debug_assert_eq!(new_graph.block_frames(), self.graph.block_frames()); let old = std::mem::replace(&mut self.graph, new_graph); - let _ = self.old_graph_tx.push(old); + // A swap landing mid-fade cuts the older graph short; the newer + // one fades from the graph that was playing. + if let Some(older) = self.fading.replace(old) { + let _ = self.old_graph_tx.push(older); + } + self.fade_pos = 0; } } @@ -116,7 +135,37 @@ impl DspWorker { pub(super) fn next_block(&mut self, block: &mut [f32]) -> usize { self.drain_swaps(); self.graph.process_block(block); - self.graph.active_output_channels() + let active = self.graph.active_output_channels(); + let Some(old) = self.fading.as_mut() else { + return active; + }; + let channels = self.graph.out_channels().max(1); + let frames = block.len() / channels; + let fade_len = (self.graph.sample_rate() as usize * SWAP_FADE_MS / 1000).max(1); + let old_active = old.active_output_channels(); + if old.out_channels() == channels && self.fade_block.len() == block.len() { + old.process_block(&mut self.fade_block); + for (f, (new, old)) in block + .chunks_exact_mut(channels) + .zip(self.fade_block.chunks_exact(channels)) + .enumerate() + { + let t = ((self.fade_pos + f) as f32 / fade_len as f32).min(1.0); + for (n, o) in new.iter_mut().zip(old) { + *n = *n * t + *o * (1.0 - t); + } + } + self.fade_pos += frames; + } else { + self.fade_pos = fade_len; + } + if self.fade_pos >= fade_len { + if let Some(old) = self.fading.take() { + let _ = self.old_graph_tx.push(old); + } + return active; + } + active.max(old_active) } pub(super) fn graph(&self) -> &OutputGraph { @@ -178,12 +227,23 @@ mod tests { built.graph } + fn block_for(worker: &DspWorker) -> Vec { + vec![0.0; worker.graph.block_frames() * worker.graph.out_channels()] + } + #[test] fn swap_hands_the_old_graph_back_to_main() { let (mut worker, mut ctrl) = dsp_worker(graph(2)); ctrl.send_graph(graph(4)).expect("queue swap"); worker.drain_swaps(); assert_eq!(worker.graph.out_channels(), 4, "worker runs the new graph"); + assert_eq!( + ctrl.old_graph_rx.slots(), + 0, + "the old graph is still fading out" + ); + let mut block = block_for(&worker); + worker.next_block(&mut block); assert_eq!( ctrl.old_graph_rx.slots(), 1, @@ -204,9 +264,65 @@ mod tests { ctrl.send_graph(graph(6)).expect("second swap"); worker.drain_swaps(); assert_eq!(worker.graph.out_channels(), 6); + assert_eq!( + ctrl.old_graph_rx.slots(), + 1, + "the skipped graph never plays" + ); + let mut block = block_for(&worker); + worker.next_block(&mut block); assert_eq!(ctrl.old_graph_rx.slots(), 2); } + /// A live-less passthrough at 64-frame blocks, fed `value` throughout. + fn constant_graph(value: f32) -> (OutputGraph, rtrb::Producer) { + let (valid, speaker) = passthrough_graph(); + let (built, mut producers) = super::super::dag::graph_tests::build_with_block( + Some(&speaker), + 48_000, + 64, + &valid, + 48_000, + false, + ); + let mut input = producers.remove("m").unwrap(); + let data = vec![value; 48_000]; + if let Ok(mut chunk) = input.write_chunk(data.len()) { + let (a, b) = chunk.as_mut_slices(); + a.copy_from_slice(&data[..a.len()]); + b.copy_from_slice(&data[a.len()..a.len() + b.len()]); + chunk.commit_all(); + } + (built.graph, input) + } + + #[test] + fn a_swap_crossfades_instead_of_stepping() { + let (old, _old_in) = constant_graph(0.5); + let (new, _new_in) = constant_graph(-0.5); + let (mut worker, mut ctrl) = dsp_worker(old); + let mut block = block_for(&worker); + worker.next_block(&mut block); + ctrl.send_graph(new).expect("queue swap"); + let mut played: Vec = Vec::new(); + for _ in 0..20 { + worker.next_block(&mut block); + played.extend(block.iter().step_by(2)); + } + let fade = 48_000 * SWAP_FADE_MS / 1000; + let worst = played + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max); + assert!(worst <= 1.0 / fade as f32 + 1e-6, "a step of {worst}"); + assert_eq!(*played.last().unwrap(), -0.5, "the new graph plays alone"); + assert_eq!( + ctrl.old_graph_rx.slots(), + 1, + "the old graph went back to main" + ); + } + #[test] fn full_swap_queue_is_an_error_not_a_block() { let (_worker, mut ctrl) = dsp_worker(graph(2)); From 43051de34a5da3dcf88f7d6c88fca10196fd7d8d Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:32:56 +0300 Subject: [PATCH 11/51] feat(audio): carry unchanged nodes across graph swaps instead of rebuilding them Monitor sources are keyed like their bridges, so they get their delivery counters. --- docs/CONCEPT.md | 3 +- src-tauri/src/audio/pipeline/dag.rs | 726 ++++++++++++++++-- src-tauri/src/audio/pipeline/mod.rs | 167 +++- src-tauri/src/audio/pipeline/output/mod.rs | 1 + src-tauri/src/audio/pipeline/output/render.rs | 14 + src-tauri/src/audio/pipeline/sig.rs | 2 +- src-tauri/src/audio/pipeline/worker.rs | 40 +- 7 files changed, 877 insertions(+), 76 deletions(-) diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index b65fc4b8..8757b128 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -45,7 +45,8 @@ Without these, one chunk takes ~16 ms and the worker stalls. - Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. - Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. The network receiver (`stream_recv.rs`) runs the same loop against its packets' write timing, holding the fill right after a packet at its start level. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block. -- Nothing steps the output: a speaker fades in over 10 ms when it starts and out over 10 ms before it retires, and a graph swap renders the old and new graphs together for 10 ms, crossfading from one to the other (`worker.rs`). +- An edit rebuilds only what it changed. A node whose build inputs are unchanged (same id, structural spec, width, rate, block) is built as an empty placeholder, and when the new graph swaps in on the running worker it takes over the old node's live state (`OutputGraph::adopt_from`): a source its ring, queue and resamplers, with the bridge feeding that ring kept; an effect its instances, controls and handles; an edge its delay line when the delay is unchanged. Only a graph that is not swapped in place is built from scratch. +- Nothing steps the output: a speaker fades in over 10 ms when it starts and out over 10 ms before it retires, and a swap that carries nothing over renders the old and new graphs together for 10 ms, crossfading from one to the other (`worker.rs`). - An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs on an offload thread (`effects/offload.rs`). One that can only step in whole blocks of its own (a model hop) names that block, and the offload gathers exactly those before calling it; the effect never pads or buffers for it itself. The offload's pad is the least that never starves at the worst alignment of those blocks against the engine's, plus a 5 ms turnaround. The effect declares only its own latency (a model's algorithmic delay, a resampler's queues), the offload adds its pad, PDC aligns against the sum, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index d48a9955..9418ddd6 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -8,6 +8,7 @@ use rtrb::{Consumer, Producer, RingBuffer}; use super::asrc::Asrc; use super::cushion::{Adjust, Cushion, MAX_SPLICE_FRAMES}; use super::latency::NodeTiming; +use super::sig::MONITOR_KEY; use crate::audio::effects::{ instantiate_effect, update_meter, EffectControl, EffectRegistry, GrHandle, LufsHandle, MeterHandle, RuntimeEffect, WaveformHandle, @@ -255,8 +256,8 @@ pub(super) struct SourceMeta { /// for ring-sources and network producers -- they don't go through a /// capture broadcast. pub input_id: Option, - /// Owning output id (or "monitor"), the other half of the key that - /// disambiguates one input feeding several outputs. + /// Owning output id (`MONITOR_KEY` for the monitor), the other half of + /// the key that disambiguates one input feeding several outputs. pub output_id: String, /// Capture-side fed/dropped counters, filled in by `pipeline/mod.rs` /// after `BroadcastTx::add` returns them for this source's ring. @@ -317,9 +318,31 @@ struct SourceState { // Per-channel taps ("chK") drawn off this source. handle_bufs: Vec<(String, Vec)>, stats: SourceStats, + /// Built empty, to take over the running source it replaces (its ring, + /// queue and resamplers) when the graph swaps in. + carry_over: bool, } impl SourceState { + /// Takes over `old`'s ring and everything queued or learned about it, so + /// the source plays on across a graph swap as if nothing happened. What + /// belongs to the graph (taps, stats, labels) stays this graph's own. + fn take_over(&mut self, old: &mut SourceState) { + std::mem::swap(&mut self.consumer, &mut old.consumer); + std::mem::swap(&mut self.resampler, &mut old.resampler); + std::mem::swap(&mut self.asrc, &mut old.asrc); + std::mem::swap(&mut self.input_staging, &mut old.input_staging); + std::mem::swap(&mut self.splice_tmp, &mut old.splice_tmp); + std::mem::swap(&mut self.out_pending, &mut old.out_pending); + std::mem::swap(&mut self.chunk_tmp, &mut old.chunk_tmp); + std::mem::swap(&mut self.cushion, &mut old.cushion); + self.queue_avg = old.queue_avg; + self.queued_after = old.queued_after; + self.last_pop_at = old.last_pop_at; + self.last_drain_gen = old.last_drain_gen; + self.carry_over = false; + } + fn is_stalled(&self) -> bool { self.last_pop_at.elapsed() > STALL_THRESHOLD } @@ -727,6 +750,11 @@ impl EffectState { /// buffers are split into stereo pairs, each through its own instance so /// per-channel filter state never bleeds across pairs. fn run(&mut self, frames: usize) { + // A carry-over placeholder whose running node never arrived. + if self.effects.is_empty() { + self.out_buf.fill(0.0); + return; + } let w = self.out_buf.len() / frames; if self.full_width || w == 2 { let sc = self.sidechain_buf.as_deref(); @@ -1003,7 +1031,37 @@ struct DelayLine { pos: usize, } +/// Hands each delay line of `old_edges` to the edge of `new_edges` that runs +/// between the same nodes and handles at the same delay, so the audio it holds +/// keeps flowing instead of restarting from silence. +fn carry_delays( + new_edges: &mut [IncomingEdge], + new_ids: &[String], + old_edges: &mut [IncomingEdge], + old_ids: &[String], +) { + for e in new_edges.iter_mut() { + let Some(d) = e.delay.as_mut() else { continue }; + let src = &new_ids[e.src_idx]; + if let Some(od) = old_edges + .iter_mut() + .filter(|o| { + &old_ids[o.src_idx] == src + && o.source_handle == e.source_handle + && o.target_handle == e.target_handle + }) + .find_map(|o| o.delay.as_mut().filter(|od| od.same_shape(d))) + { + std::mem::swap(d, od); + } + } +} + impl DelayLine { + fn same_shape(&self, other: &DelayLine) -> bool { + self.buf.len() == other.buf.len() && self.scratch.len() == other.scratch.len() + } + fn new(delay_frames: usize, channels: usize, block_frames: usize) -> Self { Self { buf: vec![0.0; delay_frames * channels].into_boxed_slice(), @@ -1039,6 +1097,11 @@ pub(super) struct OutputGraph { /// speaker sets it to the device's channel count. out_channels: usize, nodes: Vec, + /// Graph node id of each of `nodes` (empty for a wire sender's sink). + node_ids: Vec, + /// What each node was built from, where a later graph may carry the node + /// over: equal keys build equal nodes. + node_keys: Vec>, terminals: Vec, /// Lookahead the graph's delay compensation has aligned every path to: the /// deepest cumulative effect latency from any source to this output. The @@ -1127,6 +1190,72 @@ impl OutputGraph { } } + /// RT-safe. Takes over the running state of every node this graph was + /// built to carry over from `old`, the graph it replaces, and every delay + /// line on an edge both share at the same length. Returns whether any + /// node was carried over; `old` cannot render after that. + pub(super) fn adopt_from(&mut self, old: &mut OutputGraph) -> bool { + let mut carried = false; + for i in 0..self.nodes.len() { + let Some(j) = (0..old.nodes.len()) + .find(|&j| !self.node_ids[i].is_empty() && old.node_ids[j] == self.node_ids[i]) + else { + continue; + }; + let same_key = self.node_keys[i].is_some() && old.node_keys[j] == self.node_keys[i]; + match (&mut self.nodes[i], &mut old.nodes[j]) { + (DagNode::Source(new), DagNode::Source(prev)) if same_key && new.carry_over => { + new.take_over(prev); + carried = true; + } + (DagNode::Effect(new), DagNode::Effect(prev)) => { + if same_key && new.effects.is_empty() { + std::mem::swap(&mut new.effects, &mut prev.effects); + carried = true; + } + carry_delays( + &mut new.incoming, + &self.node_ids, + &mut prev.incoming, + &old.node_ids, + ); + carry_delays( + &mut new.sidechain, + &self.node_ids, + &mut prev.sidechain, + &old.node_ids, + ); + } + (DagNode::Consumer(new), DagNode::Consumer(prev)) => { + carry_delays( + &mut new.incoming, + &self.node_ids, + &mut prev.incoming, + &old.node_ids, + ); + } + _ => {} + } + } + for t in self.terminals.iter_mut() { + let Some(d) = t.delay.as_mut() else { continue }; + let src = &self.node_ids[t.src_idx]; + if let Some(od) = old + .terminals + .iter_mut() + .filter(|o| { + &old.node_ids[o.src_idx] == src + && o.source_handle == t.source_handle + && o.route == t.route + }) + .find_map(|o| o.delay.as_mut().filter(|od| od.same_shape(d))) + { + std::mem::swap(d, od); + } + } + carried + } + /// Fill `output` (`block_frames * out_channels` long) with one block of /// mixed audio at `sample_rate`. pub(super) fn process_block(&mut self, output: &mut [f32]) { @@ -1268,6 +1397,32 @@ pub(super) struct BuiltOutputGraph { pub node_meta: HashMap, /// Latency and working block of every effect built here. pub node_timings: Vec, + /// What the next build of this output needs to carry these nodes over. + pub carried: HashMap, + /// Inputs whose source this graph takes over from the one it replaces: + /// their rings, and the bridges feeding them, stay as they are. + pub carried_inputs: Vec, +} + +/// A node as a later build of the same output sees it: equal keys mean the +/// node can be carried over rather than built again, and the rest is what +/// building it produced that the placeholder must reproduce. +#[derive(Clone)] +pub(super) struct CarriedNode { + key: String, + effect: Option, +} + +#[derive(Clone)] +struct CarriedEffect { + latency: usize, + working_block: Option, + full_width: bool, + bypass: Arc, + meter: Option, + lufs: Option, + gr: Option, + scope: Option, } /// Build the per-output DAG: walk backward from `output_id`, topo-sort the @@ -1298,6 +1453,9 @@ pub(super) fn build_output_graph( // once in its owning output's graph and read back as a ring-source. Maps // node id -> (ring consumer, owner-graph sample rate, channel width). mut cut_leaves: HashMap, + // What the graph this build replaces is running, for carrying unchanged + // nodes over. Empty when nothing is being replaced in place. + previous: &HashMap, ) -> AppResult { let cut_leaf_ids: HashSet = cut_leaves.keys().cloned().collect(); let reachable: HashSet = match output_id { @@ -1369,6 +1527,8 @@ pub(super) fn build_output_graph( let mut sources: Vec = Vec::new(); let mut node_latencies: Vec = Vec::with_capacity(topo.len()); let mut node_timings: Vec = Vec::new(); + let mut carried: HashMap = HashMap::new(); + let mut carried_inputs: Vec = Vec::new(); // Per-node channel width; effects inherit the max width of their upstreams. let mut node_channels: Vec = Vec::with_capacity(topo.len()); @@ -1394,7 +1554,7 @@ pub(super) fn build_output_graph( native_sr: owner_sr, frames_per_block: source.input_samples_per_block / width.max(1), input_id: None, - output_id: output_id.unwrap_or("monitor").to_string(), + output_id: output_id.unwrap_or(MONITOR_KEY).to_string(), capture: None, }); id_to_index.insert(id.clone(), nodes.len()); @@ -1469,11 +1629,23 @@ pub(super) fn build_output_graph( .get(id) .ok_or_else(|| AppError::Validation(format!("input {id} has no SR")))?; let source_channels = input_native_channels.get(id).copied().unwrap_or(2) as usize; + let key = format!( + "src|{:?}|{input_sr}|{source_channels}|{output_sr}|{block_frames}|{source_realtime}", + input.spec + ); + let carry_over = previous.get(id).is_some_and(|p| p.key == key); + carried.insert(id.clone(), CarriedNode { key, effect: None }); // Scale by channels to keep the buffered span constant in time; at // high channel counts a smaller cushion starves on capture-clock drift. - let (producer, consumer) = - RingBuffer::::new(ring_capacity_frames(input_sr) * source_channels); - producer_pairs.push((id.clone(), producer)); + let consumer = if carry_over { + carried_inputs.push(id.clone()); + RingBuffer::::new(1).1 + } else { + let (producer, consumer) = + RingBuffer::::new(ring_capacity_frames(input_sr) * source_channels); + producer_pairs.push((id.clone(), producer)); + consumer + }; let mut ch_handles: Vec = valid .edges .iter() @@ -1531,7 +1703,7 @@ pub(super) fn build_output_graph( native_sr: input_sr, frames_per_block: input_frames_per_block as usize, input_id: Some(id.clone()), - output_id: output_id.unwrap_or("monitor").to_string(), + output_id: output_id.unwrap_or(MONITOR_KEY).to_string(), capture: None, }); let source = SourceState { @@ -1566,6 +1738,7 @@ pub(super) fn build_output_graph( meter: input_meters.get(id).cloned(), handle_bufs: source_handle_bufs, stats, + carry_over, }; id_to_index.insert(id.clone(), nodes.len()); nodes.push(DagNode::Source(source)); @@ -1620,38 +1793,90 @@ pub(super) fn build_output_graph( .max() .unwrap_or(0); let eff_channels = upstream_w.max(target_w).max(tap_w).max(1); + let key = format!( + "fx|{:?}|{eff_channels}|{output_sr}|{block_frames}|{realtime}", + super::sig::structural_effect(&effect.spec) + ); + let previous_effect = previous + .get(id) + .filter(|p| p.key == key) + .and_then(|p| p.effect.clone()); + // A node carried over is built empty and takes over the running + // instances when the graph swaps in; anything else is built now. // Built once the node's width is known: a plugin is offered that // width and may take it whole, the way a DAW instantiates one // multichannel plugin instead of several stereo ones. - let build = instantiate_effect( - &effect.spec, - id, - output_sr, - block_frames, - realtime, - true, - eff_channels, - registry, - ); - if let Some(c) = build.control { - controls.push((id.clone(), c)); - } - if build.bypass_is_new { - bypasses.push((id.clone(), build.bypass.clone())); - } - if let Some(m) = build.meter { - meters.push(m); - } - if let Some(l) = build.lufs { - lufs.push(l); - } - if let Some(g) = build.gr { - gr_handles.push(g); - } - if let Some(s) = build.scope { - scopes.push(s); - } - let bypass = build.bypass; + let (effects, this_effect) = match previous_effect { + Some(p) => (Vec::new(), p), + None => { + let build = instantiate_effect( + &effect.spec, + id, + output_sr, + block_frames, + realtime, + true, + eff_channels, + registry, + ); + if let Some(c) = build.control { + controls.push((id.clone(), c)); + } + if build.bypass_is_new { + bypasses.push((id.clone(), build.bypass.clone())); + } + // Analyzers read all channels at once, and so does a plugin + // that accepted the node's full width. Everything else runs + // one instance per stereo pair. + let full_width = build.full_width + || matches!( + effect.spec, + EffectSpec::LevelMeter(_) + | EffectSpec::Waveform(_) + | EffectSpec::Spectrum(_) + ); + let this_effect = CarriedEffect { + latency: build.effect.latency_frames(), + working_block: build.effect.working_block(), + full_width, + bypass: build.bypass, + meter: build.meter, + lufs: build.lufs, + gr: build.gr, + scope: build.scope, + }; + let pairs = if full_width { + 1 + } else { + eff_channels.div_ceil(2) + }; + let mut effects = Vec::with_capacity(pairs); + effects.push(build.effect); + for _ in 1..pairs { + // Extra stereo pairs are separate instances for wider + // audio, never the editor target. Extra pairs exist + // only when the node is driven pairwise, so each is + // asked for stereo rather than the node's full width. + let extra = instantiate_effect( + &effect.spec, + id, + output_sr, + block_frames, + realtime, + false, + 2, + registry, + ); + effects.push(extra.effect); + } + (effects, this_effect) + } + }; + meters.extend(this_effect.meter.clone()); + lufs.extend(this_effect.lufs.clone()); + gr_handles.extend(this_effect.gr.clone()); + scopes.extend(this_effect.scope.clone()); + let bypass = this_effect.bypass.clone(); let make_edge = |src_idx: usize, source_handle: Option, target_handle: Option| { let pad = max_upstream - node_latencies[src_idx]; @@ -1704,44 +1929,20 @@ pub(super) fn build_output_graph( (h, vec![0.0; block_frames * w]) }) .collect(); - // Analyzers read all channels at once, and so does a plugin that - // accepted the node's full width. Everything else runs one instance - // per stereo pair. - let full_width = build.full_width - || matches!( - effect.spec, - EffectSpec::LevelMeter(_) | EffectSpec::Waveform(_) | EffectSpec::Spectrum(_) - ); - let pairs = if full_width { - 1 - } else { - eff_channels.div_ceil(2) - }; - let mut effects = Vec::with_capacity(pairs); - let own = build.effect.latency_frames(); + let own = this_effect.latency; node_timings.push(NodeTiming { node_id: id.clone(), latency_frames: own as u32, - working_block: build.effect.working_block().map(|w| w as u32), + working_block: this_effect.working_block.map(|w| w as u32), }); - effects.push(build.effect); - for _ in 1..pairs { - // Extra stereo pairs are separate instances for wider audio, - // never the editor target. - // Extra pairs exist only when the node is driven pairwise, so - // each is asked for stereo rather than the node's full width. - let extra = instantiate_effect( - &effect.spec, - id, - output_sr, - block_frames, - realtime, - false, - 2, - registry, - ); - effects.push(extra.effect); - } + let full_width = this_effect.full_width; + carried.insert( + id.clone(), + CarriedNode { + key, + effect: Some(this_effect), + }, + ); id_to_index.insert(id.clone(), nodes.len()); nodes.push(DagNode::Effect(EffectState { effects, @@ -1876,12 +2077,15 @@ pub(super) fn build_output_graph( send_producers, })); + let (node_ids, node_keys) = node_identities(nodes.len(), &id_to_index, &carried); return Ok(BuiltOutputGraph { graph: OutputGraph { sample_rate: output_sr, block_frames, out_channels: 2, nodes, + node_ids, + node_keys, terminals: Vec::new(), latency_frames: max_up, blocks: blocks.clone(), @@ -1903,6 +2107,8 @@ pub(super) fn build_output_graph( }, node_meta, node_timings, + carried, + carried_inputs, }); } @@ -1951,12 +2157,15 @@ pub(super) fn build_output_graph( None => Vec::new(), }; + let (node_ids, node_keys) = node_identities(nodes.len(), &id_to_index, &carried); Ok(BuiltOutputGraph { graph: OutputGraph { sample_rate: output_sr, block_frames, out_channels: 2, nodes, + node_ids, + node_keys, terminals, latency_frames: node_latencies.iter().copied().max().unwrap_or(0), blocks: blocks.clone(), @@ -1978,9 +2187,29 @@ pub(super) fn build_output_graph( }, node_meta, node_timings, + carried, + carried_inputs, }) } +/// Each node's graph id (empty for a node no graph id names) and carry-over +/// key, by index. +fn node_identities( + len: usize, + id_to_index: &HashMap, + carried: &HashMap, +) -> (Vec, Vec>) { + let mut ids = vec![String::new(); len]; + for (id, &i) in id_to_index { + ids[i] = id.clone(); + } + let keys = ids + .iter() + .map(|id| carried.get(id).map(|c| c.key.clone())) + .collect(); + (ids, keys) +} + /// A fan-out node's published ring as another output reads it: the ring, the /// owner's rate and width, and when the owner wrote to it. pub(super) type CutLeaf = (Consumer, u32, usize, Arc); @@ -2070,6 +2299,7 @@ fn ring_source( meter: None, handle_bufs, stats: SourceStats::new(), + carry_over: false, }) } @@ -2446,7 +2676,7 @@ pub(super) mod graph_tests { prod.push_partial_slice(data).1.is_empty() as usize * data.len() } - fn fresh_registry() -> EffectRegistry { + pub(in crate::audio::pipeline) fn fresh_registry() -> EffectRegistry { EffectRegistry::new() } @@ -2498,6 +2728,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), ) .expect("build succeeds"); let mut map = HashMap::new(); @@ -2566,6 +2797,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), ) .expect("build"); let prod = &mut producer_pairs[0].1; @@ -2610,6 +2842,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), ) .expect("build"); let prod = &mut producer_pairs[0].1; @@ -2649,6 +2882,7 @@ pub(super) mod graph_tests { &drain, &HashMap::new(), HashMap::new(), + &HashMap::new(), ) .expect("build"); let prod = &mut producer_pairs[0].1; @@ -2667,6 +2901,351 @@ pub(super) mod graph_tests { assert_eq!(out2, vec![0.0; TIMER_BLOCK_FRAMES * 2]); } + /// Mic into a plain gain and a limiter (2 ms lookahead) in parallel: a + /// source ring, an effect that holds audio, and a delay line padding the + /// plain branch. The branches meet at the speaker, or with `mix` at a gain + /// before it, so the delay line sits on a terminal or an effect's input. + /// `meter` adds a level meter off the mic, an edit that touches none of it. + pub(in crate::audio::pipeline) fn parallel_with_lookahead( + meter: bool, + lookahead_ms: f32, + mix: bool, + ) -> ValidGraph { + let mut nodes = vec![ + mic("m"), + gain_node("g", 0.0), + gain_node("l", 0.0), + speaker("s"), + ]; + let meet = if mix { "x" } else { "s" }; + let mut edges = vec![ + edge("e1", "m", None, "g", None), + edge("e2", "m", None, "l", None), + edge("e3", "g", None, meet, None), + edge("e4", "l", None, meet, None), + ]; + if mix { + nodes.push(gain_node("x", 0.0)); + edges.push(edge("e6", "x", None, "s", None)); + } + if meter { + nodes.push(node("lm", NodeKind::LevelMeter, serde_json::json!({}))); + edges.push(edge("e5", "m", None, "lm", None)); + } + let mut valid = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes, + edges, + } + .validate() + .expect("valid"); + for e in &mut valid.effects { + if e.id == "l" { + e.spec = EffectSpec::Limiter(crate::audio::graph::LimiterData { + ceiling_db: 0.0, + lookahead_ms, + release_ms: 50.0, + bypassed: false, + }); + } + } + valid + } + + /// Builds `valid` at 64-frame blocks with `registry` kept across builds, + /// as the pipeline keeps it, carrying over from `previous`. + pub(in crate::audio::pipeline) fn rebuild( + valid: &ValidGraph, + registry: &mut EffectRegistry, + previous: &HashMap, + realtime: bool, + ) -> (BuiltOutputGraph, HashMap>) { + let mut pairs = Vec::new(); + let native = valid.inputs.iter().map(|i| (i.id.clone(), SR)).collect(); + let native_ch = valid.inputs.iter().map(|i| (i.id.clone(), 2u32)).collect(); + let built = build_output_graph( + Some("s"), + SR, + 64, + realtime, + valid, + &native, + &native_ch, + &mut pairs, + registry, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + HashMap::new(), + previous, + ) + .expect("build succeeds"); + (built, pairs.into_iter().collect()) + } + + #[test] + fn an_edit_elsewhere_leaves_the_running_audio_untouched() { + for mix in [false, true] { + edit_elsewhere(mix); + } + } + + fn edit_elsewhere(mix: bool) { + // The limiter's lookahead and the plain branch's delay line both hold + // 96 frames. Rebuilt from scratch they would restart from silence and + // the source from an empty ring; carried over, the mix goes on sample + // for sample as if no swap happened. + let mut registry = fresh_registry(); + let (mut a, mut producers) = rebuild( + ¶llel_with_lookahead(false, 2.0, mix), + &mut registry, + &HashMap::new(), + false, + ); + let input = producers.get_mut("m").unwrap(); + let fed = quiet_ramp(64 * 40); + push_all(input, &fed); + let mut got = render(&mut a.graph, 20); + + let (mut b, fresh) = rebuild( + ¶llel_with_lookahead(true, 2.0, mix), + &mut registry, + &a.carried, + false, + ); + assert!( + !fresh.contains_key("m"), + "a carried source gets no new ring" + ); + assert_eq!(b.carried_inputs, vec!["m".to_string()]); + assert_eq!(b.graph.latency_frames(), 96); + assert!(b.graph.adopt_from(&mut a.graph), "nodes carried over"); + got.extend(render(&mut b.graph, 20)); + + let pad = 96; + for f in pad..64 * 40 { + let want = 2.0 * fed[(f - pad) * 2]; + assert!( + (got[f * 2] - want).abs() < 1e-5, + "mix {mix}, frame {f}: got {} want {want}", + got[f * 2] + ); + } + } + + #[test] + fn a_structural_change_rebuilds_only_that_node() { + let mut registry = fresh_registry(); + let (a, _) = rebuild( + ¶llel_with_lookahead(false, 2.0, false), + &mut registry, + &HashMap::new(), + false, + ); + let (b, _) = rebuild( + ¶llel_with_lookahead(false, 4.0, false), + &mut registry, + &a.carried, + false, + ); + let effect = |g: &OutputGraph, id: &str| { + let i = g.node_ids.iter().position(|n| n == id).expect("node"); + match &g.nodes[i] { + DagNode::Effect(e) => e.effects.len(), + _ => panic!("{id} is not an effect"), + } + }; + assert_eq!( + effect(&b.graph, "g"), + 0, + "the untouched gain is carried over" + ); + assert!( + effect(&b.graph, "l") > 0, + "the changed limiter is built anew" + ); + assert_eq!(b.graph.latency_frames(), 192, "4 ms lookahead @ 48k"); + } + + #[test] + fn a_live_source_keeps_playing_across_a_swap() { + // A locked live source, fed a block per block: once playing, a swap + // must not re-prime it (silence) or realign it (a jump). + let mut registry = fresh_registry(); + let valid_a = passthrough_graph().0; + let valid_b = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + gain_node("g", 0.0), + speaker("s"), + node("lm", NodeKind::LevelMeter, serde_json::json!({})), + ], + edges: vec![ + edge("e1", "m", None, "g", None), + edge("e2", "g", None, "s", None), + edge("e3", "m", None, "lm", None), + ], + } + .validate() + .expect("valid"); + let (mut a, mut producers) = rebuild(&valid_a, &mut registry, &HashMap::new(), true); + a.graph.lock_inputs(&HashSet::from(["m".to_string()])); + let mut input = producers.remove("m").unwrap(); + let mut next = 1usize; + let mut feed_block = |input: &mut Producer| { + let block: Vec = (0..64) + .flat_map(|i| { + let v = (next + i) as f32; + [v, v] + }) + .collect(); + next += 64; + push_all(input, &block); + }; + for _ in 0..8 { + feed_block(&mut input); + } + let mut out = vec![0.0; 64 * 2]; + let mut played = Vec::new(); + for _ in 0..200 { + feed_block(&mut input); + a.graph.process_block(&mut out); + played.extend(out.iter().step_by(2).copied()); + } + let (mut b, _) = rebuild(&valid_b, &mut registry, &a.carried, true); + b.graph.lock_inputs(&HashSet::from(["m".to_string()])); + assert!(b.graph.adopt_from(&mut a.graph)); + for _ in 0..200 { + feed_block(&mut input); + b.graph.process_block(&mut out); + played.extend(out.iter().step_by(2).copied()); + } + let tail = &played[100 * 64..]; + for w in tail.windows(2) { + assert_eq!(w[1], w[0] + 1.0, "the source skipped or repeated audio"); + } + } + + /// Mic → flat EQ → Mute → 0 dB gain → speaker, with `meter` adding a + /// level meter off the mute: the chain from the field report. + fn mute_chain(meter: bool) -> ValidGraph { + let mut nodes = vec![ + mic("m"), + node("e", NodeKind::Eq, serde_json::json!({ "gainsDb": vec![0.0; 10] })), + node("mu", NodeKind::Mute, serde_json::json!({ "muted": false })), + gain_node("g", 0.0), + speaker("s"), + ]; + let mut edges = vec![ + edge("e1", "m", None, "e", None), + edge("e2", "e", None, "mu", None), + edge("e3", "mu", None, "g", None), + edge("e4", "g", None, "s", None), + ]; + if meter { + nodes.push(node("lm", NodeKind::LevelMeter, serde_json::json!({}))); + edges.push(edge("e5", "mu", None, "lm", None)); + } + GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes, + edges, + } + .validate() + .expect("valid") + } + + #[test] + fn a_chain_through_mute_stays_at_unity() { + // Through a rebuild that carries the chain over, and the mute switched + // off and on again live, a 0.5 sine never comes out above 0.5. + for width in [2usize, 16] { + let mut registry = fresh_registry(); + let (mut a, mut producers) = + rebuild(&mute_chain(false), &mut registry, &HashMap::new(), false); + a.graph.set_out_channels(width); + let muted = a + .controls + .iter() + .find_map(|(id, c)| match c { + EffectControl::Mute { muted } if id == "mu" => Some(muted.clone()), + _ => None, + }) + .expect("mute control"); + let input = producers.get_mut("m").unwrap(); + let sine: Vec = (0..48_000) + .flat_map(|n| { + let v = ((n % 480) as f32 / 480.0 * std::f32::consts::TAU).sin() * 0.5; + [v, v] + }) + .collect(); + push_all(input, &sine); + let mut out = vec![0.0; 64 * width]; + let mut peak = 0.0f32; + for _ in 0..200 { + a.graph.process_block(&mut out); + peak = out.iter().fold(peak, |m, s| m.max(s.abs())); + } + let (mut b, _) = rebuild(&mute_chain(true), &mut registry, &a.carried, false); + b.graph.set_out_channels(width); + assert!(b.graph.adopt_from(&mut a.graph)); + for k in 0..500 { + if k == 100 { + muted.store(true, Ordering::Relaxed); + } + if k == 200 { + muted.store(false, Ordering::Relaxed); + } + b.graph.process_block(&mut out); + peak = out.iter().fold(peak, |m, s| m.max(s.abs())); + } + assert!(peak <= 0.5 + 1e-5, "{width} channels: peak {peak}"); + } + } + + #[test] + fn full_volume_is_unity_gain_into_a_wide_device() { + // A stereo source at 100% into a 16-channel device through a 0 dB + // gain: its two channels come out at exactly the level they went in, + // and nothing else is fed. + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = build_with_block(Some("s"), SR, 64, &valid, SR, false); + built.graph.set_out_channels(16); + push_all(producers.get_mut("m").unwrap(), &vec![0.5; 64 * 2 * 8]); + let mut out = vec![0.0; 64 * 16]; + for _ in 0..4 { + built.graph.process_block(&mut out); + } + for frame in out.chunks_exact(16) { + assert_eq!(&frame[..2], &[0.5, 0.5]); + assert!(frame[2..].iter().all(|&s| s == 0.0)); + } + } + + #[test] + fn the_monitor_sources_are_keyed_like_its_bridges() { + // Delivery counters are joined to a source by (input, output); the + // monitor's bridges are keyed by `MONITOR_KEY`, so its sources must be + // too, or a paused app reads as a pipeline falling behind. + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + node("lm", NodeKind::LevelMeter, serde_json::json!({})), + ], + edges: vec![edge("e1", "m", None, "lm", None)], + }; + let valid = g.validate().expect("valid"); + let (built, _) = build(None, SR, &valid, SR, true); + assert_eq!(built.sources[0].output_id, MONITOR_KEY); + } + #[test] fn limiter_latency_pads_the_parallel_path() { // Two branches to one output: a passthrough gain and a limiter with @@ -3341,6 +3920,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), ) .expect("build"); push_all(&mut pairs[0].1, &vec![0.5; 4 * TIMER_BLOCK_FRAMES]); @@ -3381,6 +3961,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), ); assert!(err.is_err(), "input without SR must fail"); } @@ -3467,6 +4048,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), cut_leaves, + &HashMap::new(), ) .expect("build B"); assert_eq!(built_b.sources.len(), 1, "the shared node is a ring source"); @@ -3573,6 +4155,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), ) .expect("build"); let mut a = stereo_ramp(4096, 0.0); @@ -4096,6 +4679,7 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), cut_leaves, + &HashMap::new(), ) .expect("build B"); assert_eq!(built_b.sources[0].native_sr, 44_100); diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index f2a9ca92..c98dea00 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -96,6 +96,12 @@ pub struct ActivePipeline { stale_bridges: Vec<(String, usize)>, /// Latency and working block per effect node, for the report. node_timings: HashMap, + /// What each output's running graph was built from, so the next graph + /// swapped in on the same worker can carry its unchanged nodes over. + carried_nodes: HashMap>, + /// Bridges of outputs being swapped in place, by (output, input): a source + /// carried over keeps the bridge that feeds its ring. + swapping_slots: HashMap<(String, String), Vec>, /// Each speaker's overload count at the last report. reported_overloads: HashMap, } @@ -110,6 +116,9 @@ struct InputState { normalizer_frames: u64, bridge_tx: BroadcastTx, bridges_by_output: HashMap>, + /// Each bridge slot's delivery counters, for a source carried over into + /// a new graph without a new slot. + capture_by_slot: HashMap, volume: Arc, paused: Option>, drain: Option>, @@ -176,6 +185,8 @@ impl ActivePipeline { xrun_thread: None, stale_bridges: Vec::new(), node_timings: HashMap::new(), + carried_nodes: HashMap::new(), + swapping_slots: HashMap::new(), reported_overloads: HashMap::new(), } } @@ -357,12 +368,62 @@ impl ActivePipeline { } self.tear_down_outputs(); self.stale_bridges.clear(); + self.carried_nodes.clear(); + self.swapping_slots.clear(); self.inputs.clear(); self.meters.clear(); self.gr_handles.clear(); self.scopes.clear(); } + /// Whether `out_id`'s new graph goes to the worker already running it, + /// the only case in which the new graph can take over the old one's nodes. + fn swaps_in_place(&self, out_id: &str, resolved: Option<&ResolvedOutput>) -> bool { + match resolved { + Some(ResolvedOutput::Speaker(spec)) => self + .speakers + .get(out_id) + .is_some_and(|s| s.sample_rate == spec.sample_rate), + Some(ResolvedOutput::File { sample_rate, .. }) => self + .recorders + .get(out_id) + .is_some_and(|r| r.sample_rate == *sample_rate), + Some(ResolvedOutput::WireSender(_)) => self.wire_senders.contains_key(out_id), + None => false, + } + } + + /// A source carried over keeps its ring, so the bridge feeding that ring + /// stays instead of being retired with the rest of the output's. + fn keep_carried_bridges( + &mut self, + out_id: &str, + inputs: &[String], + captured: &mut Vec<(String, String, CaptureStats)>, + ) { + for input_id in inputs { + let key = (out_id.to_string(), input_id.clone()); + let Some(slots) = self.swapping_slots.remove(&key) else { + continue; + }; + self.stale_bridges + .retain(|(id, slot)| !(id == input_id && slots.contains(slot))); + let Some(state) = self.inputs.get_mut(input_id) else { + continue; + }; + for slot in &slots { + if let Some(c) = state.capture_by_slot.get(slot) { + captured.push((input_id.clone(), out_id.to_string(), c.clone())); + } + } + state + .bridges_by_output + .entry(out_id.to_string()) + .or_default() + .extend(slots); + } + } + // Signal all recorders before joining any so they cover the same wall-clock window. fn tear_down_outputs(&mut self) { // Before anything is dismantled: its next tick would measure a window @@ -481,6 +542,10 @@ impl ActivePipeline { let swapping = matches!(cat, Cat::GraphSwap); for (input_id, state) in self.inputs.iter_mut() { if let Some(slots) = state.bridges_by_output.remove(id) { + if swapping { + self.swapping_slots + .insert((id.clone(), input_id.clone()), slots.clone()); + } for slot in slots { if swapping { self.stale_bridges.push((input_id.clone(), slot)); @@ -833,6 +898,9 @@ impl ActivePipeline { // Tag each producer with its owning output_id so per-output // bridges can be tracked in `InputState.bridges_by_output`. let mut output_graphs: HashMap = HashMap::new(); + // Delivery counters of sources carried over, whose bridges are not + // re-added below. + let mut carried_captures: Vec<(String, String, CaptureStats)> = Vec::new(); let mut all_pairs: Vec<(String, String, Producer)> = Vec::new(); // `built.output`'s index in `self.output_stats`, by output id -- lets the // speaker-stream branch below fill in the real channel count and the @@ -874,6 +942,11 @@ impl ActivePipeline { }; let mut my_pairs: Vec<(String, Producer)> = Vec::new(); let cut_leaves = pending_cuts.remove(&out.id).unwrap_or_default(); + let previous = if self.swaps_in_place(&out.id, output_runtime.get(&out.id)) { + self.carried_nodes.get(&out.id).cloned().unwrap_or_default() + } else { + HashMap::new() + }; let mut built = build_output_graph( Some(out.id.as_str()), output_sr, @@ -889,7 +962,11 @@ impl ActivePipeline { &input_drain, &input_meters, cut_leaves, + &previous, )?; + self.keep_carried_bridges(&out.id, &built.carried_inputs, &mut carried_captures); + self.carried_nodes + .insert(out.id.clone(), std::mem::take(&mut built.carried)); // Wire publish taps for nodes this output owns and other outputs read. for (node, cons) in &cut_plan.consumers { if cons.is_empty() @@ -956,7 +1033,15 @@ impl ActivePipeline { // drop backlog like any other live path. Without this its ring // grows unbounded whenever the DSP cannot keep up, and latency // climbs for as long as the pipeline runs. - let built = build_output_graph( + let previous = if self.monitor.is_some() { + self.carried_nodes + .get(MONITOR_KEY) + .cloned() + .unwrap_or_default() + } else { + HashMap::new() + }; + let mut built = build_output_graph( None, monitor_sr, dag::TIMER_BLOCK_FRAMES, @@ -971,7 +1056,15 @@ impl ActivePipeline { &input_drain, &input_meters, pending_cuts.remove(MONITOR_KEY).unwrap_or_default(), + &previous, )?; + self.keep_carried_bridges( + MONITOR_KEY, + &built.carried_inputs, + &mut carried_captures, + ); + self.carried_nodes + .insert(MONITOR_KEY.to_string(), std::mem::take(&mut built.carried)); for (inp_id, prod) in my_pairs { all_pairs.push((MONITOR_KEY.to_string(), inp_id, prod)); } @@ -1015,7 +1108,7 @@ impl ActivePipeline { // into `self.source_stats` afterward -- SourceMeta is already built by // `build_output_graph` above, before the bridge slot (and its counters) // exists, so the two have to be joined here by their shared (input, output) key. - let mut captured: Vec<(String, String, CaptureStats)> = Vec::new(); + let mut captured: Vec<(String, String, CaptureStats)> = carried_captures; for (input_id, tagged) in by_input { if self.inputs.contains_key(&input_id) { let state = self.inputs.get_mut(&input_id).unwrap(); @@ -1034,6 +1127,7 @@ impl ActivePipeline { state.bridge_tx.drain_discarded(); } let (slot, capture) = state.bridge_tx.add(prod)?; + state.capture_by_slot.insert(slot, capture.clone()); captured.push((input_id.clone(), out_id.clone(), capture)); state .bridges_by_output @@ -1062,8 +1156,10 @@ impl ActivePipeline { let drain = new_input_drain.remove(&input_id); let (mut bridge_tx, bridge_rx) = broadcast_channel(); let mut bridges_by_output: HashMap> = HashMap::new(); + let mut capture_by_slot = HashMap::new(); for (out_id, prod) in tagged { let (slot, capture) = bridge_tx.add(prod)?; + capture_by_slot.insert(slot, capture.clone()); captured.push((input_id.clone(), out_id.clone(), capture)); bridges_by_output.entry(out_id).or_default().push(slot); } @@ -1089,6 +1185,7 @@ impl ActivePipeline { normalizer_frames, bridge_tx, bridges_by_output, + capture_by_slot, volume, paused, drain, @@ -1160,6 +1257,7 @@ impl ActivePipeline { normalizer_frames, bridge_tx, bridges_by_output: HashMap::new(), + capture_by_slot: HashMap::new(), volume, paused, drain, @@ -1322,6 +1420,7 @@ impl ActivePipeline { } } + self.swapping_slots.clear(); // The swapped-in graphs own the live rings now; retire the ones that fed // their predecessors. for (input_id, slot) in std::mem::take(&mut self.stale_bridges) { @@ -1548,6 +1647,69 @@ mod tests { assert!(p.speakers.is_empty()); } + #[test] + fn a_carried_source_keeps_the_bridge_that_feeds_its_ring() { + use crate::audio::pipeline::file_reader::file_reader_test_emitter::TestEmitter; + + let path = std::env::temp_dir().join(format!("pipeline_carry_{}.wav", std::process::id())); + let mut enc = crate::audio::encoders::build_encoder( + &path, + 48_000, + 2, + crate::audio::graph::RecordingFormat::Wav { + bit_depth: crate::audio::graph::WavBitDepth::F32, + }, + false, + ) + .unwrap(); + enc.write_interleaved(&[0.0; 64]).unwrap(); + enc.finalize().unwrap(); + let reader = file_reader::start_audio_file_reader( + "f".into(), + path.clone(), + broadcast_channel().1, + false, + Arc::new(AtomicBool::new(true)), + TestEmitter::default(), + ) + .expect("reader"); + let (mut bridge_tx, _rx) = broadcast_channel(); + let (slot, capture) = bridge_tx.add(rtrb::RingBuffer::new(8).0).unwrap(); + let mut p = ActivePipeline::new(); + p.inputs.insert( + "m".into(), + InputState { + _handle: InputHandle::AudioFile(reader), + sample_rate: 48_000, + channels: 2, + io: None, + normalizer_frames: 0, + bridge_tx, + bridges_by_output: HashMap::new(), + capture_by_slot: HashMap::from([(slot, capture)]), + volume: Arc::new(AtomicU32::new(1.0f32.to_bits())), + paused: None, + drain: None, + }, + ); + // As `prepare_for_reconcile` leaves an output swapping in place. + p.swapping_slots + .insert(("s".into(), "m".into()), vec![slot]); + p.stale_bridges.push(("m".into(), slot)); + + let mut captured = Vec::new(); + p.keep_carried_bridges("s", &["m".to_string()], &mut captured); + + assert!( + p.stale_bridges.is_empty(), + "the carried ring's bridge is not retired" + ); + assert_eq!(p.inputs["m"].bridges_by_output["s"], vec![slot]); + assert_eq!(captured.len(), 1, "its delivery counters follow it"); + assert!(p.swapping_slots.is_empty()); + let _ = std::fs::remove_file(&path); + } + #[test] fn file_commands_drive_the_audio_file_reader_atoms() { use crate::audio::pipeline::file_reader::file_reader_test_emitter::TestEmitter; @@ -1593,6 +1755,7 @@ mod tests { normalizer_frames: 0, bridge_tx: broadcast_channel().0, bridges_by_output: HashMap::new(), + capture_by_slot: HashMap::new(), volume: volume.clone(), paused: Some(paused.clone()), drain: Some(drain.clone()), diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index 1af811c0..77d37fc2 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -569,6 +569,7 @@ mod tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), ) .expect("build"); diff --git a/src-tauri/src/audio/pipeline/output/render.rs b/src-tauri/src/audio/pipeline/output/render.rs index 9b618f5b..431c8ae0 100644 --- a/src-tauri/src/audio/pipeline/output/render.rs +++ b/src-tauri/src/audio/pipeline/output/render.rs @@ -465,6 +465,20 @@ mod tests { } } + #[test] + fn resampling_to_the_device_rate_keeps_the_level() { + // A 48 kHz graph into a 44.1 kHz device: a steady 0.5 comes out at 0.5. + let mut t = rig(64, 44_100); + feed(&mut t.input, &vec![0.5; 48_000]); + let got = play(&mut t.r, &[64], 16_000); + let steady = &got[8_000 * 2..]; + let worst = steady + .iter() + .map(|s| (s - 0.5).abs()) + .fold(0.0_f32, f32::max); + assert!(worst < 1e-3, "off by {worst}"); + } + #[test] fn graph_swap_lands_between_blocks() { let mut t = rig(64, SR); diff --git a/src-tauri/src/audio/pipeline/sig.rs b/src-tauri/src/audio/pipeline/sig.rs index f3441843..b478d6be 100644 --- a/src-tauri/src/audio/pipeline/sig.rs +++ b/src-tauri/src/audio/pipeline/sig.rs @@ -95,7 +95,7 @@ pub(super) fn compute_output_sig(graph: &ValidGraph, output_id: &str) -> OutputS /// /// A field is structural unless zeroed below, so an unhandled field forces a /// rebuild rather than being silently dropped from the signature. -fn structural_effect(spec: &EffectSpec) -> EffectSpec { +pub(super) fn structural_effect(spec: &EffectSpec) -> EffectSpec { use EffectSpec as E; let mut s = spec.clone(); match &mut s { diff --git a/src-tauri/src/audio/pipeline/worker.rs b/src-tauri/src/audio/pipeline/worker.rs index 3db0ea57..96107a46 100644 --- a/src-tauri/src/audio/pipeline/worker.rs +++ b/src-tauri/src/audio/pipeline/worker.rs @@ -116,9 +116,19 @@ impl DspWorker { /// the stream instead. #[inline] fn drain_swaps(&mut self) { - while let Ok(new_graph) = self.cmd_rx.pop() { + while let Ok(mut new_graph) = self.cmd_rx.pop() { debug_assert_eq!(new_graph.block_frames(), self.graph.block_frames()); + // Nodes the new graph carries over keep playing without a seam; + // the old graph, emptied of them, cannot render a fade. + let carried = new_graph.adopt_from(&mut self.graph); let old = std::mem::replace(&mut self.graph, new_graph); + if carried { + if let Some(older) = self.fading.take() { + let _ = self.old_graph_tx.push(older); + } + let _ = self.old_graph_tx.push(old); + continue; + } // A swap landing mid-fade cuts the older graph short; the newer // one fades from the graph that was playing. if let Some(older) = self.fading.replace(old) { @@ -323,6 +333,34 @@ mod tests { ); } + #[test] + fn a_swap_that_carries_nodes_over_does_not_fade() { + use super::super::dag::graph_tests::{fresh_registry, parallel_with_lookahead, rebuild}; + let mut registry = fresh_registry(); + let (a, _producers) = rebuild( + ¶llel_with_lookahead(false, 2.0, false), + &mut registry, + &std::collections::HashMap::new(), + false, + ); + let (b, _) = rebuild( + ¶llel_with_lookahead(true, 2.0, false), + &mut registry, + &a.carried, + false, + ); + let (mut worker, mut ctrl) = dsp_worker(a.graph); + ctrl.send_graph(b.graph).expect("queue swap"); + let mut block = block_for(&worker); + worker.next_block(&mut block); + assert!(worker.fading.is_none(), "an emptied graph cannot fade out"); + assert_eq!( + ctrl.old_graph_rx.slots(), + 1, + "the old graph went back at once" + ); + } + #[test] fn full_swap_queue_is_an_error_not_a_block() { let (_worker, mut ctrl) = dsp_worker(graph(2)); From 8332a40ec63adab51d678be0f915a324139d8db0 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:57:40 +0300 Subject: [PATCH 12/51] test(audio): commit the recorded tap timing fixtures the cushion tests read --- .gitignore | 1 - .../pipeline/testdata/chrome_tap_512.csv | 1951 ++ .../audio/pipeline/testdata/chrome_tap_64.csv | 15616 ++++++++++++++++ 3 files changed, 17567 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/audio/pipeline/testdata/chrome_tap_512.csv create mode 100644 src-tauri/src/audio/pipeline/testdata/chrome_tap_64.csv diff --git a/.gitignore b/.gitignore index 96a7be34..c81ee9cd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,3 @@ vite.config.ts.timestamp-* # storybook /storybook-static -/src-tauri/src/audio/pipeline/testdata diff --git a/src-tauri/src/audio/pipeline/testdata/chrome_tap_512.csv b/src-tauri/src/audio/pipeline/testdata/chrome_tap_512.csv new file mode 100644 index 00000000..52f0ed45 --- /dev/null +++ b/src-tauri/src/audio/pipeline/testdata/chrome_tap_512.csv @@ -0,0 +1,1951 @@ +0.029833,512 +0.041073,512 +0.052690,512 +0.064292,512 +0.075888,512 +0.087442,512 +0.099160,512 +0.110626,512 +0.122308,512 +0.133954,512 +0.145553,512 +0.157107,512 +0.168816,512 +0.180271,512 +0.191987,512 +0.203615,512 +0.215195,512 +0.226742,512 +0.238459,512 +0.249974,512 +0.261694,512 +0.273439,512 +0.284835,512 +0.296372,512 +0.308019,512 +0.319754,512 +0.331248,512 +0.342883,512 +0.354577,512 +0.366071,512 +0.377738,512 +0.389301,512 +0.400921,512 +0.412587,512 +0.424219,512 +0.438800,512 +0.448481,512 +0.459010,512 +0.470574,512 +0.482181,512 +0.493861,512 +0.505451,512 +0.517111,512 +0.528669,512 +0.540237,512 +0.551846,512 +0.563421,512 +0.575070,512 +0.586671,512 +0.598310,512 +0.609891,512 +0.621522,512 +0.633193,512 +0.644719,512 +0.656341,512 +0.668003,512 +0.679563,512 +0.691092,512 +0.702712,512 +0.714333,512 +0.726022,512 +0.737561,512 +0.749261,512 +0.760864,512 +0.772437,512 +0.784039,512 +0.795711,512 +0.807199,512 +0.818837,512 +0.830494,512 +0.842110,512 +0.853757,512 +0.865291,512 +0.876920,512 +0.888520,512 +0.900132,512 +0.911787,512 +0.923317,512 +0.935005,512 +0.946522,512 +0.958144,512 +0.969793,512 +0.981401,512 +0.993026,512 +1.004789,512 +1.016257,512 +1.027865,512 +1.039488,512 +1.051090,512 +1.062663,512 +1.074273,512 +1.085905,512 +1.097467,512 +1.109082,512 +1.120657,512 +1.132310,512 +1.143976,512 +1.155491,512 +1.167155,512 +1.178800,512 +1.190363,512 +1.201940,512 +1.213523,512 +1.225194,512 +1.236913,512 +1.248389,512 +1.260053,512 +1.271593,512 +1.283257,512 +1.294875,512 +1.306428,512 +1.318115,512 +1.329721,512 +1.341287,512 +1.352953,512 +1.364468,512 +1.376082,512 +1.387703,512 +1.399382,512 +1.410889,512 +1.422648,512 +1.434256,512 +1.445828,512 +1.457443,512 +1.469056,512 +1.480608,512 +1.492178,512 +1.503885,512 +1.515522,512 +1.527015,512 +1.538765,512 +1.550224,512 +1.561981,512 +1.573546,512 +1.585046,512 +1.596711,512 +1.608276,512 +1.619942,512 +1.631576,512 +1.643140,512 +1.655924,512 +1.666424,512 +1.678309,512 +1.689775,512 +1.701291,512 +1.712846,512 +1.724437,512 +1.736150,512 +1.747730,512 +1.759320,512 +1.770938,512 +1.782486,512 +1.794193,512 +1.805699,512 +1.817303,512 +1.828930,512 +1.840530,512 +1.852227,512 +1.863911,512 +1.875424,512 +1.887073,512 +1.898604,512 +1.910259,512 +1.921864,512 +1.933472,512 +1.945086,512 +1.956625,512 +1.968232,512 +1.979884,512 +1.991462,512 +2.003116,512 +2.014621,512 +2.026236,512 +2.037867,512 +2.049493,512 +2.061075,512 +2.072766,512 +2.084328,512 +2.095911,512 +2.107529,512 +2.119154,512 +2.130787,512 +2.142313,512 +2.153924,512 +2.165617,512 +2.177272,512 +2.188930,512 +2.200470,512 +2.212139,512 +2.223670,512 +2.235222,512 +2.246846,512 +2.258479,512 +2.270050,512 +2.281658,512 +2.293259,512 +2.304953,512 +2.316600,512 +2.328271,512 +2.339805,512 +2.351426,512 +2.363033,512 +2.374554,512 +2.386121,512 +2.397811,512 +2.409455,512 +2.421005,512 +2.432645,512 +2.444352,512 +2.455875,512 +2.467518,512 +2.479027,512 +2.490679,512 +2.502321,512 +2.513902,512 +2.525452,512 +2.537146,512 +2.548684,512 +2.560422,512 +2.571928,512 +2.583506,512 +2.595250,512 +2.606811,512 +2.618447,512 +2.629962,512 +2.641572,512 +2.653162,512 +2.664799,512 +2.676528,512 +2.688116,512 +2.699610,512 +2.711282,512 +2.722935,512 +2.734461,512 +2.746143,512 +2.757765,512 +2.769309,512 +2.780921,512 +2.792534,512 +2.804142,512 +2.815813,512 +2.827412,512 +2.839019,512 +2.850605,512 +2.862241,512 +2.873764,512 +2.885407,512 +2.897075,512 +2.908660,512 +2.920318,512 +2.931866,512 +2.943466,512 +2.955196,512 +2.966644,512 +2.978399,512 +2.989898,512 +3.001507,512 +3.013190,512 +3.024716,512 +3.036418,512 +3.048024,512 +3.059597,512 +3.071201,512 +3.082863,512 +3.094484,512 +3.106038,512 +3.117630,512 +3.129219,512 +3.140939,512 +3.152446,512 +3.164017,512 +3.175687,512 +3.187374,512 +3.198856,512 +3.210528,512 +3.222140,512 +3.233726,512 +3.245475,512 +3.256933,512 +3.268549,512 +3.280143,512 +3.291731,512 +3.303447,512 +3.315076,512 +3.326559,512 +3.338156,512 +3.349786,512 +3.361455,512 +3.373072,512 +3.384635,512 +3.396285,512 +3.407874,512 +3.419525,512 +3.431086,512 +3.442748,512 +3.454261,512 +3.465899,512 +3.477544,512 +3.489131,512 +3.500810,512 +3.512422,512 +3.523890,512 +3.535520,512 +3.547231,512 +3.558811,512 +3.570453,512 +3.582010,512 +3.593581,512 +3.605281,512 +3.616880,512 +3.628567,512 +3.640131,512 +3.651772,512 +3.663287,512 +3.674962,512 +3.686480,512 +3.698192,512 +3.709654,512 +3.721391,512 +3.732945,512 +3.744582,512 +3.756183,512 +3.767844,512 +3.779432,512 +3.791033,512 +3.802584,512 +3.814136,512 +3.825787,512 +3.837471,512 +3.848969,512 +3.860624,512 +3.872346,512 +3.883932,512 +3.895506,512 +3.907076,512 +3.918751,512 +3.930311,512 +3.941896,512 +3.953499,512 +3.965084,512 +3.976797,512 +3.988351,512 +3.999913,512 +4.011567,512 +4.023270,512 +4.034843,512 +4.046434,512 +4.058083,512 +4.069653,512 +4.081282,512 +4.092812,512 +4.104506,512 +4.116033,512 +4.127628,512 +4.139240,512 +4.150942,512 +4.162459,512 +4.174164,512 +4.185711,512 +4.197331,512 +4.208881,512 +4.220513,512 +4.232131,512 +4.243737,512 +4.255389,512 +4.267061,512 +4.278574,512 +4.290205,512 +4.301792,512 +4.313377,512 +4.325028,512 +4.336679,512 +4.348267,512 +4.359855,512 +4.371513,512 +4.383105,512 +4.394744,512 +4.406346,512 +4.417925,512 +4.429500,512 +4.441161,512 +4.452710,512 +4.464324,512 +4.475998,512 +4.487647,512 +4.499211,512 +4.510772,512 +4.522413,512 +4.533988,512 +4.545571,512 +4.557278,512 +4.568903,512 +4.580534,512 +4.592173,512 +4.603668,512 +4.615304,512 +4.626930,512 +4.638562,512 +4.650121,512 +4.661785,512 +4.673292,512 +4.684921,512 +4.696538,512 +4.708210,512 +4.719818,512 +4.731417,512 +4.742988,512 +4.754622,512 +4.766171,512 +4.777782,512 +4.789404,512 +4.801083,512 +4.812611,512 +4.824212,512 +4.835917,512 +4.847464,512 +4.859094,512 +4.870719,512 +4.882253,512 +4.893924,512 +4.905601,512 +4.917167,512 +4.928794,512 +4.940358,512 +4.951946,512 +4.965981,512 +4.980847,512 +5.011686,1536 +5.024696,512 +5.033869,512 +5.044949,512 +5.056482,512 +5.068699,512 +5.079701,512 +5.091633,512 +5.102924,512 +5.114643,512 +5.126323,512 +5.137776,512 +5.149332,512 +5.160904,512 +5.172627,512 +5.184225,512 +5.195864,512 +5.207366,512 +5.220372,512 +5.230600,512 +5.242280,512 +5.253855,512 +5.265472,512 +5.277170,512 +5.288618,512 +5.300763,512 +5.311954,512 +5.323547,512 +5.335107,512 +5.346738,512 +5.358315,512 +5.370018,512 +5.381472,512 +5.393103,512 +5.404814,512 +5.416425,512 +5.428002,512 +5.439640,512 +5.451162,512 +5.462896,512 +5.474487,512 +5.485991,512 +5.497662,512 +5.509289,512 +5.520819,512 +5.532480,512 +5.544080,512 +5.555723,512 +5.567286,512 +5.578907,512 +5.590501,512 +5.602170,512 +5.613753,512 +5.625352,512 +5.636980,512 +5.648517,512 +5.660111,512 +5.671832,512 +5.683374,512 +5.695159,512 +5.706671,512 +5.718295,512 +5.729803,512 +5.741444,512 +5.753033,512 +5.764716,512 +5.776365,512 +5.787892,512 +5.799517,512 +5.811205,512 +5.822696,512 +5.834421,512 +5.846798,512 +5.859247,512 +5.869243,512 +5.881945,512 +5.893640,512 +5.904168,512 +5.916035,512 +5.927331,512 +5.938882,512 +5.950404,512 +5.962109,512 +5.973677,512 +5.985349,512 +5.996906,512 +6.008508,512 +6.020186,512 +6.031794,512 +6.043318,512 +6.054856,512 +6.066536,512 +6.078180,512 +6.090773,512 +6.101412,512 +6.112926,512 +6.124541,512 +6.136118,512 +6.148954,512 +6.159485,512 +6.170997,512 +6.182662,512 +6.194239,512 +6.206036,512 +6.217426,512 +6.229084,512 +6.240664,512 +6.252869,512 +6.264185,512 +6.275554,512 +6.287119,512 +6.305669,512 +6.312308,512 +6.322020,512 +6.333525,512 +6.345268,512 +6.356801,512 +6.368363,512 +6.380026,512 +6.391590,512 +6.403269,512 +6.414760,512 +6.426423,512 +6.438133,512 +6.449607,512 +6.461369,512 +6.472913,512 +6.484533,512 +6.496081,512 +6.507714,512 +6.519281,512 +6.530855,512 +6.542518,512 +6.554069,512 +6.565792,512 +6.577487,512 +6.588957,512 +6.600634,512 +6.612182,512 +6.623744,512 +6.635501,512 +6.646995,512 +6.658636,512 +6.670317,512 +6.681808,512 +6.693516,512 +6.705107,512 +6.716774,512 +6.728269,512 +6.739873,512 +6.751464,512 +6.763201,512 +6.774760,512 +6.786342,512 +6.797904,512 +6.809539,512 +6.821216,512 +6.832829,512 +6.844406,512 +6.855947,512 +6.867624,512 +6.879195,512 +6.890856,512 +6.902478,512 +6.914161,512 +6.925595,512 +6.937282,512 +6.948874,512 +6.960501,512 +6.972150,512 +6.983789,512 +6.995343,512 +7.006943,512 +7.018521,512 +7.030173,512 +7.041843,512 +7.053424,512 +7.064931,512 +7.076604,512 +7.088214,512 +7.099783,512 +7.111414,512 +7.122958,512 +7.134583,512 +7.146181,512 +7.157851,512 +7.169480,512 +7.181089,512 +7.192766,512 +7.204339,512 +7.215853,512 +7.227519,512 +7.239093,512 +7.250826,512 +7.262301,512 +7.273906,512 +7.285597,512 +7.297142,512 +7.308768,512 +7.320341,512 +7.332044,512 +7.343637,512 +7.355216,512 +7.366948,512 +7.378486,512 +7.390044,512 +7.401686,512 +7.413231,512 +7.424922,512 +7.436486,512 +7.448110,512 +7.459744,512 +7.471325,512 +7.482864,512 +7.494465,512 +7.506122,512 +7.517743,512 +7.529479,512 +7.540969,512 +7.552584,512 +7.564164,512 +7.575791,512 +7.587522,512 +7.599075,512 +7.610759,512 +7.622296,512 +7.633845,512 +7.645546,512 +7.657049,512 +7.668672,512 +7.680287,512 +7.691952,512 +7.703567,512 +7.715153,512 +7.726669,512 +7.738307,512 +7.749911,512 +7.761575,512 +7.773141,512 +7.784808,512 +7.796408,512 +7.808069,512 +7.819616,512 +7.831292,512 +7.842836,512 +7.854423,512 +7.866094,512 +7.877687,512 +7.889211,512 +7.900842,512 +7.912545,512 +7.924131,512 +7.935707,512 +7.947360,512 +7.958875,512 +7.970510,512 +7.982084,512 +7.993750,512 +8.005325,512 +8.017033,512 +8.028607,512 +8.040226,512 +8.051773,512 +8.063371,512 +8.075077,512 +8.086620,512 +8.098217,512 +8.109939,512 +8.121458,512 +8.133013,512 +8.144657,512 +8.156401,512 +8.167947,512 +8.179525,512 +8.191150,512 +8.202803,512 +8.214347,512 +8.225962,512 +8.237593,512 +8.249186,512 +8.260762,512 +8.272496,512 +8.284048,512 +8.295749,512 +8.307250,512 +8.319013,512 +8.330448,512 +8.342070,512 +8.354563,512 +8.365445,512 +8.377240,512 +8.388548,512 +8.400236,512 +8.411741,512 +8.423379,512 +8.435027,512 +8.446550,512 +8.458148,512 +8.469780,512 +8.481419,512 +8.492982,512 +8.504658,512 +8.516177,512 +8.527868,512 +8.539461,512 +8.551075,512 +8.562634,512 +8.574277,512 +8.585887,512 +8.597537,512 +8.609111,512 +8.620721,512 +8.632241,512 +8.643929,512 +8.655529,512 +8.667111,512 +8.678705,512 +8.690318,512 +8.702075,512 +8.713707,512 +8.725366,512 +8.736913,512 +8.748384,512 +8.760040,512 +8.771677,512 +8.783251,512 +8.794902,512 +8.806481,512 +8.818119,512 +8.829753,512 +8.841346,512 +8.852979,512 +8.864545,512 +8.876171,512 +8.887774,512 +8.899312,512 +8.911007,512 +8.922530,512 +8.934162,512 +8.945864,512 +8.957463,512 +8.969077,512 +8.980579,512 +8.992216,512 +9.003862,512 +9.015512,512 +9.027041,512 +9.038649,512 +9.050319,512 +9.061924,512 +9.073460,512 +9.085106,512 +9.096668,512 +9.108362,512 +9.119979,512 +9.131614,512 +9.143197,512 +9.154842,512 +9.166426,512 +9.178011,512 +9.189591,512 +9.201150,512 +9.212888,512 +9.224489,512 +9.236068,512 +9.247656,512 +9.259205,512 +9.270800,512 +9.282429,512 +9.294014,512 +9.305698,512 +9.317271,512 +9.328899,512 +9.340532,512 +9.352071,512 +9.363710,512 +9.375278,512 +9.386889,512 +9.398608,512 +9.410220,512 +9.421808,512 +9.433477,512 +9.445038,512 +9.456555,512 +9.468272,512 +9.479798,512 +9.491474,512 +9.503121,512 +9.514630,512 +9.526285,512 +9.537865,512 +9.549579,512 +9.561192,512 +9.572705,512 +9.584364,512 +9.596109,512 +9.607574,512 +9.619285,512 +9.630826,512 +9.642371,512 +9.654015,512 +9.665609,512 +9.677260,512 +9.688779,512 +9.700400,512 +9.711994,512 +9.723694,512 +9.735258,512 +9.746861,512 +9.758521,512 +9.770037,512 +9.781707,512 +9.793318,512 +9.804860,512 +9.816471,512 +9.828152,512 +9.839790,512 +9.851289,512 +9.862960,512 +9.874591,512 +9.886159,512 +9.897859,512 +9.909376,512 +9.920995,512 +9.932678,512 +9.944361,512 +9.955862,512 +9.967385,512 +9.978985,512 +9.990626,512 +10.002214,512 +10.013908,512 +10.025449,512 +10.037110,512 +10.048667,512 +10.060319,512 +10.071897,512 +10.083611,512 +10.095170,512 +10.106824,512 +10.118328,512 +10.130017,512 +10.141600,512 +10.153297,512 +10.164864,512 +10.176399,512 +10.188100,512 +10.199722,512 +10.211266,512 +10.222904,512 +10.234462,512 +10.246139,512 +10.257689,512 +10.269263,512 +10.280885,512 +10.292519,512 +10.304166,512 +10.315715,512 +10.327404,512 +10.338954,512 +10.350614,512 +10.362335,512 +10.373816,512 +10.385452,512 +10.396989,512 +10.408620,512 +10.420253,512 +10.431879,512 +10.443495,512 +10.455034,512 +10.466620,512 +10.478365,512 +10.489914,512 +10.501554,512 +10.513182,512 +10.524747,512 +10.536369,512 +10.547976,512 +10.560449,512 +10.571125,512 +10.582832,512 +10.594347,512 +10.605982,512 +10.617584,512 +10.629248,512 +10.640845,512 +10.652434,512 +10.664059,512 +10.675694,512 +10.687217,512 +10.698864,512 +10.710497,512 +10.722141,512 +10.733739,512 +10.745273,512 +10.756982,512 +10.768537,512 +10.780206,512 +10.791791,512 +10.803469,512 +10.814960,512 +10.826630,512 +10.838161,512 +10.849817,512 +10.861407,512 +10.873018,512 +10.884596,512 +10.896204,512 +10.907821,512 +10.919512,512 +10.931059,512 +10.942707,512 +10.954363,512 +10.965928,512 +10.977608,512 +10.989219,512 +11.000681,512 +11.012362,512 +11.023959,512 +11.035561,512 +11.047208,512 +11.058718,512 +11.070320,512 +11.082056,512 +11.093608,512 +11.105297,512 +11.116798,512 +11.128488,512 +11.140011,512 +11.151624,512 +11.163261,512 +11.174873,512 +11.186561,512 +11.198155,512 +11.209746,512 +11.221332,512 +11.232877,512 +11.244557,512 +11.256148,512 +11.267743,512 +11.279421,512 +11.290925,512 +11.302548,512 +11.314230,512 +11.325755,512 +11.337358,512 +11.348967,512 +11.360610,512 +11.372232,512 +11.383855,512 +11.395431,512 +11.407060,512 +11.418620,512 +11.430285,512 +11.441921,512 +11.453594,512 +11.465099,512 +11.476761,512 +11.488289,512 +11.499984,512 +11.511539,512 +11.523205,512 +11.534846,512 +11.546390,512 +11.557997,512 +11.569574,512 +11.581163,512 +11.592797,512 +11.604442,512 +11.616123,512 +11.627758,512 +11.639314,512 +11.650847,512 +11.662432,512 +11.674096,512 +11.685665,512 +11.697382,512 +11.708961,512 +11.720505,512 +11.732234,512 +11.743720,512 +11.755398,512 +11.766956,512 +11.778580,512 +11.790203,512 +11.801824,512 +11.813458,512 +11.825042,512 +11.836681,512 +11.848324,512 +11.859916,512 +11.871565,512 +11.883052,512 +11.894627,512 +11.906311,512 +11.917963,512 +11.929444,512 +11.941151,512 +11.952674,512 +11.964389,512 +11.975917,512 +11.987547,512 +11.999181,512 +13.059587,512 +13.071117,512 +13.082702,512 +13.094343,512 +13.105911,512 +13.117596,512 +13.129257,512 +13.140709,512 +13.152370,512 +13.164028,512 +13.175549,512 +13.187143,512 +13.198874,512 +13.210427,512 +13.222087,512 +13.233610,512 +13.245214,512 +13.256891,512 +13.268496,512 +13.280025,512 +13.291711,512 +13.303321,512 +13.314871,512 +13.326534,512 +13.338205,512 +13.349703,512 +13.361390,512 +13.372989,512 +13.384633,512 +13.396119,512 +13.407778,512 +13.419424,512 +13.431063,512 +13.442663,512 +13.454205,512 +13.465835,512 +13.477413,512 +13.489087,512 +13.500692,512 +13.512291,512 +13.524022,512 +13.535629,512 +13.547168,512 +13.558711,512 +13.570383,512 +13.581988,512 +13.593503,512 +13.605159,512 +13.616751,512 +13.628317,512 +13.640052,512 +13.651532,512 +13.663258,512 +13.674742,512 +13.686448,512 +13.698042,512 +13.709649,512 +13.721213,512 +13.732835,512 +13.744418,512 +13.756095,512 +13.767667,512 +13.779337,512 +13.790854,512 +13.802591,512 +13.814071,512 +13.825843,512 +13.837326,512 +13.848990,512 +13.860546,512 +13.872197,512 +13.883776,512 +13.895412,512 +13.906987,512 +13.918697,512 +13.930281,512 +13.941850,512 +13.953462,512 +13.965080,512 +13.976646,512 +13.988328,512 +13.999872,512 +14.011489,512 +14.023067,512 +14.034738,512 +14.046383,512 +14.057977,512 +14.069596,512 +14.081164,512 +14.092726,512 +14.104313,512 +14.115914,512 +14.127627,512 +14.139244,512 +14.150866,512 +14.162411,512 +14.174058,512 +14.185682,512 +14.197328,512 +14.208848,512 +14.220489,512 +14.232036,512 +14.243733,512 +14.255318,512 +14.266961,512 +14.278578,512 +14.290203,512 +14.301788,512 +14.313409,512 +14.324882,512 +14.336645,512 +14.348125,512 +14.359811,512 +14.371423,512 +14.382972,512 +14.394634,512 +14.406303,512 +14.417846,512 +14.429556,512 +14.441121,512 +14.452749,512 +14.464308,512 +14.475995,512 +14.487430,512 +14.499129,512 +14.510740,512 +14.522389,512 +14.533964,512 +14.545521,512 +14.557126,512 +14.568820,512 +14.580405,512 +14.592118,512 +14.603601,512 +14.615238,512 +14.626747,512 +14.638490,512 +14.650038,512 +14.661692,512 +14.673307,512 +14.684980,512 +14.696481,512 +14.708024,512 +14.719739,512 +14.731353,512 +14.742969,512 +14.754513,512 +14.766174,512 +14.777741,512 +14.789400,512 +14.801007,512 +14.812566,512 +14.824179,512 +14.835776,512 +14.847449,512 +14.859082,512 +14.870687,512 +14.882206,512 +14.893789,512 +14.905410,512 +14.917116,512 +14.928672,512 +14.940235,512 +14.951855,512 +14.963473,512 +14.975160,512 +14.986843,512 +14.998268,512 +15.009998,512 +15.021586,512 +15.033162,512 +15.044777,512 +15.056371,512 +15.067989,512 +15.079554,512 +15.091230,512 +15.102885,512 +15.114448,512 +15.126058,512 +15.137708,512 +15.149301,512 +15.160882,512 +15.172488,512 +15.184044,512 +15.195769,512 +15.207340,512 +15.218925,512 +15.230593,512 +15.242132,512 +15.253755,512 +15.265339,512 +15.277006,512 +15.288576,512 +15.300155,512 +15.311840,512 +15.323464,512 +15.334981,512 +15.346616,512 +15.358215,512 +15.369820,512 +15.381463,512 +15.393092,512 +15.404757,512 +15.416291,512 +15.427927,512 +15.439584,512 +15.451146,512 +15.462693,512 +15.474378,512 +15.485987,512 +15.497507,512 +15.509174,512 +15.520756,512 +15.532363,512 +15.544089,512 +15.555577,512 +15.567233,512 +15.578868,512 +15.590447,512 +15.602039,512 +15.613705,512 +15.625311,512 +15.636895,512 +15.648494,512 +15.660200,512 +15.671730,512 +15.683272,512 +15.694893,512 +15.706539,512 +15.718100,512 +15.729773,512 +15.741347,512 +15.752953,512 +15.764554,512 +15.776183,512 +15.787901,512 +15.799498,512 +15.811072,512 +15.822586,512 +15.834274,512 +15.845966,512 +15.857530,512 +15.869182,512 +15.880712,512 +15.892397,512 +15.903958,512 +15.915559,512 +15.927156,512 +15.938812,512 +15.950292,512 +15.962019,512 +15.973509,512 +15.985233,512 +15.996772,512 +16.008469,512 +16.020050,512 +16.031611,512 +16.043206,512 +16.054874,512 +16.066407,512 +16.078095,512 +16.089617,512 +16.101340,512 +16.112929,512 +16.124489,512 +16.136153,512 +16.147772,512 +16.159383,512 +16.171000,512 +16.182597,512 +16.194174,512 +16.205804,512 +16.217460,512 +16.229016,512 +16.240644,512 +16.252155,512 +16.263819,512 +16.275361,512 +16.287003,512 +16.298577,512 +16.310242,512 +16.321940,512 +16.333448,512 +16.345118,512 +16.356659,512 +16.368295,512 +16.379995,512 +16.391489,512 +16.403176,512 +16.414761,512 +16.426414,512 +16.437977,512 +16.449543,512 +16.461181,512 +16.472781,512 +16.484366,512 +16.496088,512 +16.507638,512 +16.519193,512 +16.530828,512 +16.542610,512 +16.554129,512 +16.565756,512 +16.577297,512 +16.588894,512 +16.600429,512 +16.612101,512 +16.623770,512 +16.635317,512 +16.646935,512 +16.658607,512 +16.670201,512 +16.681755,512 +16.693408,512 +16.705037,512 +16.716659,512 +16.728222,512 +16.739881,512 +16.751438,512 +16.763064,512 +16.774658,512 +16.786269,512 +16.797875,512 +16.809436,512 +16.821137,512 +16.832662,512 +16.844368,512 +16.855965,512 +16.867604,512 +16.879194,512 +16.890728,512 +16.902331,512 +16.913991,512 +16.925520,512 +16.937136,512 +16.948836,512 +16.960495,512 +16.972081,512 +16.983589,512 +16.995247,512 +17.006834,512 +17.018483,512 +17.030095,512 +17.041691,512 +17.053305,512 +17.064917,512 +17.076491,512 +17.088143,512 +17.099737,512 +17.111374,512 +17.122900,512 +17.134553,512 +17.146207,512 +17.157793,512 +17.169332,512 +17.181012,512 +17.192611,512 +17.204184,512 +17.215907,512 +17.227371,512 +17.239089,512 +17.250623,512 +17.262311,512 +17.273842,512 +17.285547,512 +17.297070,512 +17.308785,512 +17.320313,512 +17.331906,512 +17.343477,512 +17.355178,512 +17.366744,512 +17.378403,512 +17.389995,512 +17.401630,512 +17.413182,512 +17.424868,512 +17.436455,512 +17.448040,512 +17.459644,512 +17.471282,512 +17.482932,512 +17.494418,512 +17.506130,512 +17.517739,512 +17.529254,512 +17.540899,512 +17.552470,512 +17.564170,512 +17.575736,512 +17.587356,512 +17.598925,512 +17.610563,512 +17.622235,512 +17.633782,512 +17.645418,512 +17.656987,512 +17.668631,512 +17.680229,512 +17.691837,512 +17.703513,512 +17.715077,512 +17.726668,512 +17.738336,512 +17.749930,512 +17.761463,512 +17.773117,512 +17.784743,512 +17.796318,512 +17.807897,512 +17.819628,512 +17.831083,512 +17.842848,512 +17.854435,512 +17.866012,512 +17.877682,512 +17.889295,512 +17.900786,512 +17.912381,512 +17.924043,512 +17.935642,512 +17.947201,512 +17.958841,512 +17.970526,512 +17.982152,512 +17.993714,512 +18.005401,512 +18.016848,512 +18.028475,512 +18.040251,512 +18.051783,512 +18.063397,512 +18.074955,512 +18.086506,512 +18.098109,512 +18.109762,512 +18.121545,512 +18.133010,512 +18.144558,512 +18.156154,512 +18.167871,512 +18.179391,512 +18.191019,512 +18.202615,512 +18.214325,512 +18.225935,512 +18.237546,512 +18.249175,512 +18.260830,512 +18.272339,512 +18.283968,512 +18.295551,512 +18.307228,512 +18.318872,512 +18.330397,512 +18.342018,512 +18.354418,512 +18.365216,512 +18.376847,512 +18.388357,512 +18.399989,512 +18.411710,512 +18.423259,512 +18.434866,512 +18.446526,512 +18.458035,512 +18.469746,512 +18.481338,512 +18.492867,512 +18.504577,512 +18.516066,512 +18.527659,512 +18.539350,512 +18.551009,512 +18.562671,512 +18.574242,512 +18.585836,512 +18.597436,512 +18.608939,512 +18.620596,512 +18.632192,512 +18.643756,512 +18.655433,512 +18.667031,512 +18.678686,512 +18.690321,512 +18.701901,512 +18.713421,512 +18.725124,512 +18.736706,512 +18.748311,512 +18.759887,512 +18.771596,512 +18.783219,512 +18.794696,512 +18.806296,512 +18.818044,512 +18.829548,512 +18.841217,512 +18.852844,512 +18.864444,512 +18.876055,512 +18.887701,512 +18.899241,512 +18.910815,512 +18.922465,512 +18.934031,512 +18.945675,512 +18.957255,512 +18.968949,512 +18.980556,512 +18.992169,512 +19.003793,512 +19.015401,512 +19.026930,512 +19.038594,512 +19.050139,512 +19.061732,512 +19.073437,512 +19.085046,512 +19.096675,512 +19.108260,512 +19.119939,512 +19.131413,512 +19.143159,512 +19.154593,512 +19.166366,512 +19.177911,512 +19.189550,512 +19.201133,512 +19.212673,512 +19.224284,512 +19.236044,512 +19.247588,512 +19.259160,512 +19.270835,512 +19.282400,512 +19.293958,512 +19.305593,512 +19.317194,512 +19.328789,512 +19.340391,512 +19.352037,512 +19.363609,512 +19.375223,512 +19.386845,512 +19.398589,512 +19.410044,512 +19.421735,512 +19.433229,512 +19.444910,512 +19.456560,512 +19.468130,512 +19.479826,512 +19.491402,512 +19.503432,512 +19.514588,512 +19.526115,512 +19.537796,512 +19.549334,512 +19.561001,512 +19.572613,512 +19.584189,512 +19.595818,512 +19.607413,512 +19.618999,512 +19.630611,512 +19.642277,512 +19.653917,512 +19.665530,512 +19.677260,512 +19.688730,512 +19.700371,512 +19.711890,512 +19.723568,512 +19.735201,512 +19.746807,512 +19.758410,512 +19.770040,512 +19.781569,512 +19.793258,512 +19.804805,512 +19.816415,512 +19.828039,512 +19.839666,512 +19.851279,512 +19.862887,512 +19.874435,512 +19.886088,512 +19.897667,512 +19.909321,512 +19.920925,512 +19.932555,512 +19.944084,512 +19.955764,512 +19.967337,512 +19.979000,512 +19.990603,512 +20.002155,512 +20.013846,512 +20.025384,512 +20.036965,512 +20.048677,512 +20.060290,512 +20.071915,512 +20.083501,512 +20.095099,512 +20.106673,512 +20.118299,512 +20.129867,512 +20.141565,512 +20.153054,512 +20.164732,512 +20.176351,512 +20.187995,512 +20.199613,512 +20.211162,512 +20.222727,512 +20.234389,512 +20.245953,512 +20.257576,512 +20.269212,512 +20.280826,512 +20.292453,512 +20.304115,512 +20.315704,512 +20.327293,512 +20.338950,512 +20.350502,512 +20.362429,512 +20.374091,512 +20.385522,512 +20.397057,512 +20.408551,512 +20.420163,512 +20.431728,512 +20.443382,512 +20.455009,512 +20.466517,512 +20.478188,512 +20.489823,512 +20.501371,512 +20.512943,512 +20.524578,512 +20.536284,512 +20.547853,512 +20.559435,512 +20.571016,512 +20.582743,512 +20.594304,512 +20.605909,512 +20.617461,512 +20.629083,512 +20.640689,512 +20.652282,512 +20.664051,512 +20.675613,512 +20.687091,512 +20.698773,512 +20.710351,512 +20.722016,512 +20.733553,512 +20.745198,512 +20.756859,512 +20.768415,512 +20.780046,512 +20.791734,512 +20.803319,512 +20.814918,512 +20.826503,512 +20.838196,512 +20.849794,512 +20.861323,512 +20.872927,512 +20.884602,512 +20.896249,512 +20.907874,512 +20.919312,512 +20.931070,512 +20.942539,512 +20.954329,512 +20.965905,512 +20.977386,512 +20.989054,512 +21.000636,512 +21.012248,512 +21.023943,512 +21.035499,512 +21.047097,512 +21.058743,512 +21.070365,512 +21.081963,512 +21.093556,512 +21.105137,512 +21.116657,512 +21.128304,512 +21.139965,512 +21.151517,512 +21.163227,512 +21.174852,512 +21.186412,512 +21.198006,512 +21.209599,512 +21.221194,512 +21.232878,512 +21.244487,512 +21.256009,512 +21.267634,512 +21.279326,512 +21.290924,512 +21.302479,512 +21.314042,512 +21.325661,512 +21.337303,512 +21.348881,512 +21.360557,512 +21.372235,512 +21.383746,512 +21.395422,512 +21.406935,512 +21.418686,512 +21.430234,512 +21.441842,512 +21.453381,512 +21.465095,512 +21.476648,512 +21.488177,512 +21.499854,512 +21.511444,512 +21.523040,512 +21.534703,512 +21.546351,512 +21.557966,512 +21.569468,512 +21.581184,512 +21.592674,512 +21.604321,512 +21.615990,512 +21.627607,512 +21.639260,512 +21.650830,512 +21.662395,512 +21.674016,512 +21.685591,512 +21.697284,512 +21.708785,512 +21.720441,512 +21.732086,512 +21.743786,512 +21.755242,512 +21.766823,512 +21.778568,512 +21.790119,512 +21.801706,512 +21.813281,512 +21.824894,512 +21.836548,512 +21.848184,512 +21.859779,512 +21.871433,512 +21.883018,512 +21.894589,512 +21.906216,512 +21.917823,512 +21.929576,512 +21.941056,512 +21.952932,512 +21.964934,512 +21.975868,512 +21.987545,512 +21.999138,512 +22.010652,512 +22.022374,512 +22.033933,512 +22.045600,512 +22.057142,512 +22.068789,512 +22.080347,512 +22.092021,512 +22.103523,512 +22.115224,512 +22.126754,512 +22.138387,512 +22.150069,512 +22.161606,512 +22.173271,512 +22.184808,512 +22.196399,512 +22.208109,512 +22.219690,512 +22.231280,512 +22.242946,512 +22.254504,512 +22.266172,512 +22.277771,512 +22.289370,512 +22.301058,512 +22.312545,512 +22.324124,512 +22.335778,512 +22.347351,512 +22.358981,512 +22.370594,512 +22.382170,512 +22.393814,512 +22.405431,512 +22.417077,512 +22.428641,512 +22.440325,512 +22.451843,512 +22.463423,512 +22.475108,512 +22.486849,512 +22.498273,512 +22.509964,512 +22.521511,512 +22.533109,512 +22.544740,512 +22.556309,512 +22.568018,512 +22.579580,512 +22.591189,512 +22.602817,512 +22.614377,512 +22.626030,512 +22.637560,512 +22.649292,512 +22.660915,512 +22.672462,512 +22.684111,512 +22.695809,512 +22.707300,512 +22.718951,512 +22.730514,512 +22.742269,512 +22.753743,512 +22.765445,512 +22.776945,512 +22.788651,512 +22.800198,512 +22.811743,512 +22.823448,512 +22.834955,512 +22.846579,512 +22.858260,512 +22.869795,512 +22.881493,512 +22.893040,512 +22.904707,512 +22.916191,512 +22.927927,512 +22.939541,512 +22.951144,512 +22.962655,512 +22.974311,512 +22.985921,512 +22.997556,512 +23.009153,512 +23.020799,512 +23.034368,512 +23.048294,512 +23.056194,512 +23.067272,512 +23.078877,512 +23.090437,512 +23.102074,512 +23.113591,512 +23.125182,512 +23.136912,512 +23.148472,512 +23.160104,512 +23.171724,512 +23.183342,512 +23.194898,512 +23.206484,512 +23.218173,512 +23.229707,512 +23.241289,512 +23.253002,512 +23.264573,512 +23.276167,512 +23.287716,512 +23.299393,512 +23.310965,512 +23.322583,512 +23.334179,512 +23.345881,512 +23.357379,512 +23.369066,512 +23.380995,512 +23.392302,512 +23.403884,512 +23.415527,512 +23.427078,512 +23.438735,512 +23.450282,512 +23.461899,512 +23.473522,512 +23.485136,512 +23.496733,512 +23.508388,512 +23.519974,512 +23.531622,512 +23.543149,512 +23.554817,512 +23.566423,512 +23.578081,512 +23.589605,512 +23.601481,512 +23.612893,512 +23.624537,512 +23.636084,512 +23.647721,512 +23.659224,512 +23.670952,512 +23.682538,512 +23.694239,512 +23.705692,512 +23.717371,512 +23.728905,512 +23.740575,512 diff --git a/src-tauri/src/audio/pipeline/testdata/chrome_tap_64.csv b/src-tauri/src/audio/pipeline/testdata/chrome_tap_64.csv new file mode 100644 index 00000000..a585ef1a --- /dev/null +++ b/src-tauri/src/audio/pipeline/testdata/chrome_tap_64.csv @@ -0,0 +1,15616 @@ +0.023414,64 +0.024052,64 +0.024183,64 +0.025622,64 +0.027040,64 +0.028587,64 +0.030015,64 +0.031456,64 +0.032930,64 +0.034356,64 +0.035766,64 +0.037306,64 +0.038724,64 +0.040170,64 +0.041617,64 +0.043059,64 +0.044494,64 +0.045992,64 +0.047471,64 +0.048924,64 +0.050302,64 +0.051793,64 +0.053236,64 +0.054693,64 +0.056119,64 +0.057557,64 +0.058997,64 +0.060435,64 +0.061875,64 +0.063344,64 +0.064842,64 +0.066229,64 +0.067720,64 +0.069170,64 +0.070620,64 +0.072066,64 +0.073495,64 +0.074933,64 +0.076493,64 +0.077921,64 +0.079350,64 +0.080797,64 +0.082247,64 +0.083715,64 +0.085190,64 +0.086646,64 +0.087990,64 +0.089450,64 +0.090920,64 +0.092368,64 +0.093828,64 +0.095289,64 +0.096759,64 +0.098161,64 +0.099665,64 +0.101132,64 +0.102486,64 +0.103977,64 +0.105468,64 +0.106960,64 +0.108302,64 +0.109782,64 +0.111272,64 +0.112738,64 +0.114192,64 +0.115561,64 +0.117050,64 +0.118518,64 +0.119971,64 +0.121431,64 +0.122900,64 +0.124284,64 +0.125767,64 +0.127242,64 +0.128698,64 +0.130156,64 +0.131624,64 +0.132985,64 +0.134464,64 +0.135925,64 +0.137415,64 +0.138902,64 +0.140341,64 +0.141683,64 +0.143144,64 +0.144628,64 +0.146101,64 +0.147563,64 +0.148961,64 +0.150413,64 +0.151876,64 +0.153323,64 +0.154792,64 +0.156248,64 +0.157761,64 +0.159103,64 +0.160558,64 +0.162102,64 +0.163472,64 +0.164960,64 +0.166426,64 +0.167899,64 +0.169372,64 +0.170760,64 +0.172244,64 +0.173720,64 +0.175172,64 +0.176540,64 +0.178014,64 +0.179484,64 +0.180966,64 +0.182438,64 +0.183879,64 +0.185221,64 +0.186716,64 +0.188194,64 +0.189691,64 +0.191043,64 +0.192515,64 +0.193941,64 +0.195387,64 +0.196823,64 +0.198404,64 +0.199827,64 +0.201268,64 +0.202691,64 +0.204131,64 +0.205547,64 +0.206989,64 +0.208560,64 +0.209982,64 +0.211409,64 +0.212864,64 +0.214326,64 +0.215783,64 +0.217156,64 +0.218616,64 +0.220109,64 +0.221575,64 +0.223048,64 +0.224510,64 +0.225858,64 +0.227338,64 +0.228826,64 +0.230282,64 +0.231729,64 +0.233242,64 +0.234564,64 +0.236047,64 +0.237537,64 +0.238998,64 +0.240483,64 +0.241822,64 +0.243277,64 +0.244757,64 +0.246219,64 +0.247676,64 +0.249143,64 +0.250626,64 +0.252087,64 +0.253567,64 +0.254914,64 +0.256408,64 +0.257888,64 +0.259222,64 +0.260716,64 +0.262194,64 +0.263662,64 +0.265139,64 +0.266523,64 +0.267993,64 +0.269466,64 +0.270941,64 +0.272303,64 +0.273795,64 +0.275274,64 +0.276731,64 +0.278197,64 +0.279671,64 +0.280994,64 +0.282546,64 +0.283901,64 +0.285374,64 +0.286850,64 +0.288313,64 +0.289786,64 +0.291290,64 +0.292635,64 +0.294120,64 +0.295605,64 +0.297040,64 +0.298470,64 +0.299919,64 +0.301362,64 +0.302823,64 +0.304252,64 +0.305704,64 +0.307150,64 +0.308606,64 +0.310042,64 +0.311481,64 +0.312927,64 +0.314394,64 +0.315866,64 +0.317298,64 +0.318843,64 +0.320288,64 +0.321730,64 +0.323171,64 +0.324603,64 +0.326051,64 +0.327443,64 +0.328958,64 +0.330415,64 +0.331840,64 +0.333277,64 +0.334730,64 +0.336189,64 +0.337636,64 +0.339070,64 +0.340504,64 +0.342061,64 +0.343487,64 +0.344949,64 +0.346418,64 +0.347876,64 +0.349201,64 +0.350758,64 +0.352196,64 +0.353642,64 +0.355124,64 +0.356470,64 +0.357924,64 +0.359398,64 +0.360866,64 +0.362353,64 +0.363823,64 +0.365177,64 +0.366654,64 +0.368115,64 +0.369607,64 +0.371009,64 +0.372482,64 +0.373959,64 +0.375364,64 +0.376829,64 +0.378305,64 +0.379798,64 +0.381299,64 +0.382686,64 +0.384080,64 +0.385608,64 +0.386950,64 +0.388424,64 +0.389883,64 +0.391352,64 +0.392837,64 +0.394198,64 +0.395692,64 +0.397182,64 +0.398636,64 +0.400108,64 +0.401475,64 +0.402954,64 +0.404450,64 +0.405936,64 +0.407284,64 +0.408771,64 +0.410255,64 +0.411727,64 +0.413173,64 +0.414511,64 +0.416025,64 +0.417494,64 +0.418962,64 +0.420333,64 +0.421784,64 +0.423236,64 +0.424731,64 +0.426192,64 +0.427658,64 +0.429117,64 +0.430582,64 +0.432047,64 +0.433402,64 +0.434898,64 +0.436302,64 +0.437778,64 +0.439219,64 +0.440727,64 +0.442085,64 +0.443560,64 +0.445010,64 +0.446533,64 +0.447954,64 +0.449416,64 +0.450863,64 +0.452347,64 +0.453790,64 +0.455211,64 +0.456634,64 +0.458068,64 +0.459503,64 +0.461062,64 +0.462489,64 +0.463902,64 +0.465307,64 +0.466852,64 +0.468307,64 +0.469781,64 +0.471124,64 +0.472576,64 +0.474030,64 +0.475521,64 +0.476991,64 +0.478477,64 +0.479929,64 +0.481364,64 +0.482827,64 +0.484174,64 +0.485646,64 +0.487117,64 +0.488564,64 +0.490041,64 +0.491524,64 +0.492872,64 +0.494352,64 +0.495820,64 +0.497256,64 +0.498705,64 +0.500189,64 +0.501633,64 +0.503074,64 +0.504517,64 +0.505950,64 +0.507408,64 +0.508908,64 +0.510356,64 +0.511830,64 +0.513275,64 +0.514721,64 +0.516168,64 +0.517641,64 +0.519085,64 +0.520548,64 +0.522001,64 +0.523450,64 +0.524923,64 +0.526261,64 +0.527706,64 +0.529216,64 +0.530665,64 +0.532150,64 +0.533599,64 +0.535064,64 +0.536520,64 +0.537979,64 +0.539314,64 +0.540888,64 +0.542218,64 +0.543701,64 +0.545153,64 +0.546631,64 +0.548080,64 +0.549506,64 +0.550921,64 +0.552403,64 +0.553860,64 +0.555353,64 +0.556721,64 +0.558261,64 +0.559678,64 +0.561151,64 +0.562654,64 +0.564037,64 +0.565527,64 +0.566900,64 +0.568388,64 +0.569932,64 +0.571255,64 +0.572703,64 +0.574257,64 +0.575692,64 +0.577121,64 +0.578547,64 +0.579972,64 +0.581398,64 +0.582953,64 +0.584380,64 +0.585841,64 +0.587263,64 +0.588684,64 +0.590125,64 +0.591554,64 +0.593104,64 +0.594519,64 +0.595931,64 +0.597467,64 +0.598910,64 +0.600356,64 +0.601767,64 +0.603177,64 +0.604724,64 +0.606143,64 +0.607614,64 +0.609061,64 +0.610521,64 +0.611974,64 +0.613425,64 +0.614898,64 +0.616230,64 +0.617687,64 +0.619245,64 +0.620713,64 +0.622151,64 +0.623611,64 +0.625059,64 +0.626499,64 +0.627951,64 +0.629389,64 +0.630836,64 +0.632193,64 +0.633772,64 +0.635209,64 +0.636645,64 +0.638091,64 +0.639525,64 +0.640990,64 +0.642438,64 +0.643904,64 +0.645346,64 +0.646814,64 +0.648254,64 +0.649696,64 +0.651163,64 +0.652605,64 +0.654071,64 +0.655428,64 +0.657033,64 +0.658336,64 +0.659775,64 +0.661331,64 +0.662684,64 +0.664150,64 +0.665635,64 +0.667073,64 +0.668535,64 +0.669980,64 +0.671424,64 +0.672889,64 +0.674375,64 +0.675844,64 +0.677176,64 +0.678668,64 +0.680156,64 +0.681967,64 +0.683035,64 +0.684465,64 +0.685923,64 +0.687385,64 +0.688833,64 +0.690328,64 +0.691778,64 +0.693228,64 +0.694693,64 +0.696163,64 +0.697620,64 +0.699068,64 +0.700397,64 +0.701955,64 +0.703384,64 +0.704807,64 +0.706216,64 +0.707773,64 +0.709186,64 +0.710602,64 +0.712060,64 +0.713547,64 +0.715031,64 +0.716474,64 +0.717899,64 +0.719327,64 +0.720793,64 +0.722220,64 +0.723634,64 +0.725097,64 +0.726542,64 +0.728003,64 +0.729458,64 +0.730919,64 +0.732376,64 +0.733831,64 +0.735264,64 +0.736717,64 +0.738193,64 +0.739635,64 +0.741103,64 +0.742578,64 +0.744019,64 +0.745464,64 +0.746918,64 +0.748383,64 +0.749824,64 +0.751265,64 +0.752677,64 +0.754141,64 +0.755579,64 +0.757013,64 +0.758469,64 +0.759935,64 +0.761402,64 +0.762886,64 +0.764332,64 +0.765803,64 +0.767235,64 +0.768690,64 +0.770140,64 +0.771587,64 +0.773047,64 +0.774481,64 +0.775954,64 +0.777420,64 +0.778871,64 +0.780334,64 +0.781795,64 +0.783236,64 +0.784589,64 +0.786031,64 +0.787503,64 +0.788950,64 +0.790396,64 +0.791829,64 +0.793284,64 +0.794741,64 +0.796207,64 +0.797670,64 +0.799149,64 +0.800622,64 +0.802078,64 +0.803532,64 +0.804980,64 +0.806425,64 +0.807879,64 +0.809339,64 +0.810785,64 +0.812249,64 +0.813687,64 +0.815146,64 +0.816591,64 +0.818047,64 +0.819510,64 +0.820864,64 +0.822344,64 +0.823805,64 +0.825283,64 +0.826751,64 +0.828205,64 +0.829672,64 +0.831033,64 +0.832503,64 +0.833950,64 +0.835405,64 +0.836883,64 +0.838339,64 +0.839843,64 +0.841190,64 +0.842694,64 +0.844136,64 +0.845608,64 +0.847067,64 +0.848519,64 +0.849959,64 +0.851437,64 +0.852868,64 +0.854290,64 +0.855706,64 +0.857139,64 +0.858696,64 +0.860156,64 +0.861552,64 +0.862970,64 +0.864487,64 +0.865863,64 +0.867397,64 +0.868785,64 +0.870205,64 +0.871667,64 +0.873110,64 +0.874586,64 +0.876053,64 +0.877512,64 +0.879002,64 +0.880479,64 +0.881822,64 +0.883289,64 +0.884788,64 +0.886224,64 +0.887615,64 +0.889120,64 +0.890629,64 +0.892000,64 +0.893472,64 +0.894916,64 +0.896467,64 +0.897831,64 +0.899313,64 +0.900713,64 +0.902226,64 +0.903700,64 +0.905158,64 +0.906485,64 +0.907962,64 +0.909421,64 +0.910869,64 +0.912345,64 +0.913807,64 +0.915262,64 +0.916678,64 +0.918102,64 +0.919648,64 +0.921113,64 +0.922545,64 +0.923893,64 +0.925458,64 +0.926809,64 +0.928278,64 +0.929736,64 +0.931186,64 +0.932640,64 +0.934076,64 +0.935520,64 +0.936977,64 +0.938410,64 +0.939879,64 +0.941319,64 +0.942770,64 +0.944220,64 +0.945669,64 +0.947141,64 +0.948595,64 +0.950051,64 +0.951516,64 +0.952929,64 +0.954476,64 +0.955925,64 +0.957348,64 +0.958763,64 +0.960276,64 +0.961686,64 +0.963135,64 +0.964546,64 +0.966079,64 +0.967495,64 +0.968955,64 +0.970395,64 +0.971850,64 +0.973341,64 +0.974806,64 +0.976241,64 +0.977678,64 +0.979126,64 +0.980576,64 +0.982025,64 +0.983485,64 +0.984935,64 +0.986404,64 +0.987770,64 +0.989225,64 +0.990684,64 +0.992146,64 +0.993605,64 +0.995058,64 +0.996494,64 +0.997948,64 +0.999427,64 +1.000897,64 +1.002340,64 +1.003812,64 +1.005183,64 +1.006663,64 +1.008120,64 +1.009588,64 +1.011077,64 +1.012528,64 +1.013970,64 +1.015443,64 +1.016885,64 +1.018337,64 +1.019667,64 +1.021120,64 +1.022695,64 +1.024146,64 +1.025507,64 +1.026946,64 +1.028400,64 +1.029885,64 +1.031322,64 +1.032786,64 +1.034234,64 +1.035671,64 +1.037152,64 +1.038589,64 +1.040035,64 +1.041491,64 +1.042944,64 +1.044405,64 +1.045853,64 +1.047283,64 +1.048742,64 +1.050234,64 +1.051706,64 +1.053071,64 +1.054524,64 +1.055958,64 +1.057502,64 +1.058910,64 +1.060325,64 +1.061866,64 +1.063282,64 +1.064704,64 +1.066134,64 +1.067571,64 +1.069028,64 +1.070475,64 +1.071915,64 +1.073403,64 +1.074866,64 +1.076309,64 +1.077774,64 +1.079227,64 +1.080673,64 +1.082144,64 +1.083592,64 +1.085033,64 +1.086469,64 +1.087929,64 +1.089370,64 +1.090823,64 +1.092289,64 +1.093732,64 +1.095157,64 +1.096614,64 +1.098077,64 +1.099526,64 +1.100985,64 +1.102416,64 +1.103954,64 +1.105395,64 +1.106849,64 +1.108200,64 +1.109656,64 +1.111109,64 +1.112587,64 +1.114052,64 +1.115559,64 +1.117028,64 +1.118363,64 +1.119923,64 +1.121381,64 +1.122815,64 +1.124264,64 +1.125700,64 +1.127136,64 +1.128601,64 +1.130044,64 +1.131495,64 +1.132939,64 +1.134395,64 +1.135853,64 +1.137290,64 +1.138726,64 +1.140179,64 +1.141631,64 +1.143097,64 +1.144564,64 +1.146028,64 +1.147488,64 +1.148942,64 +1.150275,64 +1.151843,64 +1.153282,64 +1.154725,64 +1.156160,64 +1.157619,64 +1.159060,64 +1.160498,64 +1.161946,64 +1.163414,64 +1.164859,64 +1.166288,64 +1.167740,64 +1.169200,64 +1.170678,64 +1.172123,64 +1.173633,64 +1.175063,64 +1.176501,64 +1.177936,64 +1.179423,64 +1.180875,64 +1.182337,64 +1.183788,64 +1.185227,64 +1.186693,64 +1.188130,64 +1.189464,64 +1.190914,64 +1.192471,64 +1.193906,64 +1.195335,64 +1.196770,64 +1.198232,64 +1.199672,64 +1.201134,64 +1.202571,64 +1.204054,64 +1.205512,64 +1.206985,64 +1.208442,64 +1.209889,64 +1.211311,64 +1.212758,64 +1.214206,64 +1.215683,64 +1.217135,64 +1.218601,64 +1.220059,64 +1.221411,64 +1.222860,64 +1.224330,64 +1.225784,64 +1.227245,64 +1.228709,64 +1.230133,64 +1.231588,64 +1.233039,64 +1.234481,64 +1.235931,64 +1.237376,64 +1.238817,64 +1.240274,64 +1.241832,64 +1.243285,64 +1.244718,64 +1.246142,64 +1.247609,64 +1.249067,64 +1.250517,64 +1.251977,64 +1.253416,64 +1.254872,64 +1.256312,64 +1.257760,64 +1.259193,64 +1.260628,64 +1.262051,64 +1.263527,64 +1.264999,64 +1.266444,64 +1.267913,64 +1.269346,64 +1.270788,64 +1.272251,64 +1.273700,64 +1.275140,64 +1.276579,64 +1.278010,64 +1.279457,64 +1.280905,64 +1.282345,64 +1.283806,64 +1.285261,64 +1.286718,64 +1.288174,64 +1.289622,64 +1.291082,64 +1.292534,64 +1.293986,64 +1.295435,64 +1.296890,64 +1.298342,64 +1.299776,64 +1.301327,64 +1.302783,64 +1.304126,64 +1.305560,64 +1.307156,64 +1.308587,64 +1.310026,64 +1.311368,64 +1.312876,64 +1.314343,64 +1.315803,64 +1.317257,64 +1.318718,64 +1.320179,64 +1.321630,64 +1.323123,64 +1.324452,64 +1.325924,64 +1.327372,64 +1.328807,64 +1.330274,64 +1.331726,64 +1.333176,64 +1.334617,64 +1.336068,64 +1.337506,64 +1.338962,64 +1.340407,64 +1.341882,64 +1.343349,64 +1.344792,64 +1.346282,64 +1.347725,64 +1.349176,64 +1.350630,64 +1.352071,64 +1.353502,64 +1.354955,64 +1.356407,64 +1.357888,64 +1.359315,64 +1.360748,64 +1.362222,64 +1.363685,64 +1.365142,64 +1.366567,64 +1.368006,64 +1.369459,64 +1.370933,64 +1.372376,64 +1.373826,64 +1.375321,64 +1.376792,64 +1.378123,64 +1.379617,64 +1.381069,64 +1.382515,64 +1.383952,64 +1.385382,64 +1.386849,64 +1.388291,64 +1.389746,64 +1.391200,64 +1.392654,64 +1.394113,64 +1.395551,64 +1.397011,64 +1.398451,64 +1.399905,64 +1.401358,64 +1.402829,64 +1.404264,64 +1.405704,64 +1.407159,64 +1.408620,64 +1.410075,64 +1.411534,64 +1.412972,64 +1.414408,64 +1.415866,64 +1.417317,64 +1.418879,64 +1.420335,64 +1.421774,64 +1.423236,64 +1.424586,64 +1.426038,64 +1.427506,64 +1.428962,64 +1.430415,64 +1.431849,64 +1.433318,64 +1.434761,64 +1.436217,64 +1.437667,64 +1.439114,64 +1.440587,64 +1.442041,64 +1.443502,64 +1.444963,64 +1.446402,64 +1.447906,64 +1.449362,64 +1.450684,64 +1.452247,64 +1.453661,64 +1.455078,64 +1.456529,64 +1.457982,64 +1.459452,64 +1.460874,64 +1.462340,64 +1.463811,64 +1.465243,64 +1.466682,64 +1.468143,64 +1.469590,64 +1.471072,64 +1.472525,64 +1.473973,64 +1.475424,64 +1.476861,64 +1.478297,64 +1.479762,64 +1.481228,64 +1.482657,64 +1.484134,64 +1.485580,64 +1.487026,64 +1.488478,64 +1.489924,64 +1.491399,64 +1.492834,64 +1.494283,64 +1.495723,64 +1.497153,64 +1.498605,64 +1.500054,64 +1.501504,64 +1.502957,64 +1.504443,64 +1.505901,64 +1.507331,64 +1.508751,64 +1.510297,64 +1.511638,64 +1.513207,64 +1.514641,64 +1.516116,64 +1.517564,64 +1.519020,64 +1.520351,64 +1.521921,64 +1.523369,64 +1.524792,64 +1.526232,64 +1.527660,64 +1.529118,64 +1.530584,64 +1.532061,64 +1.533511,64 +1.534952,64 +1.536404,64 +1.537858,64 +1.539309,64 +1.540779,64 +1.542226,64 +1.543682,64 +1.545119,64 +1.546563,64 +1.548022,64 +1.549486,64 +1.550831,64 +1.552301,64 +1.553754,64 +1.555209,64 +1.556653,64 +1.558095,64 +1.559640,64 +1.560989,64 +1.562549,64 +1.563880,64 +1.565462,64 +1.566899,64 +1.568360,64 +1.569718,64 +1.571184,64 +1.572636,64 +1.574082,64 +1.575562,64 +1.577011,64 +1.578441,64 +1.579903,64 +1.581351,64 +1.582803,64 +1.584261,64 +1.585714,64 +1.587156,64 +1.588600,64 +1.590067,64 +1.591540,64 +1.593023,64 +1.594473,64 +1.595917,64 +1.597263,64 +1.598737,64 +1.600199,64 +1.601649,64 +1.603111,64 +1.604576,64 +1.606029,64 +1.607500,64 +1.608962,64 +1.610438,64 +1.611876,64 +1.613327,64 +1.614763,64 +1.616238,64 +1.617675,64 +1.619118,64 +1.620494,64 +1.621971,64 +1.623431,64 +1.624888,64 +1.626336,64 +1.627777,64 +1.629249,64 +1.630692,64 +1.632155,64 +1.633621,64 +1.635068,64 +1.636514,64 +1.637955,64 +1.639392,64 +1.640822,64 +1.642253,64 +1.643819,64 +1.645276,64 +1.646612,64 +1.648049,64 +1.649540,64 +1.650992,64 +1.652440,64 +1.653895,64 +1.655346,64 +1.656791,64 +1.658230,64 +1.659687,64 +1.661120,64 +1.662618,64 +1.664060,64 +1.665560,64 +1.667008,64 +1.668470,64 +1.669919,64 +1.671355,64 +1.672805,64 +1.674179,64 +1.675631,64 +1.677081,64 +1.678526,64 +1.680098,64 +1.681563,64 +1.682910,64 +1.684373,64 +1.685851,64 +1.687323,64 +1.688790,64 +1.690235,64 +1.691667,64 +1.693110,64 +1.694559,64 +1.696003,64 +1.697469,64 +1.698930,64 +1.700355,64 +1.701769,64 +1.703315,64 +1.704719,64 +1.706142,64 +1.707556,64 +1.709093,64 +1.710507,64 +1.711946,64 +1.713390,64 +1.714828,64 +1.716307,64 +1.717776,64 +1.719264,64 +1.720643,64 +1.722086,64 +1.723524,64 +1.725083,64 +1.726543,64 +1.728001,64 +1.729337,64 +1.730778,64 +1.732235,64 +1.733808,64 +1.735254,64 +1.736697,64 +1.738128,64 +1.739580,64 +1.741017,64 +1.742453,64 +1.743908,64 +1.745360,64 +1.746782,64 +1.748213,64 +1.749674,64 +1.751130,64 +1.752591,64 +1.754049,64 +1.755490,64 +1.756928,64 +1.758379,64 +1.759814,64 +1.761368,64 +1.762815,64 +1.764275,64 +1.765726,64 +1.767157,64 +1.768606,64 +1.769961,64 +1.771454,64 +1.772927,64 +1.774380,64 +1.775822,64 +1.777266,64 +1.778723,64 +1.780177,64 +1.781635,64 +1.783124,64 +1.784572,64 +1.786049,64 +1.787409,64 +1.788866,64 +1.790305,64 +1.791748,64 +1.793212,64 +1.794664,64 +1.796143,64 +1.797601,64 +1.799059,64 +1.800488,64 +1.801952,64 +1.803394,64 +1.804882,64 +1.806316,64 +1.807768,64 +1.809203,64 +1.810669,64 +1.812136,64 +1.813585,64 +1.815030,64 +1.816485,64 +1.817918,64 +1.819383,64 +1.820830,64 +1.822286,64 +1.823739,64 +1.825182,64 +1.826629,64 +1.828078,64 +1.829508,64 +1.830923,64 +1.832482,64 +1.833888,64 +1.835299,64 +1.836714,64 +1.838253,64 +1.839657,64 +1.841079,64 +1.842630,64 +1.844056,64 +1.845471,64 +1.846917,64 +1.848353,64 +1.849793,64 +1.851266,64 +1.852710,64 +1.854162,64 +1.855627,64 +1.857113,64 +1.858548,64 +1.859997,64 +1.861440,64 +1.862886,64 +1.864333,64 +1.865774,64 +1.867212,64 +1.868668,64 +1.870135,64 +1.871612,64 +1.873076,64 +1.874498,64 +1.875936,64 +1.877385,64 +1.878827,64 +1.880294,64 +1.881807,64 +1.883263,64 +1.884730,64 +1.886071,64 +1.887572,64 +1.889022,64 +1.890479,64 +1.891942,64 +1.893390,64 +1.894870,64 +1.896344,64 +1.897788,64 +1.899234,64 +1.900670,64 +1.902126,64 +1.903588,64 +1.905023,64 +1.906447,64 +1.907901,64 +1.909351,64 +1.910801,64 +1.912236,64 +1.913757,64 +1.915104,64 +1.916587,64 +1.918091,64 +1.919565,64 +1.920929,64 +1.922379,64 +1.923874,64 +1.925357,64 +1.926822,64 +1.928254,64 +1.929714,64 +1.931091,64 +1.932553,64 +1.934008,64 +1.935454,64 +1.936932,64 +1.938400,64 +1.939860,64 +1.941297,64 +1.942735,64 +1.944177,64 +1.945664,64 +1.947024,64 +1.948468,64 +1.950024,64 +1.951465,64 +1.952893,64 +1.954330,64 +1.955765,64 +1.957199,64 +1.958624,64 +1.960184,64 +1.961618,64 +1.963045,64 +1.964459,64 +1.965899,64 +1.967445,64 +1.968872,64 +1.970305,64 +1.971721,64 +1.973144,64 +1.974710,64 +1.976129,64 +1.977548,64 +1.978977,64 +1.980385,64 +1.981929,64 +1.983357,64 +1.984791,64 +1.986229,64 +1.987685,64 +1.989151,64 +1.990592,64 +1.992053,64 +1.993521,64 +1.994967,64 +1.996418,64 +1.997851,64 +1.999299,64 +2.000776,64 +2.002234,64 +2.003693,64 +2.005152,64 +2.006609,64 +2.008066,64 +2.009510,64 +2.010968,64 +2.012433,64 +2.013785,64 +2.015331,64 +2.016765,64 +2.018225,64 +2.019690,64 +2.021139,64 +2.022598,64 +2.023957,64 +2.025424,64 +2.026875,64 +2.028332,64 +2.029782,64 +2.031241,64 +2.032689,64 +2.034130,64 +2.035563,64 +2.037032,64 +2.038484,64 +2.039922,64 +2.041385,64 +2.042832,64 +2.044288,64 +2.045738,64 +2.047213,64 +2.048672,64 +2.050141,64 +2.051613,64 +2.052986,64 +2.054466,64 +2.055924,64 +2.057393,64 +2.058860,64 +2.060300,64 +2.061736,64 +2.063160,64 +2.064578,64 +2.066115,64 +2.067525,64 +2.068947,64 +2.070403,64 +2.071860,64 +2.073325,64 +2.074794,64 +2.076235,64 +2.077677,64 +2.079104,64 +2.080530,64 +2.082080,64 +2.083500,64 +2.084919,64 +2.086354,64 +2.087797,64 +2.089238,64 +2.090720,64 +2.092203,64 +2.093665,64 +2.095099,64 +2.096555,64 +2.098035,64 +2.099495,64 +2.100960,64 +2.102306,64 +2.103770,64 +2.105223,64 +2.106643,64 +2.108178,64 +2.109602,64 +2.111040,64 +2.112444,64 +2.113975,64 +2.115384,64 +2.116919,64 +2.118325,64 +2.119736,64 +2.121184,64 +2.122647,64 +2.124099,64 +2.125558,64 +2.127020,64 +2.128470,64 +2.129952,64 +2.131397,64 +2.132836,64 +2.134272,64 +2.135714,64 +2.137184,64 +2.138644,64 +2.140084,64 +2.141556,64 +2.143015,64 +2.144478,64 +2.145841,64 +2.147273,64 +2.148841,64 +2.150192,64 +2.151648,64 +2.153118,64 +2.154558,64 +2.156022,64 +2.157484,64 +2.158959,64 +2.160408,64 +2.161863,64 +2.163320,64 +2.164779,64 +2.166216,64 +2.167658,64 +2.169084,64 +2.170562,64 +2.172019,64 +2.173470,64 +2.174919,64 +2.176378,64 +2.177803,64 +2.179269,64 +2.180721,64 +2.182165,64 +2.183611,64 +2.185075,64 +2.186513,64 +2.187962,64 +2.189414,64 +2.190888,64 +2.192327,64 +2.193793,64 +2.195234,64 +2.196699,64 +2.198171,64 +2.199608,64 +2.201027,64 +2.202441,64 +2.203996,64 +2.205414,64 +2.206828,64 +2.208255,64 +2.209789,64 +2.211197,64 +2.212627,64 +2.214056,64 +2.215515,64 +2.216946,64 +2.218384,64 +2.219960,64 +2.221386,64 +2.222805,64 +2.224232,64 +2.225655,64 +2.227091,64 +2.228633,64 +2.230082,64 +2.231455,64 +2.232900,64 +2.234372,64 +2.235825,64 +2.237261,64 +2.238822,64 +2.240160,64 +2.241607,64 +2.243178,64 +2.244525,64 +2.245986,64 +2.247428,64 +2.248881,64 +2.250343,64 +2.251795,64 +2.253240,64 +2.254694,64 +2.256162,64 +2.257633,64 +2.259111,64 +2.260606,64 +2.261999,64 +2.263470,64 +2.264944,64 +2.266392,64 +2.267834,64 +2.269304,64 +2.270631,64 +2.272193,64 +2.273618,64 +2.275044,64 +2.276477,64 +2.277939,64 +2.279385,64 +2.280809,64 +2.282364,64 +2.283791,64 +2.285206,64 +2.286632,64 +2.288157,64 +2.289581,64 +2.290993,64 +2.292421,64 +2.293889,64 +2.295379,64 +2.296845,64 +2.298326,64 +2.299667,64 +2.301165,64 +2.302625,64 +2.304059,64 +2.305510,64 +2.306974,64 +2.308445,64 +2.309891,64 +2.311339,64 +2.312776,64 +2.314289,64 +2.315746,64 +2.317190,64 +2.318638,64 +2.320092,64 +2.321510,64 +2.322965,64 +2.324407,64 +2.325871,64 +2.327364,64 +2.328695,64 +2.330181,64 +2.331610,64 +2.333067,64 +2.334547,64 +2.336013,64 +2.337463,64 +2.338943,64 +2.340399,64 +2.341749,64 +2.343199,64 +2.344673,64 +2.346138,64 +2.347612,64 +2.349079,64 +2.350534,64 +2.352024,64 +2.353369,64 +2.354815,64 +2.356263,64 +2.357715,64 +2.359170,64 +2.360625,64 +2.362135,64 +2.363578,64 +2.365032,64 +2.366473,64 +2.367918,64 +2.369363,64 +2.370832,64 +2.372273,64 +2.373710,64 +2.375156,64 +2.376622,64 +2.378092,64 +2.379547,64 +2.380981,64 +2.382453,64 +2.383918,64 +2.385374,64 +2.386751,64 +2.388200,64 +2.389653,64 +2.391092,64 +2.392549,64 +2.394014,64 +2.395474,64 +2.396933,64 +2.398374,64 +2.399840,64 +2.401297,64 +2.402757,64 +2.404207,64 +2.405633,64 +2.407090,64 +2.408541,64 +2.410003,64 +2.411450,64 +2.412909,64 +2.414378,64 +2.415897,64 +2.417207,64 +2.418782,64 +2.420245,64 +2.421600,64 +2.423041,64 +2.424484,64 +2.425931,64 +2.427380,64 +2.428839,64 +2.430276,64 +2.431842,64 +2.433296,64 +2.434739,64 +2.436185,64 +2.437654,64 +2.439109,64 +2.440441,64 +2.441888,64 +2.443345,64 +2.444911,64 +2.446368,64 +2.447707,64 +2.449197,64 +2.450650,64 +2.452082,64 +2.453532,64 +2.454994,64 +2.456467,64 +2.457913,64 +2.459391,64 +2.460819,64 +2.462236,64 +2.463683,64 +2.465135,64 +2.466568,64 +2.468002,64 +2.469544,64 +2.470955,64 +2.472379,64 +2.473921,64 +2.475325,64 +2.476769,64 +2.478229,64 +2.479675,64 +2.481117,64 +2.482564,64 +2.484000,64 +2.485457,64 +2.486953,64 +2.488395,64 +2.489860,64 +2.491297,64 +2.492737,64 +2.494202,64 +2.495659,64 +2.497123,64 +2.498562,64 +2.500000,64 +2.501451,64 +2.502880,64 +2.504348,64 +2.505811,64 +2.507275,64 +2.508715,64 +2.510158,64 +2.511609,64 +2.513059,64 +2.514507,64 +2.515981,64 +2.517430,64 +2.518896,64 +2.520355,64 +2.521783,64 +2.523235,64 +2.524704,64 +2.526137,64 +2.527603,64 +2.529049,64 +2.530495,64 +2.531941,64 +2.533387,64 +2.534827,64 +2.536281,64 +2.537720,64 +2.539168,64 +2.540624,64 +2.542090,64 +2.543547,64 +2.545001,64 +2.546457,64 +2.547880,64 +2.549338,64 +2.550800,64 +2.552252,64 +2.553715,64 +2.555168,64 +2.556604,64 +2.558028,64 +2.559495,64 +2.560949,64 +2.562399,64 +2.563842,64 +2.565278,64 +2.566747,64 +2.568189,64 +2.569654,64 +2.571113,64 +2.572552,64 +2.574012,64 +2.575493,64 +2.576965,64 +2.578394,64 +2.579853,64 +2.581194,64 +2.582654,64 +2.584212,64 +2.585654,64 +2.587111,64 +2.588563,64 +2.590034,64 +2.591376,64 +2.592839,64 +2.594281,64 +2.595737,64 +2.597190,64 +2.598667,64 +2.600145,64 +2.601616,64 +2.603066,64 +2.604535,64 +2.605982,64 +2.607432,64 +2.608880,64 +2.610326,64 +2.611789,64 +2.613228,64 +2.614686,64 +2.616122,64 +2.617574,64 +2.619019,64 +2.620508,64 +2.621846,64 +2.623306,64 +2.624753,64 +2.626195,64 +2.627641,64 +2.629210,64 +2.630649,64 +2.632087,64 +2.633534,64 +2.634981,64 +2.636425,64 +2.637794,64 +2.639350,64 +2.640810,64 +2.642254,64 +2.643685,64 +2.645136,64 +2.646591,64 +2.648021,64 +2.649451,64 +2.650900,64 +2.652346,64 +2.653789,64 +2.655219,64 +2.656659,64 +2.658226,64 +2.659668,64 +2.661117,64 +2.662562,64 +2.664006,64 +2.665473,64 +2.666902,64 +2.668335,64 +2.669765,64 +2.671288,64 +2.672738,64 +2.674091,64 +2.675545,64 +2.677036,64 +2.678511,64 +2.679959,64 +2.681390,64 +2.682883,64 +2.684327,64 +2.685751,64 +2.687228,64 +2.688681,64 +2.690143,64 +2.691493,64 +2.693057,64 +2.694519,64 +2.695866,64 +2.697312,64 +2.698750,64 +2.700221,64 +2.701675,64 +2.703130,64 +2.704605,64 +2.706042,64 +2.707507,64 +2.708974,64 +2.710403,64 +2.711859,64 +2.713299,64 +2.714749,64 +2.716212,64 +2.717662,64 +2.719108,64 +2.720593,64 +2.721969,64 +2.723432,64 +2.724892,64 +2.726354,64 +2.727809,64 +2.729245,64 +2.730700,64 +2.732190,64 +2.733630,64 +2.735076,64 +2.736518,64 +2.737958,64 +2.739424,64 +2.740890,64 +2.742336,64 +2.743784,64 +2.745212,64 +2.746660,64 +2.748095,64 +2.749536,64 +2.751108,64 +2.752567,64 +2.753913,64 +2.755370,64 +2.756816,64 +2.758281,64 +2.759740,64 +2.761212,64 +2.762702,64 +2.764157,64 +2.765620,64 +2.767069,64 +2.768533,64 +2.769859,64 +2.771318,64 +2.772787,64 +2.774239,64 +2.775684,64 +2.777141,64 +2.778592,64 +2.780060,64 +2.781532,64 +2.782977,64 +2.784415,64 +2.785887,64 +2.787335,64 +2.788791,64 +2.790223,64 +2.791664,64 +2.793101,64 +2.794543,64 +2.796016,64 +2.797499,64 +2.798978,64 +2.800336,64 +2.801778,64 +2.803368,64 +2.804806,64 +2.806266,64 +2.807727,64 +2.809169,64 +2.810514,64 +2.811981,64 +2.813446,64 +2.814911,64 +2.816329,64 +2.817759,64 +2.819326,64 +2.820684,64 +2.822150,64 +2.823592,64 +2.825041,64 +2.826485,64 +2.827935,64 +2.829394,64 +2.830845,64 +2.832296,64 +2.833731,64 +2.835179,64 +2.836628,64 +2.838091,64 +2.839525,64 +2.840969,64 +2.842421,64 +2.843884,64 +2.845329,64 +2.846901,64 +2.848340,64 +2.849790,64 +2.851232,64 +2.852682,64 +2.854129,64 +2.855561,64 +2.857027,64 +2.858472,64 +2.859894,64 +2.861333,64 +2.862786,64 +2.864225,64 +2.865692,64 +2.867153,64 +2.868621,64 +2.870083,64 +2.871541,64 +2.872982,64 +2.874427,64 +2.875874,64 +2.877317,64 +2.878761,64 +2.880213,64 +2.881688,64 +2.883115,64 +2.884559,64 +2.886060,64 +2.887416,64 +2.888874,64 +2.890332,64 +2.891791,64 +2.893244,64 +2.894698,64 +2.896143,64 +2.897626,64 +2.899113,64 +2.900557,64 +2.901995,64 +2.903437,64 +2.904878,64 +2.906355,64 +2.907797,64 +2.909224,64 +2.910674,64 +2.912153,64 +2.913608,64 +2.915056,64 +2.916503,64 +2.917941,64 +2.919397,64 +2.920858,64 +2.922288,64 +2.923752,64 +2.925223,64 +2.926682,64 +2.928125,64 +2.929576,64 +2.931045,64 +2.932508,64 +2.933942,64 +2.935417,64 +2.936865,64 +2.938214,64 +2.939675,64 +2.941141,64 +2.942602,64 +2.944051,64 +2.945500,64 +2.946978,64 +2.948422,64 +2.949866,64 +2.951312,64 +2.952795,64 +2.954255,64 +2.955702,64 +2.957158,64 +2.958595,64 +2.960050,64 +2.961479,64 +2.962933,64 +2.964399,64 +2.965842,64 +2.967296,64 +2.968753,64 +2.970201,64 +2.971654,64 +2.973138,64 +2.974610,64 +2.975968,64 +2.977439,64 +2.978890,64 +2.980336,64 +2.981783,64 +2.983224,64 +2.984670,64 +2.986115,64 +2.987596,64 +2.989055,64 +2.990485,64 +2.991941,64 +2.993410,64 +2.994864,64 +2.996310,64 +2.997743,64 +2.999203,64 +3.000656,64 +3.002133,64 +3.003609,64 +3.005074,64 +3.006427,64 +3.007895,64 +3.009412,64 +3.010848,64 +3.012358,64 +3.013696,64 +3.015173,64 +3.016622,64 +3.018083,64 +3.019525,64 +3.020992,64 +3.022456,64 +3.023905,64 +3.025363,64 +3.026831,64 +3.028280,64 +3.029740,64 +3.031182,64 +3.032542,64 +3.034002,64 +3.035462,64 +3.036906,64 +3.038362,64 +3.039837,64 +3.041287,64 +3.042730,64 +3.044179,64 +3.045638,64 +3.047089,64 +3.048557,64 +3.050058,64 +3.051443,64 +3.052925,64 +3.054418,64 +3.055763,64 +3.057210,64 +3.058680,64 +3.060164,64 +3.061608,64 +3.063091,64 +3.064530,64 +3.065963,64 +3.067399,64 +3.068839,64 +3.070272,64 +3.071728,64 +3.073278,64 +3.074743,64 +3.076183,64 +3.077636,64 +3.079037,64 +3.080497,64 +3.081941,64 +3.083408,64 +3.084897,64 +3.086244,64 +3.087696,64 +3.089136,64 +3.090599,64 +3.092144,64 +3.093595,64 +3.095037,64 +3.096484,64 +3.097918,64 +3.099376,64 +3.100818,64 +3.102256,64 +3.103692,64 +3.105156,64 +3.106638,64 +3.108082,64 +3.109581,64 +3.110898,64 +3.112385,64 +3.113835,64 +3.115290,64 +3.116749,64 +3.118225,64 +3.119697,64 +3.121177,64 +3.122508,64 +3.123995,64 +3.125453,64 +3.126896,64 +3.128372,64 +3.129860,64 +3.131302,64 +3.132785,64 +3.134147,64 +3.135594,64 +3.137060,64 +3.138513,64 +3.139983,64 +3.141431,64 +3.142876,64 +3.144325,64 +3.145790,64 +3.147255,64 +3.148695,64 +3.150147,64 +3.151604,64 +3.153112,64 +3.154466,64 +3.155928,64 +3.157396,64 +3.158831,64 +3.160258,64 +3.161695,64 +3.163174,64 +3.164630,64 +3.166095,64 +3.167595,64 +3.169063,64 +3.170400,64 +3.171885,64 +3.173339,64 +3.174793,64 +3.176241,64 +3.177672,64 +3.179125,64 +3.180586,64 +3.182047,64 +3.183496,64 +3.184951,64 +3.186371,64 +3.187933,64 +3.189381,64 +3.190710,64 +3.192181,64 +3.193625,64 +3.195194,64 +3.196654,64 +3.198096,64 +3.199435,64 +3.200906,64 +3.202378,64 +3.203825,64 +3.205261,64 +3.206730,64 +3.208164,64 +3.209625,64 +3.211091,64 +3.212534,64 +3.213996,64 +3.215433,64 +3.216890,64 +3.218355,64 +3.219762,64 +3.221247,64 +3.222702,64 +3.224167,64 +3.225653,64 +3.227103,64 +3.228555,64 +3.230000,64 +3.231459,64 +3.232900,64 +3.234337,64 +3.235774,64 +3.237220,64 +3.238701,64 +3.240141,64 +3.241601,64 +3.243035,64 +3.244529,64 +3.245975,64 +3.247433,64 +3.248782,64 +3.250243,64 +3.251711,64 +3.253172,64 +3.254636,64 +3.256105,64 +3.257564,64 +3.259031,64 +3.260483,64 +3.261921,64 +3.263374,64 +3.264815,64 +3.266275,64 +3.267724,64 +3.269181,64 +3.270651,64 +3.271996,64 +3.273462,64 +3.274931,64 +3.276390,64 +3.277837,64 +3.279303,64 +3.280745,64 +3.282183,64 +3.283663,64 +3.285108,64 +3.286569,64 +3.288037,64 +3.289498,64 +3.290990,64 +3.292297,64 +3.293875,64 +3.295205,64 +3.296663,64 +3.298153,64 +3.299611,64 +3.301065,64 +3.302530,64 +3.304039,64 +3.305474,64 +3.306930,64 +3.308381,64 +3.309830,64 +3.311273,64 +3.312724,64 +3.314087,64 +3.315553,64 +3.317009,64 +3.318448,64 +3.319917,64 +3.321365,64 +3.322819,64 +3.324284,64 +3.325736,64 +3.327181,64 +3.328668,64 +3.330129,64 +3.331569,64 +3.333015,64 +3.334457,64 +3.335909,64 +3.337366,64 +3.338825,64 +3.340291,64 +3.341662,64 +3.343117,64 +3.344570,64 +3.346016,64 +3.347475,64 +3.348943,64 +3.350420,64 +3.351861,64 +3.353306,64 +3.354751,64 +3.356224,64 +3.357692,64 +3.359156,64 +3.360630,64 +3.361960,64 +3.363550,64 +3.364986,64 +3.366428,64 +3.367887,64 +3.369320,64 +3.370780,64 +3.372142,64 +3.373602,64 +3.375039,64 +3.376537,64 +3.378008,64 +3.379481,64 +3.380933,64 +3.382286,64 +3.383738,64 +3.385215,64 +3.386701,64 +3.388167,64 +3.389621,64 +3.391088,64 +3.392532,64 +3.393973,64 +3.395444,64 +3.396888,64 +3.398333,64 +3.399758,64 +3.401206,64 +3.402661,64 +3.404106,64 +3.405574,64 +3.407047,64 +3.408483,64 +3.409913,64 +3.411347,64 +3.412827,64 +3.414261,64 +3.415720,64 +3.417171,64 +3.418612,64 +3.420056,64 +3.421536,64 +3.423007,64 +3.424432,64 +3.425890,64 +3.427382,64 +3.428812,64 +3.430265,64 +3.431731,64 +3.433190,64 +3.434619,64 +3.436103,64 +3.437555,64 +3.438992,64 +3.440432,64 +3.441882,64 +3.443339,64 +3.444772,64 +3.446232,64 +3.447669,64 +3.449141,64 +3.450588,64 +3.452040,64 +3.453490,64 +3.454955,64 +3.456297,64 +3.457764,64 +3.459198,64 +3.460772,64 +3.462217,64 +3.463659,64 +3.465105,64 +3.466570,64 +3.468004,64 +3.469449,64 +3.470911,64 +3.472367,64 +3.473809,64 +3.475256,64 +3.476704,64 +3.478134,64 +3.479584,64 +3.481038,64 +3.482512,64 +3.483944,64 +3.485401,64 +3.486857,64 +3.488306,64 +3.489788,64 +3.491237,64 +3.492692,64 +3.494122,64 +3.495560,64 +3.497000,64 +3.498443,64 +3.499920,64 +3.501402,64 +3.502760,64 +3.504242,64 +3.505701,64 +3.507166,64 +3.508629,64 +3.510106,64 +3.511458,64 +3.512917,64 +3.514379,64 +3.515843,64 +3.517299,64 +3.518736,64 +3.520173,64 +3.521643,64 +3.523093,64 +3.524553,64 +3.526022,64 +3.527469,64 +3.528953,64 +3.530398,64 +3.531872,64 +3.533229,64 +3.534713,64 +3.536165,64 +3.537631,64 +3.539101,64 +3.540554,64 +3.541999,64 +3.543447,64 +3.544895,64 +3.546366,64 +3.547808,64 +3.549272,64 +3.550628,64 +3.552115,64 +3.553646,64 +3.555074,64 +3.556500,64 +3.557933,64 +3.559389,64 +3.560853,64 +3.562289,64 +3.563771,64 +3.565215,64 +3.566674,64 +3.568133,64 +3.569582,64 +3.571071,64 +3.572506,64 +3.573931,64 +3.575379,64 +3.576816,64 +3.578309,64 +3.579767,64 +3.581227,64 +3.582675,64 +3.584114,64 +3.585466,64 +3.587013,64 +3.588433,64 +3.589869,64 +3.591305,64 +3.592724,64 +3.594261,64 +3.595673,64 +3.597088,64 +3.598561,64 +3.599988,64 +3.601435,64 +3.602891,64 +3.604383,64 +3.605832,64 +3.607295,64 +3.608744,64 +3.610205,64 +3.611646,64 +3.613117,64 +3.614561,64 +3.616012,64 +3.617509,64 +3.618857,64 +3.620311,64 +3.621776,64 +3.623258,64 +3.624744,64 +3.626203,64 +3.627652,64 +3.629081,64 +3.630514,64 +3.631973,64 +3.633428,64 +3.634857,64 +3.636284,64 +3.637703,64 +3.639153,64 +3.640621,64 +3.642079,64 +3.643547,64 +3.644981,64 +3.646442,64 +3.647861,64 +3.649369,64 +3.650779,64 +3.652265,64 +3.653758,64 +3.655217,64 +3.656678,64 +3.658020,64 +3.659469,64 +3.660966,64 +3.662422,64 +3.663872,64 +3.665316,64 +3.666757,64 +3.668210,64 +3.669681,64 +3.671128,64 +3.672572,64 +3.674035,64 +3.675544,64 +3.676965,64 +3.678376,64 +3.679791,64 +3.681334,64 +3.682759,64 +3.684185,64 +3.685601,64 +3.687139,64 +3.688546,64 +3.689987,64 +3.691408,64 +3.692868,64 +3.694320,64 +3.695803,64 +3.697243,64 +3.698703,64 +3.700143,64 +3.701593,64 +3.703046,64 +3.704498,64 +3.705943,64 +3.707392,64 +3.708835,64 +3.710286,64 +3.711757,64 +3.713205,64 +3.714646,64 +3.716133,64 +3.717593,64 +3.719012,64 +3.720499,64 +3.721932,64 +3.723387,64 +3.724845,64 +3.726288,64 +3.727729,64 +3.729163,64 +3.730622,64 +3.732065,64 +3.733507,64 +3.734949,64 +3.736394,64 +3.737949,64 +3.739397,64 +3.740835,64 +3.742267,64 +3.743686,64 +3.745131,64 +3.746583,64 +3.748027,64 +3.749483,64 +3.750903,64 +3.752459,64 +3.753800,64 +3.755249,64 +3.756742,64 +3.758179,64 +3.759611,64 +3.761054,64 +3.762625,64 +3.764076,64 +3.765523,64 +3.766962,64 +3.768408,64 +3.769864,64 +3.771330,64 +3.772765,64 +3.774220,64 +3.775647,64 +3.777082,64 +3.778521,64 +3.779969,64 +3.781427,64 +3.782858,64 +3.784304,64 +3.785723,64 +3.787279,64 +3.788702,64 +3.790136,64 +3.791577,64 +3.793028,64 +3.794474,64 +3.795932,64 +3.797416,64 +3.798869,64 +3.800289,64 +3.801694,64 +3.803234,64 +3.804647,64 +3.806060,64 +3.807608,64 +3.809016,64 +3.810425,64 +3.811845,64 +3.813388,64 +3.814814,64 +3.816224,64 +3.817759,64 +3.819190,64 +3.820646,64 +3.822083,64 +3.823521,64 +3.824958,64 +3.826401,64 +3.827836,64 +3.829301,64 +3.830757,64 +3.832202,64 +3.833649,64 +3.835109,64 +3.836583,64 +3.838048,64 +3.839491,64 +3.840963,64 +3.842440,64 +3.843880,64 +3.845327,64 +3.846775,64 +3.848221,64 +3.849668,64 +3.851030,64 +3.852603,64 +3.854034,64 +3.855479,64 +3.856938,64 +3.858388,64 +3.859832,64 +3.861265,64 +3.862705,64 +3.864156,64 +3.865617,64 +3.867051,64 +3.868500,64 +3.869938,64 +3.871409,64 +3.872857,64 +3.874314,64 +3.875745,64 +3.877183,64 +3.878631,64 +3.880085,64 +3.881540,64 +3.882988,64 +3.884463,64 +3.885920,64 +3.887385,64 +3.888865,64 +3.890219,64 +3.891661,64 +3.893108,64 +3.894654,64 +3.896060,64 +3.897576,64 +3.898979,64 +3.900403,64 +3.901836,64 +3.903389,64 +3.904725,64 +3.906283,64 +3.907718,64 +3.909168,64 +3.910598,64 +3.912050,64 +3.913477,64 +3.914943,64 +3.916381,64 +3.917844,64 +3.919300,64 +3.920738,64 +3.922169,64 +3.923622,64 +3.925083,64 +3.926533,64 +3.927993,64 +3.929459,64 +3.930925,64 +3.932384,64 +3.933827,64 +3.935280,64 +3.936735,64 +3.938176,64 +3.939635,64 +3.941081,64 +3.942526,64 +3.943962,64 +3.945429,64 +3.946902,64 +3.948380,64 +3.949719,64 +3.951200,64 +3.952650,64 +3.954117,64 +3.955571,64 +3.957042,64 +3.958478,64 +3.959935,64 +3.961372,64 +3.962833,64 +3.964268,64 +3.965728,64 +3.967181,64 +3.968626,64 +3.970101,64 +3.971557,64 +3.973002,64 +3.974454,64 +3.975891,64 +3.977352,64 +3.978802,64 +3.980260,64 +3.981690,64 +3.983157,64 +3.984596,64 +3.986047,64 +3.987516,64 +3.988977,64 +3.990448,64 +3.991925,64 +3.993273,64 +3.994737,64 +3.996183,64 +3.997599,64 +3.999138,64 +4.000547,64 +4.001974,64 +4.003528,64 +4.004882,64 +4.006352,64 +4.007786,64 +4.009247,64 +4.010680,64 +4.012165,64 +4.013612,64 +4.015037,64 +4.016473,64 +4.017933,64 +4.019387,64 +4.020857,64 +4.022310,64 +4.023763,64 +4.025233,64 +4.026730,64 +4.028095,64 +4.029545,64 +4.031010,64 +4.032489,64 +4.033950,64 +4.035403,64 +4.036859,64 +4.038289,64 +4.039740,64 +4.041245,64 +4.042712,64 +4.044054,64 +4.045489,64 +4.047046,64 +4.048471,64 +4.049868,64 +4.051344,64 +4.052780,64 +4.054249,64 +4.055687,64 +4.057121,64 +4.058558,64 +4.060010,64 +4.061572,64 +4.062911,64 +4.064474,64 +4.065919,64 +4.067368,64 +4.068829,64 +4.070263,64 +4.071710,64 +4.073121,64 +4.074545,64 +4.076036,64 +4.077476,64 +4.078893,64 +4.080320,64 +4.081883,64 +4.083304,64 +4.084731,64 +4.086169,64 +4.087608,64 +4.089054,64 +4.090523,64 +4.091998,64 +4.093420,64 +4.094898,64 +4.096359,64 +4.097789,64 +4.099221,64 +4.100650,64 +4.102158,64 +4.103648,64 +4.105043,64 +4.106522,64 +4.107938,64 +4.109587,64 +4.110893,64 +4.112322,64 +4.113727,64 +4.115192,64 +4.116698,64 +4.118111,64 +4.119540,64 +4.120966,64 +4.122507,64 +4.123943,64 +4.125384,64 +4.126827,64 +4.128253,64 +4.129719,64 +4.131229,64 +4.132575,64 +4.134136,64 +4.135577,64 +4.137013,64 +4.138479,64 +4.139856,64 +4.141414,64 +4.142744,64 +4.144197,64 +4.145627,64 +4.147199,64 +4.148644,64 +4.150090,64 +4.151511,64 +4.152935,64 +4.154430,64 +4.155848,64 +4.157280,64 +4.158717,64 +4.160256,64 +4.161692,64 +4.163069,64 +4.164610,64 +4.166041,64 +4.167484,64 +4.168904,64 +4.170320,64 +4.171757,64 +4.173247,64 +4.174685,64 +4.176226,64 +4.177663,64 +4.179081,64 +4.180514,64 +4.181957,64 +4.183411,64 +4.184850,64 +4.186283,64 +4.187758,64 +4.189279,64 +4.190721,64 +4.192069,64 +4.193642,64 +4.195077,64 +4.196499,64 +4.197962,64 +4.199390,64 +4.200817,64 +4.202255,64 +4.203695,64 +4.205243,64 +4.206665,64 +4.208049,64 +4.209560,64 +4.210974,64 +4.212424,64 +4.213866,64 +4.215429,64 +4.216865,64 +4.218264,64 +4.219694,64 +4.221138,64 +4.222563,64 +4.224030,64 +4.225482,64 +4.226909,64 +4.228451,64 +4.229865,64 +4.231291,64 +4.232743,64 +4.234261,64 +4.235680,64 +4.237134,64 +4.238599,64 +4.240035,64 +4.241483,64 +4.242919,64 +4.244366,64 +4.245851,64 +4.247284,64 +4.248740,64 +4.250189,64 +4.251628,64 +4.253084,64 +4.254574,64 +4.256008,64 +4.257482,64 +4.258921,64 +4.260377,64 +4.261809,64 +4.263279,64 +4.264724,64 +4.266163,64 +4.267606,64 +4.269100,64 +4.270447,64 +4.271922,64 +4.273377,64 +4.274841,64 +4.276360,64 +4.277702,64 +4.279177,64 +4.280643,64 +4.282111,64 +4.283556,64 +4.285045,64 +4.286490,64 +4.287951,64 +4.289421,64 +4.290767,64 +4.292334,64 +4.293767,64 +4.295196,64 +4.296635,64 +4.298092,64 +4.299532,64 +4.300985,64 +4.302420,64 +4.303866,64 +4.305304,64 +4.306758,64 +4.308218,64 +4.309668,64 +4.311120,64 +4.312570,64 +4.314010,64 +4.315489,64 +4.316952,64 +4.318421,64 +4.319890,64 +4.321340,64 +4.322766,64 +4.324231,64 +4.325708,64 +4.327144,64 +4.328612,64 +4.329961,64 +4.331416,64 +4.332870,64 +4.334323,64 +4.335762,64 +4.337212,64 +4.338684,64 +4.340141,64 +4.341592,64 +4.343056,64 +4.344499,64 +4.345931,64 +4.347400,64 +4.348866,64 +4.350331,64 +4.351822,64 +4.353273,64 +4.354693,64 +4.356126,64 +4.357551,64 +4.359068,64 +4.360484,64 +4.361898,64 +4.363316,64 +4.364884,64 +4.366328,64 +4.367760,64 +4.369168,64 +4.370590,64 +4.372129,64 +4.373537,64 +4.374948,64 +4.376404,64 +4.377949,64 +4.379360,64 +4.380764,64 +4.382226,64 +4.383684,64 +4.385159,64 +4.386646,64 +4.388397,64 +4.389499,64 +4.390970,64 +4.392393,64 +4.393819,64 +4.395347,64 +4.396774,64 +4.398198,64 +4.399626,64 +4.401048,64 +4.402501,64 +4.403947,64 +4.405420,64 +4.406883,64 +4.408322,64 +4.409764,64 +4.411319,64 +4.412752,64 +4.414200,64 +4.415653,64 +4.417099,64 +4.418548,64 +4.420026,64 +4.421475,64 +4.422924,64 +4.424389,64 +4.425826,64 +4.427263,64 +4.428704,64 +4.430155,64 +4.431607,64 +4.433043,64 +4.434484,64 +4.435924,64 +4.437381,64 +4.438840,64 +4.440280,64 +4.441753,64 +4.443201,64 +4.444644,64 +4.446100,64 +4.447560,64 +4.449005,64 +4.450453,64 +4.451900,64 +4.453356,64 +4.454805,64 +4.456256,64 +4.457697,64 +4.459120,64 +4.460566,64 +4.462019,64 +4.463463,64 +4.464914,64 +4.466385,64 +4.467846,64 +4.469329,64 +4.470794,64 +4.472225,64 +4.473666,64 +4.475110,64 +4.476548,64 +4.477985,64 +4.479457,64 +4.480916,64 +4.482413,64 +4.483844,64 +4.485276,64 +4.486725,64 +4.488177,64 +4.489627,64 +4.491074,64 +4.492538,64 +4.493986,64 +4.495421,64 +4.496871,64 +4.498335,64 +4.499777,64 +4.501235,64 +4.502681,64 +4.504137,64 +4.505582,64 +4.507041,64 +4.508489,64 +4.509945,64 +4.511371,64 +4.512843,64 +4.514318,64 +4.515687,64 +4.517262,64 +4.518593,64 +4.520047,64 +4.521529,64 +4.522991,64 +4.524440,64 +4.525901,64 +4.527343,64 +4.528779,64 +4.530237,64 +4.531788,64 +4.533206,64 +4.534648,64 +4.536074,64 +4.537522,64 +4.538940,64 +4.540385,64 +4.541923,64 +4.543335,64 +4.544798,64 +4.546277,64 +4.547751,64 +4.549112,64 +4.550581,64 +4.552026,64 +4.553486,64 +4.554972,64 +4.556415,64 +4.557850,64 +4.559342,64 +4.560761,64 +4.562163,64 +4.563622,64 +4.565078,64 +4.566514,64 +4.567942,64 +4.569469,64 +4.570862,64 +4.572401,64 +4.573771,64 +4.575196,64 +4.576748,64 +4.578141,64 +4.579684,64 +4.581109,64 +4.582522,64 +4.583937,64 +4.585352,64 +4.586901,64 +4.588320,64 +4.589742,64 +4.591200,64 +4.592621,64 +4.594066,64 +4.595528,64 +4.597054,64 +4.598508,64 +4.599963,64 +4.601387,64 +4.602930,64 +4.604527,64 +4.606024,64 +4.607579,64 +4.608584,64 +4.610028,64 +4.612490,64 +4.614441,128 +4.615845,64 +4.617369,64 +4.618735,64 +4.621339,64 +4.622021,64 +4.623146,64 +4.624623,64 +4.626086,64 +4.627536,64 +4.629679,64 +4.630353,64 +4.631845,64 +4.633264,64 +4.634848,64 +4.636304,64 +4.637884,64 +4.639191,64 +4.640606,64 +4.641984,64 +4.643738,64 +4.644945,64 +4.646657,64 +4.647898,64 +4.649663,64 +4.650997,64 +4.654651,128 +4.655073,64 +4.656793,64 +4.658029,64 +4.659467,64 +4.660834,64 +4.662292,64 +4.663738,64 +4.665206,64 +4.666654,64 +4.668071,64 +4.669683,64 +4.671034,64 +4.672555,64 +4.673911,64 +4.675456,64 +4.676892,64 +4.678242,64 +4.679710,64 +4.681166,64 +4.682626,64 +4.684088,64 +4.685546,64 +4.686995,64 +4.688485,64 +4.689967,64 +4.691420,64 +4.692758,64 +4.694211,64 +4.695670,64 +4.697112,64 +4.698672,64 +4.700118,64 +4.701564,64 +4.703013,64 +4.704456,64 +4.705891,64 +4.707331,64 +4.708826,64 +4.710158,64 +4.711642,64 +4.713080,64 +4.716122,128 +4.717490,64 +4.718969,64 +4.720414,64 +4.721827,64 +4.723233,64 +4.724786,64 +4.726195,64 +4.727619,64 +4.729055,64 +4.730482,64 +4.731953,64 +4.733406,64 +4.734868,64 +4.736363,64 +4.737799,64 +4.739246,64 +4.740733,64 +4.742187,64 +4.743618,64 +4.745064,64 +4.746501,64 +4.747975,64 +4.749461,64 +4.750917,64 +4.752363,64 +4.753720,64 +4.755265,64 +4.756728,64 +4.758177,64 +4.759622,64 +4.761073,64 +4.762533,64 +4.763965,64 +4.765441,64 +4.766777,64 +4.768228,64 +4.769671,64 +4.771147,64 +4.772610,64 +4.774068,64 +4.775499,64 +4.776960,64 +4.778405,64 +4.779873,64 +4.781329,64 +4.782789,64 +4.784235,64 +4.785682,64 +4.787128,64 +4.788585,64 +4.790018,64 +4.791469,64 +4.792915,64 +4.794373,64 +4.795835,64 +4.797292,64 +4.798742,64 +4.800219,64 +4.801675,64 +4.803132,64 +4.804607,64 +4.805942,64 +4.807424,64 +4.808887,64 +4.810347,64 +4.811790,64 +4.813239,64 +4.814672,64 +4.816121,64 +4.817600,64 +4.819099,64 +4.820566,64 +4.822018,64 +4.823478,64 +4.824821,64 +4.826306,64 +4.827743,64 +4.829211,64 +4.830663,64 +4.832113,64 +4.833550,64 +4.834993,64 +4.836436,64 +4.837890,64 +4.839363,64 +4.840839,64 +4.842287,64 +4.843727,64 +4.845176,64 +4.846625,64 +4.848075,64 +4.849531,64 +4.850991,64 +4.852429,64 +4.853869,64 +4.855339,64 +4.856817,64 +4.858286,64 +4.859731,64 +4.861173,64 +4.862624,64 +4.864062,64 +4.865528,64 +4.866976,64 +4.868418,64 +4.869850,64 +4.871338,64 +4.872778,64 +4.874240,64 +4.875665,64 +4.877103,64 +4.878557,64 +4.880014,64 +4.881466,64 +4.882956,64 +4.884402,64 +4.885842,64 +4.887309,64 +4.888667,64 +4.890116,64 +4.891686,64 +4.893141,64 +4.894579,64 +4.895920,64 +4.897380,64 +4.898857,64 +4.900300,64 +4.901733,64 +4.903169,64 +4.904644,64 +4.906078,64 +4.907525,64 +4.909105,64 +4.910432,64 +4.911998,64 +4.913454,64 +4.914886,64 +4.916332,64 +4.917775,64 +4.919237,64 +4.920679,64 +4.922152,64 +4.923586,64 +4.925055,64 +4.926521,64 +4.927962,64 +4.929412,64 +4.930744,64 +4.932308,64 +4.933763,64 +4.935097,64 +4.936672,64 +4.938034,64 +4.939469,64 +4.940944,64 +4.942408,64 +4.943886,64 +4.945336,64 +4.946789,64 +4.948277,64 +4.949725,64 +4.951204,64 +4.952523,64 +4.954082,64 +4.955546,64 +4.956979,64 +4.958413,64 +4.959854,64 +4.961286,64 +4.962809,64 +4.964244,64 +4.965569,64 +4.967042,64 +4.968510,64 +4.969981,64 +4.971448,64 +4.972918,64 +4.974360,64 +4.975827,64 +4.977281,64 +4.978731,64 +4.980185,64 +4.981638,64 +4.983084,64 +4.984536,64 +4.986001,64 +4.987465,64 +4.988917,64 +4.990359,64 +4.991785,64 +4.993235,64 +4.994691,64 +4.996155,64 +4.997611,64 +4.999048,64 +5.000530,64 +5.001962,64 +5.003402,64 +5.004880,64 +5.006207,64 +5.007686,64 +5.009151,64 +5.010602,64 +5.012065,64 +5.013543,64 +5.015011,64 +5.016468,64 +5.017831,64 +5.019294,64 +5.020731,64 +5.022206,64 +5.023673,64 +5.025123,64 +5.026573,64 +5.028003,64 +5.029482,64 +5.030944,64 +5.032423,64 +5.033871,64 +5.035311,64 +5.036760,64 +5.038201,64 +5.039654,64 +5.041097,64 +5.042574,64 +5.044024,64 +5.045464,64 +5.046910,64 +5.048363,64 +5.049817,64 +5.051269,64 +5.052702,64 +5.054126,64 +5.055589,64 +5.057029,64 +5.058460,64 +5.059923,64 +5.061471,64 +5.062937,64 +5.064385,64 +5.065731,64 +5.067192,64 +5.068648,64 +5.070126,64 +5.071582,64 +5.073047,64 +5.074503,64 +5.075963,64 +5.077418,64 +5.078875,64 +5.080322,64 +5.081777,64 +5.083248,64 +5.084580,64 +5.086138,64 +5.087593,64 +5.089022,64 +5.090501,64 +5.091835,64 +5.093302,64 +5.094767,64 +5.096223,64 +5.097691,64 +5.099160,64 +5.100615,64 +5.102065,64 +5.103534,64 +5.104893,64 +5.106370,64 +5.107834,64 +5.109304,64 +5.110746,64 +5.112187,64 +5.113645,64 +5.115123,64 +5.116567,64 +5.118025,64 +5.119469,64 +5.120962,64 +5.122417,64 +5.123886,64 +5.125333,64 +5.126781,64 +5.128120,64 +5.129695,64 +5.131023,64 +5.132488,64 +5.133938,64 +5.135402,64 +5.136886,64 +5.138373,64 +5.139815,64 +5.141258,64 +5.142696,64 +5.144134,64 +5.145576,64 +5.147012,64 +5.148484,64 +5.149951,64 +5.151385,64 +5.152829,64 +5.154286,64 +5.155734,64 +5.157195,64 +5.158640,64 +5.160073,64 +5.161523,64 +5.162985,64 +5.164424,64 +5.165868,64 +5.167360,64 +5.168796,64 +5.170245,64 +5.171718,64 +5.173165,64 +5.174622,64 +5.176065,64 +5.177515,64 +5.178974,64 +5.180429,64 +5.181873,64 +5.183324,64 +5.184764,64 +5.186216,64 +5.187691,64 +5.189120,64 +5.190578,64 +5.192018,64 +5.193461,64 +5.194922,64 +5.196373,64 +5.197830,64 +5.199279,64 +5.200732,64 +5.202170,64 +5.203621,64 +5.205074,64 +5.206509,64 +5.207956,64 +5.209397,64 +5.210856,64 +5.212343,64 +5.213775,64 +5.215212,64 +5.216664,64 +5.218100,64 +5.219662,64 +5.220993,64 +5.222569,64 +5.224011,64 +5.225453,64 +5.226901,64 +5.228339,64 +5.229779,64 +5.231233,64 +5.232722,64 +5.234080,64 +5.235617,64 +5.237082,64 +5.238524,64 +5.239992,64 +5.241412,64 +5.242866,64 +5.244300,64 +5.245733,64 +5.247155,64 +5.248562,64 +5.250098,64 +5.251515,64 +5.252931,64 +5.254476,64 +5.255897,64 +5.257340,64 +5.258791,64 +5.260242,64 +5.261702,64 +5.263200,64 +5.264660,64 +5.266110,64 +5.267544,64 +5.269002,64 +5.270427,64 +5.271889,64 +5.273338,64 +5.274783,64 +5.276232,64 +5.277705,64 +5.279148,64 +5.280592,64 +5.282045,64 +5.283508,64 +5.284853,64 +5.286306,64 +5.287863,64 +5.289305,64 +5.290787,64 +5.292103,64 +5.293672,64 +5.295135,64 +5.296569,64 +5.298014,64 +5.299377,64 +5.300824,64 +5.302300,64 +5.303789,64 +5.305236,64 +5.306687,64 +5.308150,64 +5.309617,64 +5.311071,64 +5.312471,64 +5.313898,64 +5.315437,64 +5.316847,64 +5.318285,64 +5.319720,64 +5.321188,64 +5.322620,64 +5.324074,64 +5.325542,64 +5.327005,64 +5.328445,64 +5.329921,64 +5.331387,64 +5.332847,64 +5.334215,64 +5.335675,64 +5.337130,64 +5.338580,64 +5.340058,64 +5.341520,64 +5.342976,64 +5.344426,64 +5.345881,64 +5.347324,64 +5.348812,64 +5.350249,64 +5.351719,64 +5.353166,64 +5.354640,64 +5.355967,64 +5.357411,64 +5.358857,64 +5.360318,64 +5.361765,64 +5.363336,64 +5.364790,64 +5.366233,64 +5.367673,64 +5.369105,64 +5.370540,64 +5.371991,64 +5.373449,64 +5.374894,64 +5.376336,64 +5.377767,64 +5.379242,64 +5.380698,64 +5.382181,64 +5.383608,64 +5.385071,64 +5.386511,64 +5.387986,64 +5.389340,64 +5.390865,64 +5.392341,64 +5.393739,64 +5.395211,64 +5.396680,64 +5.398048,64 +5.399548,64 +5.401046,64 +5.402478,64 +5.403962,64 +5.405417,64 +5.406755,64 +5.408237,64 +5.409674,64 +5.411142,64 +5.412593,64 +5.414072,64 +5.415555,64 +5.417003,64 +5.418430,64 +5.419859,64 +5.421278,64 +5.422810,64 +5.424230,64 +5.425643,64 +5.427181,64 +5.428590,64 +5.430011,64 +5.431437,64 +5.432980,64 +5.434419,64 +5.435865,64 +5.437313,64 +5.438754,64 +5.440200,64 +5.441659,64 +5.443107,64 +5.444546,64 +5.445999,64 +5.447429,64 +5.448881,64 +5.450338,64 +5.451797,64 +5.453245,64 +5.454704,64 +5.456147,64 +5.457611,64 +5.459086,64 +5.460523,64 +5.461965,64 +5.463422,64 +5.464880,64 +5.466359,64 +5.467824,64 +5.469261,64 +5.470710,64 +5.472176,64 +5.473509,64 +5.474967,64 +5.476528,64 +5.477979,64 +5.479448,64 +5.480873,64 +5.482319,64 +5.483755,64 +5.485205,64 +5.486572,64 +5.488132,64 +5.489581,64 +5.491050,64 +5.492376,64 +5.493838,64 +5.495305,64 +5.496766,64 +5.498207,64 +5.499673,64 +5.501127,64 +5.502594,64 +5.504015,64 +5.505484,64 +5.506915,64 +5.508378,64 +5.509828,64 +5.511272,64 +5.512713,64 +5.514181,64 +5.515658,64 +5.517120,64 +5.518574,64 +5.520031,64 +5.521480,64 +5.522928,64 +5.524380,64 +5.525842,64 +5.527293,64 +5.528741,64 +5.530200,64 +5.531650,64 +5.533079,64 +5.534529,64 +5.535983,64 +5.537438,64 +5.538868,64 +5.540321,64 +5.541760,64 +5.543198,64 +5.544648,64 +5.546094,64 +5.547528,64 +5.549009,64 +5.550475,64 +5.551941,64 +5.553407,64 +5.554850,64 +5.556288,64 +5.557731,64 +5.559175,64 +5.560617,64 +5.562054,64 +5.563501,64 +5.564965,64 +5.566403,64 +5.567837,64 +5.569282,64 +5.570861,64 +5.572191,64 +5.573760,64 +5.575218,64 +5.576666,64 +5.578108,64 +5.579554,64 +5.580986,64 +5.582444,64 +5.583891,64 +5.585351,64 +5.586788,64 +5.588245,64 +5.589715,64 +5.591077,64 +5.592544,64 +5.594021,64 +5.595477,64 +5.596899,64 +5.598325,64 +5.599865,64 +5.601270,64 +5.602687,64 +5.604236,64 +5.605667,64 +5.607081,64 +5.608494,64 +5.610033,64 +5.611459,64 +5.612891,64 +5.614363,64 +5.615818,64 +5.617291,64 +5.618637,64 +5.620179,64 +5.621584,64 +5.623013,64 +5.624435,64 +5.625999,64 +5.627407,64 +5.628820,64 +5.630236,64 +5.631773,64 +5.633213,64 +5.634640,64 +5.636055,64 +5.637612,64 +5.639030,64 +5.640451,64 +5.641933,64 +5.643341,64 +5.644875,64 +5.646285,64 +5.647690,64 +5.649153,64 +5.650583,64 +5.652023,64 +5.653464,64 +5.654952,64 +5.656410,64 +5.657863,64 +5.659296,64 +5.660747,64 +5.662203,64 +5.663666,64 +5.665105,64 +5.666558,64 +5.667999,64 +5.669443,64 +5.670916,64 +5.672361,64 +5.673821,64 +5.675292,64 +5.676730,64 +5.678166,64 +5.679600,64 +5.681036,64 +5.682600,64 +5.684025,64 +5.685471,64 +5.686910,64 +5.688377,64 +5.689840,64 +5.691270,64 +5.692723,64 +5.694184,64 +5.695647,64 +5.697093,64 +5.698544,64 +5.699984,64 +5.701484,64 +5.702828,64 +5.704321,64 +5.705777,64 +5.707210,64 +5.708670,64 +5.710122,64 +5.711567,64 +5.713025,64 +5.714474,64 +5.715953,64 +5.717420,64 +5.718771,64 +5.720275,64 +5.721718,64 +5.723174,64 +5.724643,64 +5.726105,64 +5.727549,64 +5.728980,64 +5.730466,64 +5.731915,64 +5.733371,64 +5.734798,64 +5.736238,64 +5.737670,64 +5.739121,64 +5.740570,64 +5.742024,64 +5.743443,64 +5.744895,64 +5.746336,64 +5.747907,64 +5.749358,64 +5.750807,64 +5.752261,64 +5.753712,64 +5.755060,64 +5.756511,64 +5.757963,64 +5.759418,64 +5.760862,64 +5.762304,64 +5.763866,64 +5.765323,64 +5.766777,64 +5.768211,64 +5.769661,64 +5.771014,64 +5.772473,64 +5.773935,64 +5.775419,64 +5.776877,64 +5.778321,64 +5.779739,64 +5.781294,64 +5.782719,64 +5.784145,64 +5.785586,64 +5.786998,64 +5.788432,64 +5.789975,64 +5.791390,64 +5.792829,64 +5.794247,64 +5.795799,64 +5.797244,64 +5.798717,64 +5.800040,64 +5.801598,64 +5.803057,64 +5.804477,64 +5.805943,64 +5.807323,64 +5.808765,64 +5.810211,64 +5.811684,64 +5.813133,64 +5.814571,64 +5.816007,64 +5.817576,64 +5.819024,64 +5.820357,64 +5.821930,64 +5.823253,64 +5.824721,64 +5.826282,64 +5.827721,64 +5.829181,64 +5.830516,64 +5.831985,64 +5.833444,64 +5.834879,64 +5.836337,64 +5.837787,64 +5.839237,64 +5.840695,64 +5.842128,64 +5.843579,64 +5.845150,64 +5.846590,64 +5.848056,64 +5.849407,64 +5.850862,64 +5.852310,64 +5.853767,64 +5.855199,64 +5.856752,64 +5.858199,64 +5.859644,64 +5.861074,64 +5.862522,64 +5.863957,64 +5.865411,64 +5.866857,64 +5.868297,64 +5.869773,64 +5.871217,64 +5.872668,64 +5.874106,64 +5.875547,64 +5.876992,64 +5.878440,64 +5.879881,64 +5.881316,64 +5.882771,64 +5.884245,64 +5.885679,64 +5.887155,64 +5.888614,64 +5.890081,64 +5.891548,64 +5.893005,64 +5.894450,64 +5.895921,64 +5.897361,64 +5.898817,64 +5.900260,64 +5.901708,64 +5.903153,64 +5.904599,64 +5.906033,64 +5.907501,64 +5.908938,64 +5.910397,64 +5.911829,64 +5.913262,64 +5.914700,64 +5.916259,64 +5.917702,64 +5.919155,64 +5.920597,64 +5.922050,64 +5.923515,64 +5.924964,64 +5.926417,64 +5.927748,64 +5.929319,64 +5.930656,64 +5.932109,64 +5.933572,64 +5.935035,64 +5.936500,64 +5.937958,64 +5.939397,64 +5.940840,64 +5.942335,64 +5.943773,64 +5.945198,64 +5.946683,64 +5.948173,64 +5.949507,64 +5.951071,64 +5.952488,64 +5.953908,64 +5.955352,64 +5.956820,64 +5.958309,64 +5.959787,64 +5.961123,64 +5.962571,64 +5.964150,64 +5.965505,64 +5.966966,64 +5.968403,64 +5.969845,64 +5.971327,64 +5.972805,64 +5.974269,64 +5.975719,64 +5.977198,64 +5.978632,64 +5.980033,64 +5.981458,64 +5.983013,64 +5.984442,64 +5.985869,64 +5.987281,64 +5.988709,64 +5.990156,64 +5.991698,64 +5.993104,64 +5.994536,64 +5.995949,64 +5.997485,64 +5.998932,64 +6.000339,64 +6.001761,64 +6.003331,64 +6.004658,64 +6.006113,64 +6.007573,64 +6.009128,64 +6.010581,64 +6.012025,64 +6.013479,64 +6.014938,64 +6.016376,64 +6.017846,64 +6.019178,64 +6.020653,64 +6.022097,64 +6.023551,64 +6.025006,64 +6.026471,64 +6.027941,64 +6.029450,64 +6.030887,64 +6.032240,64 +6.033693,64 +6.035137,64 +6.036703,64 +6.038155,64 +6.039497,64 +6.040975,64 +6.042406,64 +6.043891,64 +6.045335,64 +6.046780,64 +6.048248,64 +6.049721,64 +6.051168,64 +6.052662,64 +6.054039,64 +6.055504,64 +6.056991,64 +6.058461,64 +6.059808,64 +6.061370,64 +6.062711,64 +6.064170,64 +6.065649,64 +6.067095,64 +6.068527,64 +6.069973,64 +6.071455,64 +6.072899,64 +6.074356,64 +6.075798,64 +6.077240,64 +6.078684,64 +6.080243,64 +6.081590,64 +6.083034,64 +6.084493,64 +6.085935,64 +6.087388,64 +6.088849,64 +6.090326,64 +6.091788,64 +6.093222,64 +6.094681,64 +6.096148,64 +6.097598,64 +6.099056,64 +6.100516,64 +6.101957,64 +6.103414,64 +6.104866,64 +6.106323,64 +6.107786,64 +6.109259,64 +6.110696,64 +6.112169,64 +6.113514,64 +6.115057,64 +6.116413,64 +6.117875,64 +6.119329,64 +6.120863,64 +6.122339,64 +6.123700,64 +6.125186,64 +6.126605,64 +6.128109,64 +6.129599,64 +6.130948,64 +6.132464,64 +6.133934,64 +6.135287,64 +6.136767,64 +6.138220,64 +6.139705,64 +6.141177,64 +6.142635,64 +6.144002,64 +6.145479,64 +6.146923,64 +6.148362,64 +6.149892,64 +6.151327,64 +6.152746,64 +6.154150,64 +6.155706,64 +6.157127,64 +6.158549,64 +6.159961,64 +6.161410,64 +6.162861,64 +6.164411,64 +6.165790,64 +6.167231,64 +6.168704,64 +6.170151,64 +6.171601,64 +6.173056,64 +6.174523,64 +6.175989,64 +6.177434,64 +6.178874,64 +6.180311,64 +6.181765,64 +6.183216,64 +6.184668,64 +6.186117,64 +6.187588,64 +6.189046,64 +6.190497,64 +6.191951,64 +6.193382,64 +6.194826,64 +6.196277,64 +6.197727,64 +6.199197,64 +6.200630,64 +6.202089,64 +6.203535,64 +6.204986,64 +6.206444,64 +6.207883,64 +6.209319,64 +6.210783,64 +6.212229,64 +6.213704,64 +6.215179,64 +6.216616,64 +6.218062,64 +6.219531,64 +6.220999,64 +6.222452,64 +6.223912,64 +6.225249,64 +6.226813,64 +6.228250,64 +6.229692,64 +6.231138,64 +6.232626,64 +6.234062,64 +6.235495,64 +6.236943,64 +6.238424,64 +6.239864,64 +6.241312,64 +6.242755,64 +6.244214,64 +6.245704,64 +6.247154,64 +6.248508,64 +6.249963,64 +6.251398,64 +6.252858,64 +6.254344,64 +6.255793,64 +6.257253,64 +6.258697,64 +6.260127,64 +6.261562,64 +6.263048,64 +6.264483,64 +6.265923,64 +6.267375,64 +6.268813,64 +6.270250,64 +6.271706,64 +6.273149,64 +6.274602,64 +6.276078,64 +6.277512,64 +6.278991,64 +6.280458,64 +6.281916,64 +6.283358,64 +6.284814,64 +6.286275,64 +6.287722,64 +6.289155,64 +6.290652,64 +6.292093,64 +6.293543,64 +6.294985,64 +6.296460,64 +6.297920,64 +6.299376,64 +6.300826,64 +6.302271,64 +6.303712,64 +6.305189,64 +6.306651,64 +6.307978,64 +6.309547,64 +6.310990,64 +6.312452,64 +6.313882,64 +6.315348,64 +6.316677,64 +6.318151,64 +6.319644,64 +6.321133,64 +6.322575,64 +6.324023,64 +6.325478,64 +6.326918,64 +6.328363,64 +6.329839,64 +6.331287,64 +6.332756,64 +6.334211,64 +6.335648,64 +6.337080,64 +6.338533,64 +6.340022,64 +6.341455,64 +6.342888,64 +6.344330,64 +6.345781,64 +6.347241,64 +6.348602,64 +6.350064,64 +6.351618,64 +6.353073,64 +6.354517,64 +6.355972,64 +6.357434,64 +6.358884,64 +6.360329,64 +6.361747,64 +6.363210,64 +6.364661,64 +6.366121,64 +6.367564,64 +6.369019,64 +6.370490,64 +6.371843,64 +6.373302,64 +6.374737,64 +6.376174,64 +6.377740,64 +6.379087,64 +6.380645,64 +6.382108,64 +6.383568,64 +6.384900,64 +6.386351,64 +6.387792,64 +6.389362,64 +6.390700,64 +6.392254,64 +6.393711,64 +6.395130,64 +6.396510,64 +6.397966,64 +6.399445,64 +6.400883,64 +6.402320,64 +6.403774,64 +6.405231,64 +6.406674,64 +6.408121,64 +6.409564,64 +6.411125,64 +6.412554,64 +6.413998,64 +6.415431,64 +6.416896,64 +6.418348,64 +6.419798,64 +6.421258,64 +6.422711,64 +6.424160,64 +6.425626,64 +6.427060,64 +6.428489,64 +6.429948,64 +6.431388,64 +6.432817,64 +6.434262,64 +6.435691,64 +6.437128,64 +6.438693,64 +6.440140,64 +6.441600,64 +6.443026,64 +6.444480,64 +6.445956,64 +6.447391,64 +6.448839,64 +6.450285,64 +6.451736,64 +6.453185,64 +6.454633,64 +6.456075,64 +6.457551,64 +6.458892,64 +6.460448,64 +6.461890,64 +6.463332,64 +6.464772,64 +6.466214,64 +6.467657,64 +6.469097,64 +6.470541,64 +6.471992,64 +6.473444,64 +6.474882,64 +6.476312,64 +6.477894,64 +6.479226,64 +6.480679,64 +6.482164,64 +6.483624,64 +6.485107,64 +6.486592,64 +6.487946,64 +6.489392,64 +6.490864,64 +6.492310,64 +6.493743,64 +6.495192,64 +6.496656,64 +6.498126,64 +6.499599,64 +6.501056,64 +6.502498,64 +6.503945,64 +6.505408,64 +6.506856,64 +6.508292,64 +6.509741,64 +6.511181,64 +6.512642,64 +6.514079,64 +6.515607,64 +6.517043,64 +6.518479,64 +6.519922,64 +6.521383,64 +6.522829,64 +6.524274,64 +6.525717,64 +6.527162,64 +6.528619,64 +6.530099,64 +6.531575,64 +6.532905,64 +6.534477,64 +6.535910,64 +6.537357,64 +6.538823,64 +6.540268,64 +6.541719,64 +6.543165,64 +6.544632,64 +6.546090,64 +6.547524,64 +6.548980,64 +6.550422,64 +6.551857,64 +6.553317,64 +6.554772,64 +6.556208,64 +6.557657,64 +6.559092,64 +6.560537,64 +6.562002,64 +6.563445,64 +6.564919,64 +6.566358,64 +6.567812,64 +6.569244,64 +6.570710,64 +6.572192,64 +6.573661,64 +6.575111,64 +6.576446,64 +6.578007,64 +6.579470,64 +6.580911,64 +6.582255,64 +6.583823,64 +6.585272,64 +6.586709,64 +6.588178,64 +6.589602,64 +6.591045,64 +6.592487,64 +6.593914,64 +6.595368,64 +6.596844,64 +6.598290,64 +6.599740,64 +6.601207,64 +6.602664,64 +6.604095,64 +6.605544,64 +6.607009,64 +6.608460,64 +6.609897,64 +6.611365,64 +6.612824,64 +6.614289,64 +6.615732,64 +6.617188,64 +6.618637,64 +6.620002,64 +6.621435,64 +6.622890,64 +6.624342,64 +6.625792,64 +6.627362,64 +6.628817,64 +6.630257,64 +6.631723,64 +6.633162,64 +6.634496,64 +6.635974,64 +6.637416,64 +6.638876,64 +6.640315,64 +6.641822,64 +6.643243,64 +6.644686,64 +6.646144,64 +6.647582,64 +6.649091,64 +6.650504,64 +6.652018,64 +6.653462,64 +6.654874,64 +6.656363,64 +6.657784,64 +6.659194,64 +6.660724,64 +6.662151,64 +6.663617,64 +6.665061,64 +6.666490,64 +6.667944,64 +6.669401,64 +6.670893,64 +6.672321,64 +6.673735,64 +6.675152,64 +6.676684,64 +6.678089,64 +6.679503,64 +6.681039,64 +6.682455,64 +6.683866,64 +6.685404,64 +6.686821,64 +6.688267,64 +6.689722,64 +6.691168,64 +6.692609,64 +6.694044,64 +6.695496,64 +6.696946,64 +6.698391,64 +6.699856,64 +6.701298,64 +6.702726,64 +6.704201,64 +6.705677,64 +6.707137,64 +6.708514,64 +6.709998,64 +6.711487,64 +6.712939,64 +6.714376,64 +6.715822,64 +6.717268,64 +6.718750,64 +6.720196,64 +6.721645,64 +6.723085,64 +6.724535,64 +6.725966,64 +6.727441,64 +6.728922,64 +6.730399,64 +6.731754,64 +6.733194,64 +6.734634,64 +6.736197,64 +6.737615,64 +6.739100,64 +6.740552,64 +6.741982,64 +6.743437,64 +6.744891,64 +6.746332,64 +6.747778,64 +6.749224,64 +6.750677,64 +6.752122,64 +6.753562,64 +6.755019,64 +6.756473,64 +6.757931,64 +6.759378,64 +6.760811,64 +6.762265,64 +6.763730,64 +6.765189,64 +6.766625,64 +6.768060,64 +6.769499,64 +6.770942,64 +6.772398,64 +6.773856,64 +6.775321,64 +6.776762,64 +6.778237,64 +6.779714,64 +6.781167,64 +6.782529,64 +6.784087,64 +6.785539,64 +6.786974,64 +6.788430,64 +6.789875,64 +6.791313,64 +6.792757,64 +6.794195,64 +6.795647,64 +6.797098,64 +6.798543,64 +6.799988,64 +6.801451,64 +6.802899,64 +6.804352,64 +6.805806,64 +6.807257,64 +6.808692,64 +6.810134,64 +6.811571,64 +6.813030,64 +6.814491,64 +6.815960,64 +6.817438,64 +6.818883,64 +6.820363,64 +6.821813,64 +6.823279,64 +6.824716,64 +6.826183,64 +6.827511,64 +6.829089,64 +6.830533,64 +6.831981,64 +6.833406,64 +6.834842,64 +6.836283,64 +6.837758,64 +6.839224,64 +6.840649,64 +6.842055,64 +6.843500,64 +6.844939,64 +6.846403,64 +6.847837,64 +6.849301,64 +6.850750,64 +6.852180,64 +6.853747,64 +6.855194,64 +6.856625,64 +6.858061,64 +6.859507,64 +6.860940,64 +6.862372,64 +6.863838,64 +6.865292,64 +6.866733,64 +6.868182,64 +6.869630,64 +6.871108,64 +6.872555,64 +6.874033,64 +6.875506,64 +6.876963,64 +6.878310,64 +6.879764,64 +6.881221,64 +6.882720,64 +6.884231,64 +6.885569,64 +6.887046,64 +6.888552,64 +6.889914,64 +6.891428,64 +6.892911,64 +6.894404,64 +6.895751,64 +6.897202,64 +6.898653,64 +6.900118,64 +6.901575,64 +6.903047,64 +6.904490,64 +6.905968,64 +6.907425,64 +6.908872,64 +6.910319,64 +6.911772,64 +6.913214,64 +6.914707,64 +6.916155,64 +6.917499,64 +6.919013,64 +6.920471,64 +6.921922,64 +6.923389,64 +6.924825,64 +6.926271,64 +6.927709,64 +6.929167,64 +6.930620,64 +6.932095,64 +6.933541,64 +6.934993,64 +6.936429,64 +6.937893,64 +6.939332,64 +6.940795,64 +6.942243,64 +6.943691,64 +6.945174,64 +6.946526,64 +6.947992,64 +6.949499,64 +6.950967,64 +6.952421,64 +6.953895,64 +6.955351,64 +6.956817,64 +6.958157,64 +6.959635,64 +6.961093,64 +6.962579,64 +6.964031,64 +6.965475,64 +6.966935,64 +6.968385,64 +6.969814,64 +6.971279,64 +6.972730,64 +6.974195,64 +6.975655,64 +6.977096,64 +6.978531,64 +6.979987,64 +6.981473,64 +6.982919,64 +6.984356,64 +6.985798,64 +6.987272,64 +6.988712,64 +6.990152,64 +6.991596,64 +6.993030,64 +6.994481,64 +6.995929,64 +6.997385,64 +6.998825,64 +7.000267,64 +7.001716,64 +7.003132,64 +7.004610,64 +7.006013,64 +7.007489,64 +7.008960,64 +7.010426,64 +7.011905,64 +7.013353,64 +7.014821,64 +7.016278,64 +7.017688,64 +7.019110,64 +7.020528,64 +7.022067,64 +7.023484,64 +7.024911,64 +7.026324,64 +7.027855,64 +7.029263,64 +7.030677,64 +7.032231,64 +7.033685,64 +7.035141,64 +7.036593,64 +7.037946,64 +7.039416,64 +7.040888,64 +7.042349,64 +7.043804,64 +7.045217,64 +7.046648,64 +7.048190,64 +7.049632,64 +7.051061,64 +7.052482,64 +7.053903,64 +7.055476,64 +7.056909,64 +7.058321,64 +7.059782,64 +7.061198,64 +7.062629,64 +7.064178,64 +7.065595,64 +7.067007,64 +7.068466,64 +7.069880,64 +7.071429,64 +7.072851,64 +7.074261,64 +7.075673,64 +7.077218,64 +7.078628,64 +7.080047,64 +7.081600,64 +7.083013,64 +7.084440,64 +7.085885,64 +7.087331,64 +7.088777,64 +7.090225,64 +7.091668,64 +7.093115,64 +7.094593,64 +7.096086,64 +7.097548,64 +7.098998,64 +7.100438,64 +7.101863,64 +7.103357,64 +7.104711,64 +7.106153,64 +7.107622,64 +7.109067,64 +7.110510,64 +7.111981,64 +7.113438,64 +7.114894,64 +7.116330,64 +7.117773,64 +7.119216,64 +7.120699,64 +7.122139,64 +7.123603,64 +7.125055,64 +7.126498,64 +7.127954,64 +7.129409,64 +7.130890,64 +7.132381,64 +7.133813,64 +7.135270,64 +7.136718,64 +7.138186,64 +7.139652,64 +7.141096,64 +7.142529,64 +7.143971,64 +7.145460,64 +7.146897,64 +7.148365,64 +7.149799,64 +7.151239,64 +7.152689,64 +7.154124,64 +7.155571,64 +7.157018,64 +7.158454,64 +7.159892,64 +7.161341,64 +7.162817,64 +7.164286,64 +7.165754,64 +7.167234,64 +7.168663,64 +7.170122,64 +7.171569,64 +7.173033,64 +7.174387,64 +7.175854,64 +7.177314,64 +7.178800,64 +7.180257,64 +7.181712,64 +7.183179,64 +7.184629,64 +7.186081,64 +7.187432,64 +7.188901,64 +7.190365,64 +7.191835,64 +7.193279,64 +7.194730,64 +7.196209,64 +7.197656,64 +7.199078,64 +7.200549,64 +7.201958,64 +7.203383,64 +7.204942,64 +7.206395,64 +7.207818,64 +7.209252,64 +7.210660,64 +7.212087,64 +7.213650,64 +7.215123,64 +7.216442,64 +7.217943,64 +7.219387,64 +7.220843,64 +7.222314,64 +7.223812,64 +7.225166,64 +7.226714,64 +7.228167,64 +7.229514,64 +7.230983,64 +7.232528,64 +7.233859,64 +7.235320,64 +7.236817,64 +7.238291,64 +7.239761,64 +7.241236,64 +7.242688,64 +7.244118,64 +7.245576,64 +7.247022,64 +7.248468,64 +7.249916,64 +7.251369,64 +7.252823,64 +7.254278,64 +7.255736,64 +7.257073,64 +7.258561,64 +7.259996,64 +7.261496,64 +7.262934,64 +7.264430,64 +7.265798,64 +7.267318,64 +7.268685,64 +7.270220,64 +7.271712,64 +7.273059,64 +7.274534,64 +7.275978,64 +7.277454,64 +7.278925,64 +7.280290,64 +7.281879,64 +7.283309,64 +7.284739,64 +7.286179,64 +7.287627,64 +7.289055,64 +7.290532,64 +7.291987,64 +7.293458,64 +7.294806,64 +7.296278,64 +7.297749,64 +7.299211,64 +7.300669,64 +7.302134,64 +7.303606,64 +7.305021,64 +7.306445,64 +7.307875,64 +7.309416,64 +7.310827,64 +7.312263,64 +7.313746,64 +7.315213,64 +7.316674,64 +7.318142,64 +7.319486,64 +7.320987,64 +7.322439,64 +7.323909,64 +7.325393,64 +7.326840,64 +7.328302,64 +7.329761,64 +7.331212,64 +7.332660,64 +7.334106,64 +7.335555,64 +7.337011,64 +7.338459,64 +7.339795,64 +7.341274,64 +7.342742,64 +7.344222,64 +7.345651,64 +7.347102,64 +7.348540,64 +7.350001,64 +7.351452,64 +7.352885,64 +7.354342,64 +7.355802,64 +7.357300,64 +7.358738,64 +7.360187,64 +7.361614,64 +7.363064,64 +7.364543,64 +7.365985,64 +7.367420,64 +7.368861,64 +7.370325,64 +7.371803,64 +7.373243,64 +7.374695,64 +7.376176,64 +7.377616,64 +7.379050,64 +7.380509,64 +7.381947,64 +7.383398,64 +7.384840,64 +7.386301,64 +7.387775,64 +7.389220,64 +7.390665,64 +7.392104,64 +7.393553,64 +7.395003,64 +7.396459,64 +7.397957,64 +7.399313,64 +7.400774,64 +7.402205,64 +7.403656,64 +7.405112,64 +7.406569,64 +7.408010,64 +7.409496,64 +7.410957,64 +7.412402,64 +7.413914,64 +7.415376,64 +7.416710,64 +7.418195,64 +7.419662,64 +7.421098,64 +7.422551,64 +7.424028,64 +7.425451,64 +7.426901,64 +7.428350,64 +7.429806,64 +7.431255,64 +7.432702,64 +7.434150,64 +7.435613,64 +7.437069,64 +7.438499,64 +7.439974,64 +7.441449,64 +7.442889,64 +7.444364,64 +7.445825,64 +7.447311,64 +7.448754,64 +7.450210,64 +7.451666,64 +7.453107,64 +7.454448,64 +7.455895,64 +7.457386,64 +7.458884,64 +7.460352,64 +7.461827,64 +7.463282,64 +7.464603,64 +7.466170,64 +7.467608,64 +7.468954,64 +7.470419,64 +7.471890,64 +7.473320,64 +7.474788,64 +7.476244,64 +7.477755,64 +7.479215,64 +7.480689,64 +7.482156,64 +7.483470,64 +7.485041,64 +7.486489,64 +7.487959,64 +7.489302,64 +7.490765,64 +7.492255,64 +7.493690,64 +7.495151,64 +7.496652,64 +7.497997,64 +7.499473,64 +7.500950,64 +7.502378,64 +7.503790,64 +7.505265,64 +7.506740,64 +7.508209,64 +7.509645,64 +7.511113,64 +7.512543,64 +7.513968,64 +7.515414,64 +7.516956,64 +7.518385,64 +7.519813,64 +7.521251,64 +7.522703,64 +7.524155,64 +7.525604,64 +7.527069,64 +7.528535,64 +7.530009,64 +7.531451,64 +7.532887,64 +7.534343,64 +7.535787,64 +7.537218,64 +7.538649,64 +7.540095,64 +7.541527,64 +7.542968,64 +7.544537,64 +7.545873,64 +7.547330,64 +7.548780,64 +7.550249,64 +7.551673,64 +7.553242,64 +7.554693,64 +7.556164,64 +7.557549,64 +7.558967,64 +7.560620,64 +7.561964,64 +7.563408,64 +7.564742,64 +7.566290,64 +7.567640,64 +7.569145,64 +7.570636,64 +7.572124,64 +7.573454,64 +7.575015,64 +7.576436,64 +7.577928,64 +7.579333,64 +7.580794,64 +7.582268,64 +7.583616,64 +7.585139,64 +7.586536,64 +7.588073,64 +7.589454,64 +7.590956,64 +7.592355,64 +7.593869,64 +7.595257,64 +7.596800,64 +7.598236,64 +7.599594,64 +7.601214,64 +7.602469,64 +7.603991,64 +7.605413,64 +7.606905,64 +7.608364,64 +7.609804,64 +7.611192,64 +7.612762,64 +7.614194,64 +7.615587,64 +7.617022,64 +7.618489,64 +7.619962,64 +7.621443,64 +7.622871,64 +7.624312,64 +7.625731,64 +7.627147,64 +7.628683,64 +7.630119,64 +7.631575,64 +7.633014,64 +7.634463,64 +7.635898,64 +7.637365,64 +7.638805,64 +7.640266,64 +7.641723,64 +7.643183,64 +7.644653,64 +7.646137,64 +7.647460,64 +7.648935,64 +7.650425,64 +7.651873,64 +7.653340,64 +7.654809,64 +7.656269,64 +7.657702,64 +7.659142,64 +7.660582,64 +7.662038,64 +7.663492,64 +7.664940,64 +7.666404,64 +7.667853,64 +7.669286,64 +7.670729,64 +7.672170,64 +7.673628,64 +7.675079,64 +7.676523,64 +7.677999,64 +7.679451,64 +7.680907,64 +7.682377,64 +7.683862,64 +7.685321,64 +7.686755,64 +7.688206,64 +7.689545,64 +7.691008,64 +7.692462,64 +7.693909,64 +7.695353,64 +7.696809,64 +7.698268,64 +7.699828,64 +7.701267,64 +7.702737,64 +7.704179,64 +7.705620,64 +7.707068,64 +7.708509,64 +7.709941,64 +7.711407,64 +7.712854,64 +7.714299,64 +7.715760,64 +7.717193,64 +7.718628,64 +7.720102,64 +7.721539,64 +7.723010,64 +7.724455,64 +7.725884,64 +7.727319,64 +7.728763,64 +7.730237,64 +7.731682,64 +7.733124,64 +7.734617,64 +7.736053,64 +7.737506,64 +7.738955,64 +7.740401,64 +7.741889,64 +7.743236,64 +7.744812,64 +7.746258,64 +7.747700,64 +7.749162,64 +7.750611,64 +7.752054,64 +7.753516,64 +7.754847,64 +7.756341,64 +7.757790,64 +7.759257,64 +7.760701,64 +7.762157,64 +7.763603,64 +7.765043,64 +7.766504,64 +7.767952,64 +7.769425,64 +7.770888,64 +7.772365,64 +7.773810,64 +7.775254,64 +7.776691,64 +7.778163,64 +7.779645,64 +7.781083,64 +7.782532,64 +7.783969,64 +7.785406,64 +7.786843,64 +7.788295,64 +7.789750,64 +7.791196,64 +7.792645,64 +7.794128,64 +7.795589,64 +7.797051,64 +7.798512,64 +7.799943,64 +7.801379,64 +7.802819,64 +7.804271,64 +7.805715,64 +7.807162,64 +7.808625,64 +7.810101,64 +7.811566,64 +7.812919,64 +7.814368,64 +7.815802,64 +7.817373,64 +7.818815,64 +7.820162,64 +7.821651,64 +7.823109,64 +7.824571,64 +7.826040,64 +7.827483,64 +7.828933,64 +7.830378,64 +7.831828,64 +7.833265,64 +7.834721,64 +7.836158,64 +7.837599,64 +7.839041,64 +7.840495,64 +7.841959,64 +7.843401,64 +7.844835,64 +7.846311,64 +7.847782,64 +7.849287,64 +7.850757,64 +7.852192,64 +7.853628,64 +7.855063,64 +7.856551,64 +7.857898,64 +7.859452,64 +7.860903,64 +7.862335,64 +7.863788,64 +7.865264,64 +7.866596,64 +7.868092,64 +7.869543,64 +7.870978,64 +7.872419,64 +7.873898,64 +7.875350,64 +7.876812,64 +7.878283,64 +7.879756,64 +7.881207,64 +7.882649,64 +7.884131,64 +7.885462,64 +7.887037,64 +7.888479,64 +7.889930,64 +7.891388,64 +7.892833,64 +7.894291,64 +7.895627,64 +7.897180,64 +7.898589,64 +7.899998,64 +7.901538,64 +7.902948,64 +7.904379,64 +7.905825,64 +7.907275,64 +7.908724,64 +7.910176,64 +7.911609,64 +7.913079,64 +7.914527,64 +7.915981,64 +7.917443,64 +7.918881,64 +7.920351,64 +7.921811,64 +7.923277,64 +7.924713,64 +7.926172,64 +7.927629,64 +7.929104,64 +7.930563,64 +7.931933,64 +7.933393,64 +7.934842,64 +7.936276,64 +7.937724,64 +7.939191,64 +7.940646,64 +7.942087,64 +7.943545,64 +7.945010,64 +7.946466,64 +7.947953,64 +7.949426,64 +7.950858,64 +7.952311,64 +7.953759,64 +7.955211,64 +7.956651,64 +7.958123,64 +7.959562,64 +7.961001,64 +7.962480,64 +7.963829,64 +7.965300,64 +7.966777,64 +7.968251,64 +7.969721,64 +7.971161,64 +7.972625,64 +7.974094,64 +7.975555,64 +7.977007,64 +7.978473,64 +7.979821,64 +7.981357,64 +7.982686,64 +7.984156,64 +7.985712,64 +7.987164,64 +7.988630,64 +7.989946,64 +7.991517,64 +7.992979,64 +7.994319,64 +7.995785,64 +7.997223,64 +7.998668,64 +8.000133,64 +8.001576,64 +8.003028,64 +8.004498,64 +8.005955,64 +8.007451,64 +8.008898,64 +8.010355,64 +8.011797,64 +8.013251,64 +8.014718,64 +8.016091,64 +8.017524,64 +8.018967,64 +8.020532,64 +8.021960,64 +8.023372,64 +8.024795,64 +8.026318,64 +8.027728,64 +8.029139,64 +8.030680,64 +8.032087,64 +8.033509,64 +8.035052,64 +8.036456,64 +8.037873,64 +8.039402,64 +8.040858,64 +8.042307,64 +8.043653,64 +8.045117,64 +8.046671,64 +8.048008,64 +8.049469,64 +8.050959,64 +8.052441,64 +8.053934,64 +8.055385,64 +8.056820,64 +8.058157,64 +8.059614,64 +8.061076,64 +8.062556,64 +8.063995,64 +8.065453,64 +8.066912,64 +8.068357,64 +8.069824,64 +8.071271,64 +8.072727,64 +8.074177,64 +8.075615,64 +8.077064,64 +8.078572,64 +8.080026,64 +8.081478,64 +8.082827,64 +8.084274,64 +8.085839,64 +8.087306,64 +8.088746,64 +8.090195,64 +8.091619,64 +8.093077,64 +8.094538,64 +8.095987,64 +8.097441,64 +8.098785,64 +8.100357,64 +8.101814,64 +8.103249,64 +8.104695,64 +8.106136,64 +8.107572,64 +8.109031,64 +8.110476,64 +8.111932,64 +8.113359,64 +8.114796,64 +8.116229,64 +8.117707,64 +8.119159,64 +8.120601,64 +8.122082,64 +8.123522,64 +8.124983,64 +8.126417,64 +8.127870,64 +8.129321,64 +8.130804,64 +8.132251,64 +8.133683,64 +8.135141,64 +8.136611,64 +8.138075,64 +8.139532,64 +8.140946,64 +8.142361,64 +8.143809,64 +8.145354,64 +8.146779,64 +8.148191,64 +8.149602,64 +8.151143,64 +8.152562,64 +8.153973,64 +8.155495,64 +8.156905,64 +8.158323,64 +8.159860,64 +8.161274,64 +8.162759,64 +8.164210,64 +8.165664,64 +8.167105,64 +8.168556,64 +8.170023,64 +8.171472,64 +8.172927,64 +8.174369,64 +8.175850,64 +8.177165,64 +8.178605,64 +8.180172,64 +8.181548,64 +8.182972,64 +8.184502,64 +8.185892,64 +8.187382,64 +8.188862,64 +8.190356,64 +8.191695,64 +8.193141,64 +8.194592,64 +8.196042,64 +8.197537,64 +8.198986,64 +8.200435,64 +8.201887,64 +8.203335,64 +8.204791,64 +8.206202,64 +8.207740,64 +8.209150,64 +8.210545,64 +8.212064,64 +8.213495,64 +8.214913,64 +8.216343,64 +8.217886,64 +8.219295,64 +8.220722,64 +8.222257,64 +8.223676,64 +8.225117,64 +8.226535,64 +8.228060,64 +8.229474,64 +8.230922,64 +8.232352,64 +8.233767,64 +8.235307,64 +8.236719,64 +8.238142,64 +8.239676,64 +8.241130,64 +8.242462,64 +8.243954,64 +8.245417,64 +8.246819,64 +8.254551,320 +8.256291,64 +8.257088,64 +8.258444,64 +8.259918,64 +8.261387,64 +8.263275,64 +8.264309,64 +8.266366,64 +8.267149,64 +8.268793,64 +8.270127,64 +8.271600,64 +8.273870,64 +8.274434,64 +8.275944,64 +8.277379,64 +8.278827,64 +8.280257,64 +8.281709,64 +8.283127,64 +8.284671,64 +8.286093,64 +8.287506,64 +8.288927,64 +8.290371,64 +8.291809,64 +8.293265,64 +8.294733,64 +8.296162,64 +8.297701,64 +8.299134,64 +8.300587,64 +8.302044,64 +8.303480,64 +8.304909,64 +8.306340,64 +8.307827,64 +8.309284,64 +8.310761,64 +8.312142,64 +8.313636,64 +8.315116,64 +8.316585,64 +8.318046,64 +8.319412,64 +8.320876,64 +8.322330,64 +8.323782,64 +8.325215,64 +8.326660,64 +8.328109,64 +8.329565,64 +8.331011,64 +8.332457,64 +8.333918,64 +8.335364,64 +8.336796,64 +8.338264,64 +8.339738,64 +8.341187,64 +8.342683,64 +8.344141,64 +8.345613,64 +8.346954,64 +8.348424,64 +8.349882,64 +8.351331,64 +8.352877,64 +8.354210,64 +8.355692,64 +8.357146,64 +8.358588,64 +8.360033,64 +8.361509,64 +8.362968,64 +8.364417,64 +8.365864,64 +8.367328,64 +8.368793,64 +8.370264,64 +8.371702,64 +8.373199,64 +8.374638,64 +8.376095,64 +8.377554,64 +8.379011,64 +8.380347,64 +8.381817,64 +8.383321,64 +8.384769,64 +8.386211,64 +8.387639,64 +8.389073,64 +8.390532,64 +8.391943,64 +8.393480,64 +8.394921,64 +8.396344,64 +8.397780,64 +8.399214,64 +8.400760,64 +8.402197,64 +8.403620,64 +8.405044,64 +8.406474,64 +8.408021,64 +8.409446,64 +8.410874,64 +8.412285,64 +8.413708,64 +8.415260,64 +8.416677,64 +8.418088,64 +8.419633,64 +8.421073,64 +8.422485,64 +8.423926,64 +8.425333,64 +8.426889,64 +8.428301,64 +8.429735,64 +8.431202,64 +8.432692,64 +8.434147,64 +8.435598,64 +8.436935,64 +8.438382,64 +8.439841,64 +8.441285,64 +8.442850,64 +8.444305,64 +8.445752,64 +8.447095,64 +8.448550,64 +8.450013,64 +8.451448,64 +8.453024,64 +8.454351,64 +8.455796,64 +8.457256,64 +8.458722,64 +8.460199,64 +8.461692,64 +8.463160,64 +8.464620,64 +8.466073,64 +8.467519,64 +8.468960,64 +8.470395,64 +8.471859,64 +8.473341,64 +8.474777,64 +8.476225,64 +8.477578,64 +8.479033,64 +8.480491,64 +8.481975,64 +8.483425,64 +8.484880,64 +8.486353,64 +8.487833,64 +8.489292,64 +8.490636,64 +8.492107,64 +8.493552,64 +8.495008,64 +8.496458,64 +8.497904,64 +8.499375,64 +8.500841,64 +8.502319,64 +8.503763,64 +8.505236,64 +8.506677,64 +8.508136,64 +8.509591,64 +8.511017,64 +8.512480,64 +8.513947,64 +8.515401,64 +8.516870,64 +8.518310,64 +8.519768,64 +8.521188,64 +8.522641,64 +8.524067,64 +8.525488,64 +8.526904,64 +8.528366,64 +8.529876,64 +8.531370,64 +8.532829,64 +8.534172,64 +8.535627,64 +8.537190,64 +8.538619,64 +8.540039,64 +8.541458,64 +8.542878,64 +8.544421,64 +8.545832,64 +8.547262,64 +8.548786,64 +8.550203,64 +8.551615,64 +8.553035,64 +8.554576,64 +8.556023,64 +8.557473,64 +8.558962,64 +8.560395,64 +8.561854,64 +8.563306,64 +8.564742,64 +8.566217,64 +8.567661,64 +8.569004,64 +8.570474,64 +8.571945,64 +8.573422,64 +8.574900,64 +8.576335,64 +8.577760,64 +8.579197,64 +8.580638,64 +8.582088,64 +8.583533,64 +8.585015,64 +8.586457,64 +8.587936,64 +8.589397,64 +8.590850,64 +8.592291,64 +8.593736,64 +8.595187,64 +8.596656,64 +8.598093,64 +8.599556,64 +8.600999,64 +8.602425,64 +8.603864,64 +8.605319,64 +8.606780,64 +8.608214,64 +8.609705,64 +8.611146,64 +8.612607,64 +8.614039,64 +8.615490,64 +8.616955,64 +8.618432,64 +8.619871,64 +8.621326,64 +8.622755,64 +8.624202,64 +8.625655,64 +8.627101,64 +8.628570,64 +8.630054,64 +8.631521,64 +8.632867,64 +8.634329,64 +8.635781,64 +8.637232,64 +8.638681,64 +8.640127,64 +8.641610,64 +8.643063,64 +8.644514,64 +8.645984,64 +8.647438,64 +8.648885,64 +8.650340,64 +8.651801,64 +8.653246,64 +8.654709,64 +8.656189,64 +8.657533,64 +8.659096,64 +8.660423,64 +8.661898,64 +8.663370,64 +8.664824,64 +8.666288,64 +8.667735,64 +8.669215,64 +8.670646,64 +8.672072,64 +8.673509,64 +8.675046,64 +8.676476,64 +8.677887,64 +8.679305,64 +8.680841,64 +8.682286,64 +8.683693,64 +8.685120,64 +8.686557,64 +8.687991,64 +8.689448,64 +8.690926,64 +8.692364,64 +8.693824,64 +8.695267,64 +8.696721,64 +8.698167,64 +8.699609,64 +8.701069,64 +8.702526,64 +8.703973,64 +8.705414,64 +8.706976,64 +8.708425,64 +8.709889,64 +8.711225,64 +8.712777,64 +8.714223,64 +8.715669,64 +8.717106,64 +8.718558,64 +8.719999,64 +8.721483,64 +8.722914,64 +8.724378,64 +8.725836,64 +8.727275,64 +8.728742,64 +8.730080,64 +8.731546,64 +8.733007,64 +8.734454,64 +8.735926,64 +8.737383,64 +8.738840,64 +8.740294,64 +8.741748,64 +8.743201,64 +8.744670,64 +8.746145,64 +8.747595,64 +8.749040,64 +8.750400,64 +8.751872,64 +8.753311,64 +8.754862,64 +8.756327,64 +8.757772,64 +8.759207,64 +8.760668,64 +8.762121,64 +8.763573,64 +8.765008,64 +8.766461,64 +8.767927,64 +8.769261,64 +8.770758,64 +8.772212,64 +8.773672,64 +8.775128,64 +8.776564,64 +8.777996,64 +8.779473,64 +8.780920,64 +8.782361,64 +8.783798,64 +8.785239,64 +8.786710,64 +8.788182,64 +8.789643,64 +8.791092,64 +8.792582,64 +8.794033,64 +8.795476,64 +8.796915,64 +8.798356,64 +8.799812,64 +8.801277,64 +8.802755,64 +8.804219,64 +8.805540,64 +8.807018,64 +8.808472,64 +8.809958,64 +8.811428,64 +8.812919,64 +8.814363,64 +8.815836,64 +8.817273,64 +8.818741,64 +8.820071,64 +8.821534,64 +8.823024,64 +8.824477,64 +8.825931,64 +8.827396,64 +8.828878,64 +8.830217,64 +8.831689,64 +8.833175,64 +8.834613,64 +8.836069,64 +8.837518,64 +8.838967,64 +8.840420,64 +8.841861,64 +8.843308,64 +8.844730,64 +8.846204,64 +8.847721,64 +8.849169,64 +8.850653,64 +8.852111,64 +8.853556,64 +8.854884,64 +8.856357,64 +8.857803,64 +8.859359,64 +8.860797,64 +8.862150,64 +8.863624,64 +8.865105,64 +8.866560,64 +8.868003,64 +8.869454,64 +8.870904,64 +8.872350,64 +8.873808,64 +8.875279,64 +8.876753,64 +8.878210,64 +8.879656,64 +8.881092,64 +8.882548,64 +8.883982,64 +8.885414,64 +8.886877,64 +8.888318,64 +8.889771,64 +8.891238,64 +8.892677,64 +8.894115,64 +8.895558,64 +8.897035,64 +8.898500,64 +8.899986,64 +8.901350,64 +8.902803,64 +8.904271,64 +8.905731,64 +8.907261,64 +8.908694,64 +8.910148,64 +8.911604,64 +8.913060,64 +8.914405,64 +8.915877,64 +8.917317,64 +8.918759,64 +8.920217,64 +8.921695,64 +8.923149,64 +8.924593,64 +8.926048,64 +8.927513,64 +8.928969,64 +8.930428,64 +8.931910,64 +8.933369,64 +8.934792,64 +8.936199,64 +8.938602,64 +8.939131,64 +8.940618,64 +8.941962,64 +8.943533,64 +8.944972,64 +8.946312,64 +8.947810,64 +8.949288,64 +8.950766,64 +8.952249,64 +8.953687,64 +8.955143,64 +8.956607,64 +8.957926,64 +8.959489,64 +8.960829,64 +8.962402,64 +8.963746,64 +8.965211,64 +8.966662,64 +8.968110,64 +8.969559,64 +8.971017,64 +8.972467,64 +8.973934,64 +8.975391,64 +8.976832,64 +8.978312,64 +8.979817,64 +8.981245,64 +8.982702,64 +8.984156,64 +8.985618,64 +8.987065,64 +8.988502,64 +8.989978,64 +8.991315,64 +8.992771,64 +8.994200,64 +8.995659,64 +8.997114,64 +8.998601,64 +9.000046,64 +9.001492,64 +9.002944,64 +9.004430,64 +9.005901,64 +9.007364,64 +9.008822,64 +9.010302,64 +9.011737,64 +9.013188,64 +9.014645,64 +9.016094,64 +9.017529,64 +9.018990,64 +9.020336,64 +9.021816,64 +9.023269,64 +9.024729,64 +9.026180,64 +9.027643,64 +9.029077,64 +9.030551,64 +9.032027,64 +9.033490,64 +9.034985,64 +9.036292,64 +9.037785,64 +9.039266,64 +9.040765,64 +9.042215,64 +9.043570,64 +9.045010,64 +9.046448,64 +9.047931,64 +9.049377,64 +9.050830,64 +9.052272,64 +9.053747,64 +9.055198,64 +9.056691,64 +9.058130,64 +9.059560,64 +9.060980,64 +9.062432,64 +9.063899,64 +9.065362,64 +9.066809,64 +9.068249,64 +9.069693,64 +9.071137,64 +9.072623,64 +9.074063,64 +9.075505,64 +9.076945,64 +9.078413,64 +9.079865,64 +9.081330,64 +9.082791,64 +9.084283,64 +9.085637,64 +9.087108,64 +9.088581,64 +9.090040,64 +9.091493,64 +9.092926,64 +9.094370,64 +9.095831,64 +9.097283,64 +9.098724,64 +9.100153,64 +9.101597,64 +9.103161,64 +9.104623,64 +9.106060,64 +9.107448,64 +9.108891,64 +9.110335,64 +9.111787,64 +9.113249,64 +9.114691,64 +9.116145,64 +9.117587,64 +9.119036,64 +9.120506,64 +9.121967,64 +9.123417,64 +9.124859,64 +9.126301,64 +9.127756,64 +9.129211,64 +9.130675,64 +9.132113,64 +9.133565,64 +9.135010,64 +9.136467,64 +9.137938,64 +9.139383,64 +9.140864,64 +9.142324,64 +9.143779,64 +9.145131,64 +9.146603,64 +9.148049,64 +9.149496,64 +9.150962,64 +9.152399,64 +9.153874,64 +9.155322,64 +9.156785,64 +9.158231,64 +9.159673,64 +9.161128,64 +9.162576,64 +9.164018,64 +9.165460,64 +9.166900,64 +9.168350,64 +9.169823,64 +9.171299,64 +9.172747,64 +9.174193,64 +9.175636,64 +9.177073,64 +9.178510,64 +9.179973,64 +9.181465,64 +9.182910,64 +9.184358,64 +9.185851,64 +9.187318,64 +9.188793,64 +9.190244,64 +9.191694,64 +9.193138,64 +9.194583,64 +9.195939,64 +9.197505,64 +9.198952,64 +9.200404,64 +9.201740,64 +9.203223,64 +9.204663,64 +9.206122,64 +9.207574,64 +9.209022,64 +9.210505,64 +9.211951,64 +9.213395,64 +9.214832,64 +9.216274,64 +9.217728,64 +9.219160,64 +9.220620,64 +9.222091,64 +9.223534,64 +9.224985,64 +9.226440,64 +9.227898,64 +9.229345,64 +9.230813,64 +9.232277,64 +9.233759,64 +9.235216,64 +9.236648,64 +9.238080,64 +9.239509,64 +9.240950,64 +9.242402,64 +9.243884,64 +9.245328,64 +9.246765,64 +9.248205,64 +9.249643,64 +9.251099,64 +9.252550,64 +9.254001,64 +9.255475,64 +9.256956,64 +9.258417,64 +9.259888,64 +9.261332,64 +9.262688,64 +9.264157,64 +9.265626,64 +9.267063,64 +9.268530,64 +9.269976,64 +9.271433,64 +9.272877,64 +9.274329,64 +9.275777,64 +9.277222,64 +9.278685,64 +9.280128,64 +9.281585,64 +9.283024,64 +9.284465,64 +9.286011,64 +9.287446,64 +9.288902,64 +9.290260,64 +9.291747,64 +9.293193,64 +9.294639,64 +9.296091,64 +9.297537,64 +9.299033,64 +9.300471,64 +9.301931,64 +9.303384,64 +9.304843,64 +9.306293,64 +9.307772,64 +9.309217,64 +9.310694,64 +9.312077,64 +9.313551,64 +9.314989,64 +9.316493,64 +9.317941,64 +9.319277,64 +9.320747,64 +9.322215,64 +9.323675,64 +9.325132,64 +9.326581,64 +9.328034,64 +9.329503,64 +9.330976,64 +9.332428,64 +9.333888,64 +9.335339,64 +9.336799,64 +9.338246,64 +9.339696,64 +9.341149,64 +9.342623,64 +9.343957,64 +9.345423,64 +9.346862,64 +9.348305,64 +9.349782,64 +9.351321,64 +9.352725,64 +9.354179,64 +9.355633,64 +9.357074,64 +9.358517,64 +9.359976,64 +9.361453,64 +9.362913,64 +9.364369,64 +9.365725,64 +9.367172,64 +9.368738,64 +9.370164,64 +9.371615,64 +9.373081,64 +9.374535,64 +9.375990,64 +9.377418,64 +9.378865,64 +9.380317,64 +9.381765,64 +9.383209,64 +9.384665,64 +9.386121,64 +9.387568,64 +9.389007,64 +9.390470,64 +9.391918,64 +9.393351,64 +9.394797,64 +9.396262,64 +9.397738,64 +9.399180,64 +9.400630,64 +9.402064,64 +9.403487,64 +9.404941,64 +9.406381,64 +9.407842,64 +9.409296,64 +9.410756,64 +9.412224,64 +9.413710,64 +9.415169,64 +9.416636,64 +9.417968,64 +9.419533,64 +9.420874,64 +9.422329,64 +9.423879,64 +9.425312,64 +9.426772,64 +9.428229,64 +9.429679,64 +9.431145,64 +9.432589,64 +9.434040,64 +9.435482,64 +9.436855,64 +9.438300,64 +9.439769,64 +9.441203,64 +9.442647,64 +9.444191,64 +9.445609,64 +9.447020,64 +9.448566,64 +9.449980,64 +9.451419,64 +9.452859,64 +9.454322,64 +9.455791,64 +9.457143,64 +9.458714,64 +9.460157,64 +9.461609,64 +9.463064,64 +9.464528,64 +9.465883,64 +9.467348,64 +9.468784,64 +9.470217,64 +9.471673,64 +9.473136,64 +9.474594,64 +9.476053,64 +9.477517,64 +9.478995,64 +9.480439,64 +9.481902,64 +9.483362,64 +9.484800,64 +9.486240,64 +9.487694,64 +9.489172,64 +9.490640,64 +9.492106,64 +9.493561,64 +9.495001,64 +9.496452,64 +9.497898,64 +9.499242,64 +9.500805,64 +9.502262,64 +9.503595,64 +9.505049,64 +9.506603,64 +9.508027,64 +9.509497,64 +9.510913,64 +9.512392,64 +9.513832,64 +9.515280,64 +9.516730,64 +9.518171,64 +9.519627,64 +9.521074,64 +9.522507,64 +9.523997,64 +9.525437,64 +9.526917,64 +9.528381,64 +9.529705,64 +9.531157,64 +9.532730,64 +9.534182,64 +9.535625,64 +9.536982,64 +9.538437,64 +9.539982,64 +9.541415,64 +9.542874,64 +9.544239,64 +9.545690,64 +9.547160,64 +9.548607,64 +9.550050,64 +9.551495,64 +9.552945,64 +9.554403,64 +9.555842,64 +9.557323,64 +9.558778,64 +9.560219,64 +9.561643,64 +9.563110,64 +9.564556,64 +9.566033,64 +9.567486,64 +9.568960,64 +9.570394,64 +9.571865,64 +9.573306,64 +9.574745,64 +9.576201,64 +9.577628,64 +9.579075,64 +9.580552,64 +9.582006,64 +9.583443,64 +9.584885,64 +9.586341,64 +9.587795,64 +9.589252,64 +9.590704,64 +9.592146,64 +9.593576,64 +9.595021,64 +9.596483,64 +9.597945,64 +9.599419,64 +9.600870,64 +9.602350,64 +9.603818,64 +9.605282,64 +9.606739,64 +9.608186,64 +9.609551,64 +9.611006,64 +9.612475,64 +9.613915,64 +9.615358,64 +9.616804,64 +9.618254,64 +9.619697,64 +9.621153,64 +9.622597,64 +9.624147,64 +9.625592,64 +9.627043,64 +9.628475,64 +9.629905,64 +9.631338,64 +9.632759,64 +9.634316,64 +9.635740,64 +9.637365,64 +9.639049,64 +9.640329,64 +9.641669,64 +9.644761,128 +9.647822,128 +9.648787,64 +9.650259,64 +9.651744,64 +9.653084,64 +9.654527,64 +9.655968,64 +9.657430,64 +9.658976,64 +9.660438,64 +9.661832,64 +9.663325,64 +9.664793,64 +9.666131,64 +9.667590,64 +9.669055,64 +9.670521,64 +9.671989,64 +9.673440,64 +9.674894,64 +9.676363,64 +9.677806,64 +9.679260,64 +9.680718,64 +9.682196,64 +9.683656,64 +9.685115,64 +9.686444,64 +9.687912,64 +9.689367,64 +9.690825,64 +9.692268,64 +9.693701,64 +9.695289,64 +9.696620,64 +9.698101,64 +9.699770,64 +9.701011,64 +9.702777,64 +9.703909,64 +9.705347,64 +9.706799,64 +9.708449,64 +9.709975,64 +9.711181,64 +9.712688,64 +9.714101,64 +9.715518,64 +9.717033,64 +9.718472,64 +9.719908,64 +9.721375,64 +9.722725,64 +9.724226,64 +9.725687,64 +9.727138,64 +9.728581,64 +9.730083,64 +9.731514,64 +9.732946,64 +9.734379,64 +9.735823,64 +9.737282,64 +9.738741,64 +9.740200,64 +9.741636,64 +9.743085,64 +9.744555,64 +9.746013,64 +9.747453,64 +9.748910,64 +9.750361,64 +9.751813,64 +9.753264,64 +9.754747,64 +9.756202,64 +9.757587,64 +9.759065,64 +9.760535,64 +9.761989,64 +9.763481,64 +9.764905,64 +9.766363,64 +9.767799,64 +9.769272,64 +9.770735,64 +9.772069,64 +9.773523,64 +9.774965,64 +9.776544,64 +9.777996,64 +9.779454,64 +9.780890,64 +9.782348,64 +9.783782,64 +9.785121,64 +9.786686,64 +9.788157,64 +9.789599,64 +9.791043,64 +9.792490,64 +9.793919,64 +9.795368,64 +9.796833,64 +9.798293,64 +9.799631,64 +9.801217,64 +9.802573,64 +9.804025,64 +9.805467,64 +9.806938,64 +9.808395,64 +9.809860,64 +9.811302,64 +9.812769,64 +9.814229,64 +9.815682,64 +9.817144,64 +9.818597,64 +9.820081,64 +9.821409,64 +9.822998,64 +9.824410,64 +9.825821,64 +9.827238,64 +9.828686,64 +9.830159,64 +9.831629,64 +9.833091,64 +9.834540,64 +9.836023,64 +9.837483,64 +9.838950,64 +9.840299,64 +9.841771,64 +9.843255,64 +9.844717,64 +9.846168,64 +9.847631,64 +9.849098,64 +9.850525,64 +9.851968,64 +9.853407,64 +9.854893,64 +9.856329,64 +9.857785,64 +9.859238,64 +9.860678,64 +9.862159,64 +9.863515,64 +9.864982,64 +9.866454,64 +9.867912,64 +9.869375,64 +9.870815,64 +9.872295,64 +9.873739,64 +9.875184,64 +9.876625,64 +9.878084,64 +9.879534,64 +9.880982,64 +9.882434,64 +9.883882,64 +9.885323,64 +9.886754,64 +9.888201,64 +9.889647,64 +9.891096,64 +9.892551,64 +9.893986,64 +9.895419,64 +9.896995,64 +9.898440,64 +9.899769,64 +9.901245,64 +9.902677,64 +9.904248,64 +9.905695,64 +9.907040,64 +9.908609,64 +9.910052,64 +9.911481,64 +9.912915,64 +9.914374,64 +9.915855,64 +9.917307,64 +9.918743,64 +9.920203,64 +9.921665,64 +9.922994,64 +9.924571,64 +9.926007,64 +9.927361,64 +9.928838,64 +9.930318,64 +9.931777,64 +9.933241,64 +9.934706,64 +9.936132,64 +9.937595,64 +9.939054,64 +9.940523,64 +9.941969,64 +9.943397,64 +9.944833,64 +9.946276,64 +9.947705,64 +9.949202,64 +9.950671,64 +9.952139,64 +9.953469,64 +9.954945,64 +9.956419,64 +9.957868,64 +9.959343,64 +9.960782,64 +9.962229,64 +9.963679,64 +9.965132,64 +9.966581,64 +9.968025,64 +9.969472,64 +9.970939,64 +9.972395,64 +9.973863,64 +9.975322,64 +9.976786,64 +9.978174,64 +9.979605,64 +9.981060,64 +9.982517,64 +9.983972,64 +9.985423,64 +9.986874,64 +9.988339,64 +9.989779,64 +9.991216,64 +9.992653,64 +9.994129,64 +9.995577,64 +9.997031,64 +9.998476,64 +9.999925,64 +10.001384,64 +10.002867,64 +10.004326,64 +10.005811,64 +10.007281,64 +10.008624,64 +10.010193,64 +10.011643,64 +10.012971,64 +10.014532,64 +10.015878,64 +10.017328,64 +10.018788,64 +10.020260,64 +10.021705,64 +10.023248,64 +10.024699,64 +10.026147,64 +10.027594,64 +10.029059,64 +10.030489,64 +10.031938,64 +10.033404,64 +10.034838,64 +10.036279,64 +10.037740,64 +10.039176,64 +10.040642,64 +10.042091,64 +10.043522,64 +10.044963,64 +10.046428,64 +10.047873,64 +10.049339,64 +10.050791,64 +10.052234,64 +10.053678,64 +10.055117,64 +10.056592,64 +10.058033,64 +10.059460,64 +10.060916,64 +10.062360,64 +10.063807,64 +10.065255,64 +10.066720,64 +10.068171,64 +10.069641,64 +10.071076,64 +10.072506,64 +10.073959,64 +10.075411,64 +10.076858,64 +10.078312,64 +10.079754,64 +10.081205,64 +10.082665,64 +10.084121,64 +10.085569,64 +10.087009,64 +10.088459,64 +10.089939,64 +10.091385,64 +10.092819,64 +10.094254,64 +10.095708,64 +10.097163,64 +10.098605,64 +10.100059,64 +10.101506,64 +10.103066,64 +10.104517,64 +10.105886,64 +10.107359,64 +10.108790,64 +10.110244,64 +10.111684,64 +10.113132,64 +10.114556,64 +10.116008,64 +10.117575,64 +10.119022,64 +10.120470,64 +10.121928,64 +10.123380,64 +10.124842,64 +10.126276,64 +10.127727,64 +10.129069,64 +10.130537,64 +10.131992,64 +10.133450,64 +10.134899,64 +10.136365,64 +10.137803,64 +10.139245,64 +10.140714,64 +10.142189,64 +10.143641,64 +10.145116,64 +10.146488,64 +10.147960,64 +10.149418,64 +10.150876,64 +10.152323,64 +10.153768,64 +10.155237,64 +10.156694,64 +10.158150,64 +10.159626,64 +10.161061,64 +10.162513,64 +10.163981,64 +10.165438,64 +10.166883,64 +10.168340,64 +10.169802,64 +10.171274,64 +10.172746,64 +10.174083,64 +10.175521,64 +10.176973,64 +10.178434,64 +10.179896,64 +10.181363,64 +10.182830,64 +10.184281,64 +10.185721,64 +10.187204,64 +10.188641,64 +10.190083,64 +10.191532,64 +10.192980,64 +10.194434,64 +10.195887,64 +10.197342,64 +10.198774,64 +10.200239,64 +10.201700,64 +10.203147,64 +10.204593,64 +10.206048,64 +10.207508,64 +10.208953,64 +10.210413,64 +10.211861,64 +10.213303,64 +10.214762,64 +10.216206,64 +10.217647,64 +10.219100,64 +10.220544,64 +10.221996,64 +10.223444,64 +10.224904,64 +10.226350,64 +10.227792,64 +10.229246,64 +10.230699,64 +10.232163,64 +10.233615,64 +10.235055,64 +10.236514,64 +10.237978,64 +10.239430,64 +10.240870,64 +10.242312,64 +10.243759,64 +10.245220,64 +10.246684,64 +10.248129,64 +10.249590,64 +10.251044,64 +10.252500,64 +10.253972,64 +10.255431,64 +10.256784,64 +10.258237,64 +10.259699,64 +10.261171,64 +10.262613,64 +10.264055,64 +10.265522,64 +10.266971,64 +10.268417,64 +10.269849,64 +10.271288,64 +10.272764,64 +10.274237,64 +10.275686,64 +10.277121,64 +10.278575,64 +10.280027,64 +10.281472,64 +10.282922,64 +10.284375,64 +10.285922,64 +10.287373,64 +10.288828,64 +10.290179,64 +10.291636,64 +10.293079,64 +10.294636,64 +10.296085,64 +10.297540,64 +10.298972,64 +10.300432,64 +10.301884,64 +10.303337,64 +10.304687,64 +10.306143,64 +10.307617,64 +10.309096,64 +10.310561,64 +10.312046,64 +10.313383,64 +10.314885,64 +10.316331,64 +10.317813,64 +10.319266,64 +10.320721,64 +10.322194,64 +10.323664,64 +10.324986,64 +10.326455,64 +10.327891,64 +10.329459,64 +10.330921,64 +10.332254,64 +10.333816,64 +10.335258,64 +10.336595,64 +10.338166,64 +10.339505,64 +10.340955,64 +10.342403,64 +10.343853,64 +10.345312,64 +10.346788,64 +10.348270,64 +10.349703,64 +10.351141,64 +10.352601,64 +10.354034,64 +10.355492,64 +10.356943,64 +10.358388,64 +10.359828,64 +10.361272,64 +10.362762,64 +10.364202,64 +10.365669,64 +10.367120,64 +10.368562,64 +10.370045,64 +10.371508,64 +10.372967,64 +10.374415,64 +10.375853,64 +10.377292,64 +10.378738,64 +10.380173,64 +10.381604,64 +10.383069,64 +10.384518,64 +10.385977,64 +10.387424,64 +10.388866,64 +10.390337,64 +10.391796,64 +10.393220,64 +10.394649,64 +10.396107,64 +10.397591,64 +10.399074,64 +10.400522,64 +10.401964,64 +10.403410,64 +10.404851,64 +10.406327,64 +10.407779,64 +10.409224,64 +10.410682,64 +10.412138,64 +10.413587,64 +10.415037,64 +10.416503,64 +10.417942,64 +10.419419,64 +10.420897,64 +10.422213,64 +10.423691,64 +10.425151,64 +10.426581,64 +10.428134,64 +10.429474,64 +10.431042,64 +10.432498,64 +10.433948,64 +10.435391,64 +10.436726,64 +10.438313,64 +10.439673,64 +10.441151,64 +10.442588,64 +10.444022,64 +10.445479,64 +10.446931,64 +10.448365,64 +10.449817,64 +10.451271,64 +10.452720,64 +10.454193,64 +10.455639,64 +10.457072,64 +10.458541,64 +10.459976,64 +10.461433,64 +10.462893,64 +10.464342,64 +10.465787,64 +10.467227,64 +10.468662,64 +10.470243,64 +10.471578,64 +10.473011,64 +10.474487,64 +10.475947,64 +10.477428,64 +10.478887,64 +10.480346,64 +10.481785,64 +10.483236,64 +10.484665,64 +10.486128,64 +10.487583,64 +10.489039,64 +10.490484,64 +10.491911,64 +10.493364,64 +10.494832,64 +10.496288,64 +10.497747,64 +10.499199,64 +10.500669,64 +10.502126,64 +10.503584,64 +10.505032,64 +10.506490,64 +10.507957,64 +10.509400,64 +10.510856,64 +10.512294,64 +10.513769,64 +10.515115,64 +10.516583,64 +10.518078,64 +10.519543,64 +10.520984,64 +10.522438,64 +10.523918,64 +10.525382,64 +10.526829,64 +10.528266,64 +10.529631,64 +10.531105,64 +10.532537,64 +10.534007,64 +10.535488,64 +10.536963,64 +10.538407,64 +10.539836,64 +10.541277,64 +10.542708,64 +10.544150,64 +10.545598,64 +10.547074,64 +10.548535,64 +10.549960,64 +10.551412,64 +10.552846,64 +10.554396,64 +10.555737,64 +10.557203,64 +10.558645,64 +10.560201,64 +10.561650,64 +10.563102,64 +10.564542,64 +10.565982,64 +10.567451,64 +10.568886,64 +10.570336,64 +10.571798,64 +10.573268,64 +10.574710,64 +10.576159,64 +10.577588,64 +10.579079,64 +10.580533,64 +10.581972,64 +10.583307,64 +10.584752,64 +10.586328,64 +10.587782,64 +10.589220,64 +10.590680,64 +10.592138,64 +10.593583,64 +10.595043,64 +10.596371,64 +10.597833,64 +10.599389,64 +10.600831,64 +10.602269,64 +10.603705,64 +10.605146,64 +10.606587,64 +10.608041,64 +10.609472,64 +10.610941,64 +10.612398,64 +10.613847,64 +10.615290,64 +10.616718,64 +10.618169,64 +10.619625,64 +10.621086,64 +10.622522,64 +10.623982,64 +10.625422,64 +10.626869,64 +10.628317,64 +10.629868,64 +10.631333,64 +10.632762,64 +10.634218,64 +10.635676,64 +10.637127,64 +10.638568,64 +10.639909,64 +10.641370,64 +10.642856,64 +10.644332,64 +10.645796,64 +10.647242,64 +10.648687,64 +10.650163,64 +10.651606,64 +10.653063,64 +10.654500,64 +10.655931,64 +10.657397,64 +10.658807,64 +10.660257,64 +10.661701,64 +10.663158,64 +10.664618,64 +10.666072,64 +10.667535,64 +10.668991,64 +10.670415,64 +10.671865,64 +10.673319,64 +10.674763,64 +10.676204,64 +10.677678,64 +10.679116,64 +10.680566,64 +10.682013,64 +10.683469,64 +10.684941,64 +10.686384,64 +10.687865,64 +10.689305,64 +10.690767,64 +10.692202,64 +10.693642,64 +10.695087,64 +10.696573,64 +10.698017,64 +10.699466,64 +10.700911,64 +10.702351,64 +10.703799,64 +10.705244,64 +10.706694,64 +10.708124,64 +10.709677,64 +10.711134,64 +10.712469,64 +10.713949,64 +10.715392,64 +10.716829,64 +10.718402,64 +10.719840,64 +10.721179,64 +10.722739,64 +10.724189,64 +10.725625,64 +10.727056,64 +10.728483,64 +10.729935,64 +10.731389,64 +10.732847,64 +10.734287,64 +10.735730,64 +10.737161,64 +10.738611,64 +10.740110,64 +10.741561,64 +10.742996,64 +10.744454,64 +10.745947,64 +10.747414,64 +10.748758,64 +10.750224,64 +10.751666,64 +10.753107,64 +10.754675,64 +10.756125,64 +10.757453,64 +10.759022,64 +10.760452,64 +10.761909,64 +10.763352,64 +10.764807,64 +10.766278,64 +10.767728,64 +10.769178,64 +10.770610,64 +10.772068,64 +10.773509,64 +10.774948,64 +10.776399,64 +10.777864,64 +10.779314,64 +10.780757,64 +10.782239,64 +10.783596,64 +10.785027,64 +10.786477,64 +10.787940,64 +10.789404,64 +10.790869,64 +10.792310,64 +10.793748,64 +10.795199,64 +10.796654,64 +10.798105,64 +10.799552,64 +10.801017,64 +10.802464,64 +10.803910,64 +10.805372,64 +10.806845,64 +10.808302,64 +10.809764,64 +10.811284,64 +10.812622,64 +10.814175,64 +10.815511,64 +10.816966,64 +10.818521,64 +10.819853,64 +10.821437,64 +10.822755,64 +10.824317,64 +10.825740,64 +10.827185,64 +10.828639,64 +10.830097,64 +10.831559,64 +10.832992,64 +10.834441,64 +10.835895,64 +10.837348,64 +10.838796,64 +10.840243,64 +10.841687,64 +10.843168,64 +10.844641,64 +10.845981,64 +10.847544,64 +10.848889,64 +10.850361,64 +10.851818,64 +10.853270,64 +10.854721,64 +10.856198,64 +10.857631,64 +10.859053,64 +10.860603,64 +10.862050,64 +10.863423,64 +10.864873,64 +10.866320,64 +10.867761,64 +10.869222,64 +10.870656,64 +10.872125,64 +10.873586,64 +10.875026,64 +10.876475,64 +10.877927,64 +10.879384,64 +10.880824,64 +10.882268,64 +10.883710,64 +10.885268,64 +10.886698,64 +10.888175,64 +10.889641,64 +10.890965,64 +10.892553,64 +10.893901,64 +10.895373,64 +10.896812,64 +10.898277,64 +10.899703,64 +10.901242,64 +10.902671,64 +10.904100,64 +10.905528,64 +10.906979,64 +10.908383,64 +10.909919,64 +10.911353,64 +10.912793,64 +10.914240,64 +10.915692,64 +10.917134,64 +10.918596,64 +10.920039,64 +10.921492,64 +10.922958,64 +10.924394,64 +10.925826,64 +10.927392,64 +10.928719,64 +10.930250,64 +10.931601,64 +10.933063,64 +10.934509,64 +10.936067,64 +10.937417,64 +10.938880,64 +10.940359,64 +10.941809,64 +10.943255,64 +10.944688,64 +10.946145,64 +10.947599,64 +10.949032,64 +10.950497,64 +10.952018,64 +10.953467,64 +10.954922,64 +10.956392,64 +10.957847,64 +10.959278,64 +10.960728,64 +10.962183,64 +10.963530,64 +10.964996,64 +10.966562,64 +10.967903,64 +10.969349,64 +10.970851,64 +10.972310,64 +10.973781,64 +10.975237,64 +10.976693,64 +10.978177,64 +10.979511,64 +10.980963,64 +10.982424,64 +10.983921,64 +10.985356,64 +10.986802,64 +10.988269,64 +10.989741,64 +10.991187,64 +10.992628,64 +10.994066,64 +10.995543,64 +10.997006,64 +10.998445,64 +10.999899,64 +11.001359,64 +11.002793,64 +11.004252,64 +11.005738,64 +11.007085,64 +11.008531,64 +11.010009,64 +11.011467,64 +11.012920,64 +11.014373,64 +11.015819,64 +11.017257,64 +11.018705,64 +11.020149,64 +11.021654,64 +11.023122,64 +11.024564,64 +11.026024,64 +11.027477,64 +11.028936,64 +11.030403,64 +11.031740,64 +11.033198,64 +11.034668,64 +11.036139,64 +11.037587,64 +11.039028,64 +11.040508,64 +11.041955,64 +11.043403,64 +11.044845,64 +11.046286,64 +11.047739,64 +11.049171,64 +11.050618,64 +11.052086,64 +11.053536,64 +11.055015,64 +11.056466,64 +11.057928,64 +11.059359,64 +11.060841,64 +11.062288,64 +11.063723,64 +11.065173,64 +11.066623,64 +11.068081,64 +11.069513,64 +11.070969,64 +11.072434,64 +11.073891,64 +11.075328,64 +11.076761,64 +11.078190,64 +11.079645,64 +11.081092,64 +11.082553,64 +11.084013,64 +11.085464,64 +11.086899,64 +11.088362,64 +11.089840,64 +11.091311,64 +11.092751,64 +11.094216,64 +11.095673,64 +11.097126,64 +11.098591,64 +11.100023,64 +11.101502,64 +11.102948,64 +11.104307,64 +11.105774,64 +11.107238,64 +11.108676,64 +11.110116,64 +11.111585,64 +11.113059,64 +11.114498,64 +11.115939,64 +11.117382,64 +11.118821,64 +11.120287,64 +11.121745,64 +11.123187,64 +11.124646,64 +11.126102,64 +11.127542,64 +11.128982,64 +11.130468,64 +11.131915,64 +11.133338,64 +11.134796,64 +11.136261,64 +11.137699,64 +11.139167,64 +11.140650,64 +11.142099,64 +11.143478,64 +11.144932,64 +11.146380,64 +11.147847,64 +11.149301,64 +11.150747,64 +11.152312,64 +11.153745,64 +11.155182,64 +11.156641,64 +11.158070,64 +11.159514,64 +11.160958,64 +11.162385,64 +11.163847,64 +11.165289,64 +11.166737,64 +11.168182,64 +11.169625,64 +11.171102,64 +11.172539,64 +11.174009,64 +11.175467,64 +11.176913,64 +11.178351,64 +11.179801,64 +11.181253,64 +11.182699,64 +11.184141,64 +11.185601,64 +11.187046,64 +11.188489,64 +11.189934,64 +11.191482,64 +11.192940,64 +11.194273,64 +11.195725,64 +11.197199,64 +11.198641,64 +11.200081,64 +11.201645,64 +11.203110,64 +11.204464,64 +11.205908,64 +11.207390,64 +11.208852,64 +11.210319,64 +11.211775,64 +11.213236,64 +11.214716,64 +11.216061,64 +11.217534,64 +11.218989,64 +11.220467,64 +11.221904,64 +11.223366,64 +11.224807,64 +11.226267,64 +11.227711,64 +11.229165,64 +11.230607,64 +11.232058,64 +11.233509,64 +11.234946,64 +11.236400,64 +11.237849,64 +11.239288,64 +11.240758,64 +11.242219,64 +11.243651,64 +11.245095,64 +11.246570,64 +11.248017,64 +11.249480,64 +11.250922,64 +11.252374,64 +11.253819,64 +11.255261,64 +11.256735,64 +11.258179,64 +11.259614,64 +11.261071,64 +11.262528,64 +11.264003,64 +11.265465,64 +11.266908,64 +11.268348,64 +11.269834,64 +11.271270,64 +11.272725,64 +11.274164,64 +11.275612,64 +11.277063,64 +11.278514,64 +11.279969,64 +11.281452,64 +11.282884,64 +11.284339,64 +11.285786,64 +11.287226,64 +11.288676,64 +11.290135,64 +11.291595,64 +11.293023,64 +11.294464,64 +11.295903,64 +11.297353,64 +11.298819,64 +11.300277,64 +11.301754,64 +11.303197,64 +11.304645,64 +11.306084,64 +11.307549,64 +11.309006,64 +11.310459,64 +11.311928,64 +11.313390,64 +11.314869,64 +11.316189,64 +11.317655,64 +11.319122,64 +11.320593,64 +11.322071,64 +11.323537,64 +11.325000,64 +11.326435,64 +11.327877,64 +11.329309,64 +11.330778,64 +11.332229,64 +11.333669,64 +11.335099,64 +11.336548,64 +11.337993,64 +11.339430,64 +11.340883,64 +11.342348,64 +11.343817,64 +11.345266,64 +11.346711,64 +11.348187,64 +11.349630,64 +11.351062,64 +11.352505,64 +11.353951,64 +11.355404,64 +11.356869,64 +11.358315,64 +11.359763,64 +11.361208,64 +11.362642,64 +11.364117,64 +11.365553,64 +11.366996,64 +11.368446,64 +11.369906,64 +11.371365,64 +11.372819,64 +11.374275,64 +11.375723,64 +11.377171,64 +11.378610,64 +11.380087,64 +11.381533,64 +11.382984,64 +11.384442,64 +11.385880,64 +11.387320,64 +11.388761,64 +11.390214,64 +11.391671,64 +11.393108,64 +11.394558,64 +11.396128,64 +11.397563,64 +11.399034,64 +11.400357,64 +11.401919,64 +11.403363,64 +11.404810,64 +11.406263,64 +11.407699,64 +11.409145,64 +11.410597,64 +11.412065,64 +11.413415,64 +11.414987,64 +11.416343,64 +11.417784,64 +11.419220,64 +11.420693,64 +11.422150,64 +11.423593,64 +11.425029,64 +11.426476,64 +11.428040,64 +11.429387,64 +11.430855,64 +11.432345,64 +11.433786,64 +11.435223,64 +11.436671,64 +11.438133,64 +11.439586,64 +11.441040,64 +11.442507,64 +11.443953,64 +11.445398,64 +11.446861,64 +11.448295,64 +11.449776,64 +11.451220,64 +11.452679,64 +11.454119,64 +11.455563,64 +11.457004,64 +11.458451,64 +11.459885,64 +11.461338,64 +11.462770,64 +11.464241,64 +11.465684,64 +11.467125,64 +11.468559,64 +11.470129,64 +11.471578,64 +11.473034,64 +11.474488,64 +11.475933,64 +11.477268,64 +11.478774,64 +11.480246,64 +11.481678,64 +11.483128,64 +11.484563,64 +11.486004,64 +11.487482,64 +11.488934,64 +11.490402,64 +11.491848,64 +11.493296,64 +11.494751,64 +11.496232,64 +11.497692,64 +11.499052,64 +11.500614,64 +11.502052,64 +11.503491,64 +11.504944,64 +11.506414,64 +11.507748,64 +11.509314,64 +11.510744,64 +11.512207,64 +11.513643,64 +11.515079,64 +11.516564,64 +11.518017,64 +11.519452,64 +11.520888,64 +11.522335,64 +11.523764,64 +11.525221,64 +11.526647,64 +11.528109,64 +11.529550,64 +11.531007,64 +11.532465,64 +11.533906,64 +11.535350,64 +11.536782,64 +11.538242,64 +11.539693,64 +11.541143,64 +11.542590,64 +11.544028,64 +11.545576,64 +11.547047,64 +11.548386,64 +11.549827,64 +11.551397,64 +11.552831,64 +11.554281,64 +11.555735,64 +11.557210,64 +11.558658,64 +11.559996,64 +11.561461,64 +11.562919,64 +11.564370,64 +11.565811,64 +11.567268,64 +11.568713,64 +11.570191,64 +11.571637,64 +11.573098,64 +11.574557,64 +11.575995,64 +11.577476,64 +11.578909,64 +11.580348,64 +11.581791,64 +11.583233,64 +11.584673,64 +11.586226,64 +11.587557,64 +11.589129,64 +11.590570,64 +11.592030,64 +11.593360,64 +11.594921,64 +11.596380,64 +11.597714,64 +11.599281,64 +11.600718,64 +11.602149,64 +11.603579,64 +11.605033,64 +11.606476,64 +11.607926,64 +11.609375,64 +11.610815,64 +11.612249,64 +11.613707,64 +11.615168,64 +11.616595,64 +11.618045,64 +11.619519,64 +11.620981,64 +11.622428,64 +11.623884,64 +11.625343,64 +11.626787,64 +11.628243,64 +11.629696,64 +11.631173,64 +11.632625,64 +11.634073,64 +11.635531,64 +11.636987,64 +11.638446,64 +11.639896,64 +11.641379,64 +11.642823,64 +11.644281,64 +11.645740,64 +11.647180,64 +11.648626,64 +11.649999,64 +11.651441,64 +11.652905,64 +11.654354,64 +11.655783,64 +11.657239,64 +11.658789,64 +11.660230,64 +11.661668,64 +11.663103,64 +11.664544,64 +11.665986,64 +11.667414,64 +11.668848,64 +11.670290,64 +11.671729,64 +11.673185,64 +11.674754,64 +11.676200,64 +11.677542,64 +11.679029,64 +11.680481,64 +11.681947,64 +11.683399,64 +11.684835,64 +11.686280,64 +11.687738,64 +11.689192,64 +11.690630,64 +11.692057,64 +11.693529,64 +11.694972,64 +11.696455,64 +11.697899,64 +11.699323,64 +11.700760,64 +11.702321,64 +11.703783,64 +11.705236,64 +11.706682,64 +11.708033,64 +11.709475,64 +11.711016,64 +11.712461,64 +11.713905,64 +11.715338,64 +11.716798,64 +11.718232,64 +11.719667,64 +11.721118,64 +11.722569,64 +11.724048,64 +11.725489,64 +11.726933,64 +11.728362,64 +11.729837,64 +11.731274,64 +11.732731,64 +11.734168,64 +11.735625,64 +11.737063,64 +11.738532,64 +11.739985,64 +11.741426,64 +11.742868,64 +11.744312,64 +11.745760,64 +11.747205,64 +11.748775,64 +11.750202,64 +11.751647,64 +11.753070,64 +11.754517,64 +11.755967,64 +11.757479,64 +11.758919,64 +11.760346,64 +11.761787,64 +11.763233,64 +11.764677,64 +11.766152,64 +11.767600,64 +11.769047,64 +11.770482,64 +11.771932,64 +11.773403,64 +11.774803,64 +11.776308,64 +11.777669,64 +11.779121,64 +11.780696,64 +11.782143,64 +11.783578,64 +11.785023,64 +11.786468,64 +11.787908,64 +11.789349,64 +11.790790,64 +11.792230,64 +11.793672,64 +11.795109,64 +11.796573,64 +11.798024,64 +11.799473,64 +11.800911,64 +11.802452,64 +11.803895,64 +11.805337,64 +11.806795,64 +11.808156,64 +11.809612,64 +11.811129,64 +11.812636,64 +11.814061,64 +11.815503,64 +11.816934,64 +11.818375,64 +11.819872,64 +11.821313,64 +11.822773,64 +11.824211,64 +11.825645,64 +11.827088,64 +11.828531,64 +11.829973,64 +11.831422,64 +11.832865,64 +11.834292,64 +11.835744,64 +11.837193,64 +11.838644,64 +11.840072,64 +11.841628,64 +11.843083,64 +11.844528,64 +11.845992,64 +11.847443,64 +11.848825,64 +11.850350,64 +11.851685,64 +11.853239,64 +11.854697,64 +11.856146,64 +11.857585,64 +11.859019,64 +11.860444,64 +11.861891,64 +11.863356,64 +11.864835,64 +11.866279,64 +11.867726,64 +11.869154,64 +11.870608,64 +11.872054,64 +11.873503,64 +11.874962,64 +11.876398,64 +11.877856,64 +11.879319,64 +11.880771,64 +11.882222,64 +11.883666,64 +11.885102,64 +11.886544,64 +11.888001,64 +11.889437,64 +11.890933,64 +11.892370,64 +11.893813,64 +11.895244,64 +11.896690,64 +11.898131,64 +11.899687,64 +11.901118,64 +11.902558,64 +11.904003,64 +11.905435,64 +11.906898,64 +11.908337,64 +11.909772,64 +11.911222,64 +11.912685,64 +11.914133,64 +11.915573,64 +11.916993,64 +11.918438,64 +11.919955,64 +11.921411,64 +11.922864,64 +11.924348,64 +11.925801,64 +11.927245,64 +11.928716,64 +11.930176,64 +11.931615,64 +11.932946,64 +11.934512,64 +11.935946,64 +11.937387,64 +11.938852,64 +11.940305,64 +11.941751,64 +11.943180,64 +11.944617,64 +11.946053,64 +11.947536,64 +11.948997,64 +11.950456,64 +11.951954,64 +11.953283,64 +11.954742,64 +11.956200,64 +11.957677,64 +11.959117,64 +11.960562,64 +11.962018,64 +11.963464,64 +11.964918,64 +11.966358,64 +11.967798,64 +11.969345,64 +11.970801,64 +11.972261,64 +11.973712,64 +11.975160,64 +11.976615,64 +11.977947,64 +11.979466,64 +11.980921,64 +11.982392,64 +11.983837,64 +11.985284,64 +11.986747,64 +11.988225,64 +11.989577,64 +11.991031,64 +11.992474,64 +11.993925,64 +11.995399,64 +11.996843,64 +11.998290,64 +11.999759,64 +12.001190,64 +12.002637,64 +12.004076,64 +12.005534,64 +12.006994,64 +12.008439,64 +12.009912,64 +12.011341,64 +12.012797,64 +12.014263,64 +12.015758,64 +12.017212,64 +12.018676,64 +12.020123,64 +12.021477,64 +12.023050,64 +12.024479,64 +12.025927,64 +12.027371,64 +12.028825,64 +12.030286,64 +12.031728,64 +12.033180,64 +12.034608,64 +12.036050,64 +12.037494,64 +12.038936,64 +12.040376,64 +12.041856,64 +12.043301,64 +12.044739,64 +12.046183,64 +12.047631,64 +12.049099,64 +12.050549,64 +12.051982,64 +12.053425,64 +12.054867,64 +12.056314,64 +12.057788,64 +12.059238,64 +12.060692,64 +12.062153,64 +12.063610,64 +12.065061,64 +12.066483,64 +12.067918,64 +12.069377,64 +12.070828,64 +12.072271,64 +12.073735,64 +12.075216,64 +12.076675,64 +12.078107,64 +12.079571,64 +12.081032,64 +12.082484,64 +12.083923,64 +12.085391,64 +12.086863,64 +12.088306,64 +12.089779,64 +12.091230,64 +12.092666,64 +12.094101,64 +12.095574,64 +12.097057,64 +12.098521,64 +12.099959,64 +12.101419,64 +12.102874,64 +12.104319,64 +12.105752,64 +12.107188,64 +12.108625,64 +12.110069,64 +12.111527,64 +12.112973,64 +12.114451,64 +12.115880,64 +12.117333,64 +12.118771,64 +12.120202,64 +12.121645,64 +12.123084,64 +12.124527,64 +12.126009,64 +12.127460,64 +12.128913,64 +12.130373,64 +12.131819,64 +12.133253,64 +12.134698,64 +12.136139,64 +12.137580,64 +12.139036,64 +12.140525,64 +12.141982,64 +12.143460,64 +12.144859,64 +12.146305,64 +12.147764,64 +12.149215,64 +12.150669,64 +12.152109,64 +12.153566,64 +12.155017,64 +12.156466,64 +12.157923,64 +12.159358,64 +12.160801,64 +12.162354,64 +12.163807,64 +12.165147,64 +12.166599,64 +12.168167,64 +12.169629,64 +12.171073,64 +12.172513,64 +12.173963,64 +12.175426,64 +12.176870,64 +12.178321,64 +12.179783,64 +12.181249,64 +12.182581,64 +12.184037,64 +12.185488,64 +12.186936,64 +12.188394,64 +12.189853,64 +12.191301,64 +12.192754,64 +12.194211,64 +12.195651,64 +12.197133,64 +12.198602,64 +12.200057,64 +12.201504,64 +12.202956,64 +12.204443,64 +12.205891,64 +12.207245,64 +12.208695,64 +12.210277,64 +12.211712,64 +12.213155,64 +12.214615,64 +12.216050,64 +12.217485,64 +12.218932,64 +12.220375,64 +12.221853,64 +12.223308,64 +12.224772,64 +12.226203,64 +12.227653,64 +12.229128,64 +12.230560,64 +12.232017,64 +12.233461,64 +12.234922,64 +12.236357,64 +12.237827,64 +12.239284,64 +12.240715,64 +12.242155,64 +12.243628,64 +12.245070,64 +12.246534,64 +12.247994,64 +12.249436,64 +12.250773,64 +12.252335,64 +12.253772,64 +12.255197,64 +12.256669,64 +12.258096,64 +12.259546,64 +12.260988,64 +12.262461,64 +12.263888,64 +12.265320,64 +12.266754,64 +12.268215,64 +12.269645,64 +12.271086,64 +12.272557,64 +12.274051,64 +12.275507,64 +12.276968,64 +12.278428,64 +12.279879,64 +12.281336,64 +12.282799,64 +12.284228,64 +12.285672,64 +12.287112,64 +12.288564,64 +12.290036,64 +12.291487,64 +12.292932,64 +12.294372,64 +12.295836,64 +12.297277,64 +12.298714,64 +12.300184,64 +12.301631,64 +12.303081,64 +12.304544,64 +12.305981,64 +12.307466,64 +12.308924,64 +12.310372,64 +12.311741,64 +12.313213,64 +12.314696,64 +12.316143,64 +12.317586,64 +12.319036,64 +12.320523,64 +12.321968,64 +12.323441,64 +12.324896,64 +12.326327,64 +12.327774,64 +12.329267,64 +12.330705,64 +12.332165,64 +12.333610,64 +12.335074,64 +12.336514,64 +12.337956,64 +12.339398,64 +12.340855,64 +12.342297,64 +12.343762,64 +12.345200,64 +12.346679,64 +12.348046,64 +12.349493,64 +12.350961,64 +12.352405,64 +12.353896,64 +12.355356,64 +12.356827,64 +12.358268,64 +12.359708,64 +12.361180,64 +12.362643,64 +12.364083,64 +12.365522,64 +12.366962,64 +12.368393,64 +12.369853,64 +12.371336,64 +12.372780,64 +12.374247,64 +12.375700,64 +12.377024,64 +12.378624,64 +12.379968,64 +12.381416,64 +12.382856,64 +12.384292,64 +12.385869,64 +12.387320,64 +12.388651,64 +12.390104,64 +12.391544,64 +12.393117,64 +12.394452,64 +12.395895,64 +12.397467,64 +12.398918,64 +12.400249,64 +12.401817,64 +12.403277,64 +12.404730,64 +12.406175,64 +12.407623,64 +12.409058,64 +12.410403,64 +12.411874,64 +12.413447,64 +12.414779,64 +12.416235,64 +12.417669,64 +12.419246,64 +12.420691,64 +12.422135,64 +12.423595,64 +12.425071,64 +12.426405,64 +12.427863,64 +12.429310,64 +12.430753,64 +12.432221,64 +12.433673,64 +12.435106,64 +12.436594,64 +12.438042,64 +12.439503,64 +12.440956,64 +12.442392,64 +12.443842,64 +12.445273,64 +12.446722,64 +12.448201,64 +12.449651,64 +12.451101,64 +12.452563,64 +12.454007,64 +12.455468,64 +12.456922,64 +12.458392,64 +12.459846,64 +12.461324,64 +12.462768,64 +12.464217,64 +12.465669,64 +12.467104,64 +12.468553,64 +12.469989,64 +12.471471,64 +12.472919,64 +12.474273,64 +12.475727,64 +12.477209,64 +12.478689,64 +12.480145,64 +12.481619,64 +12.483073,64 +12.484523,64 +12.485967,64 +12.487450,64 +12.488888,64 +12.490331,64 +12.491755,64 +12.493211,64 +12.494658,64 +12.496124,64 +12.497576,64 +12.499018,64 +12.500464,64 +12.501908,64 +12.503342,64 +12.504811,64 +12.506289,64 +12.507736,64 +12.509198,64 +12.510645,64 +12.512078,64 +12.513525,64 +12.514966,64 +12.516415,64 +12.517858,64 +12.519285,64 +12.520748,64 +12.522189,64 +12.523637,64 +12.525112,64 +12.526560,64 +12.528051,64 +12.529525,64 +12.530981,64 +12.532442,64 +12.533873,64 +12.535317,64 +12.536775,64 +12.538233,64 +12.539691,64 +12.541019,64 +12.542598,64 +12.544036,64 +12.545483,64 +12.546846,64 +12.548336,64 +12.549805,64 +12.551178,64 +12.552743,64 +12.554096,64 +12.555565,64 +12.557036,64 +12.558493,64 +12.559957,64 +12.561402,64 +12.562884,64 +12.564358,64 +12.565813,64 +12.567151,64 +12.568714,64 +12.570165,64 +12.571496,64 +12.572958,64 +12.574416,64 +12.575871,64 +12.577421,64 +12.578760,64 +12.580210,64 +12.581660,64 +12.583106,64 +12.584679,64 +12.586129,64 +12.587562,64 +12.589006,64 +12.590458,64 +12.591897,64 +12.593342,64 +12.594787,64 +12.596229,64 +12.597690,64 +12.599158,64 +12.600593,64 +12.602045,64 +12.603537,64 +12.604995,64 +12.606441,64 +12.607778,64 +12.609344,64 +12.610797,64 +12.612258,64 +12.613711,64 +12.615056,64 +12.616499,64 +12.617947,64 +12.619501,64 +12.620881,64 +12.622341,64 +12.623783,64 +12.625235,64 +12.626686,64 +12.628139,64 +12.629576,64 +12.631020,64 +12.632478,64 +12.633940,64 +12.635401,64 +12.636841,64 +12.638298,64 +12.639758,64 +12.641222,64 +12.642667,64 +12.644148,64 +12.645613,64 +12.647042,64 +12.648477,64 +12.649922,64 +12.651338,64 +12.652889,64 +12.654335,64 +12.655779,64 +12.657234,64 +12.658665,64 +12.660101,64 +12.661533,64 +12.662989,64 +12.664468,64 +12.665833,64 +12.667302,64 +12.668735,64 +12.670296,64 +12.671633,64 +12.673198,64 +12.674631,64 +12.676071,64 +12.677504,64 +12.678963,64 +12.680455,64 +12.681893,64 +12.683237,64 +12.684696,64 +12.686260,64 +12.687713,64 +12.689162,64 +12.690627,64 +12.691972,64 +12.693436,64 +12.694885,64 +12.696348,64 +12.697807,64 +12.699248,64 +12.700708,64 +12.702135,64 +12.703675,64 +12.705100,64 +12.706586,64 +12.708026,64 +12.709457,64 +12.710937,64 +12.712371,64 +12.713822,64 +12.715270,64 +12.716747,64 +12.718177,64 +12.719623,64 +12.721078,64 +12.722535,64 +12.723995,64 +12.725455,64 +12.726785,64 +12.728360,64 +12.729796,64 +12.731256,64 +12.732581,64 +12.734153,64 +12.735581,64 +12.737015,64 +12.738456,64 +12.739884,64 +12.741325,64 +12.742795,64 +12.744222,64 +12.745680,64 +12.747123,64 +12.748581,64 +12.750034,64 +12.751506,64 +12.752956,64 +12.754411,64 +12.755857,64 +12.757304,64 +12.758738,64 +12.760188,64 +12.761631,64 +12.763119,64 +12.764588,64 +12.766071,64 +12.767435,64 +12.768873,64 +12.770445,64 +12.771775,64 +12.773242,64 +12.774701,64 +12.776155,64 +12.777593,64 +12.779056,64 +12.780513,64 +12.781967,64 +12.783417,64 +12.784878,64 +12.786333,64 +12.787786,64 +12.789228,64 +12.790684,64 +12.792128,64 +12.793628,64 +12.795063,64 +12.796541,64 +12.797980,64 +12.799413,64 +12.800862,64 +12.802297,64 +12.803747,64 +12.805200,64 +12.806631,64 +12.808072,64 +12.809533,64 +12.811034,64 +12.812501,64 +12.813972,64 +12.815409,64 +12.816847,64 +12.818291,64 +12.819742,64 +12.821210,64 +12.822644,64 +12.824114,64 +12.825551,64 +12.826994,64 +12.828460,64 +12.829910,64 +12.831365,64 +12.832731,64 +12.834284,64 +12.835725,64 +12.837183,64 +12.838524,64 +12.840111,64 +12.841549,64 +12.842995,64 +12.844443,64 +12.845879,64 +12.847332,64 +12.848811,64 +12.850243,64 +12.851704,64 +12.853034,64 +12.854489,64 +12.855947,64 +12.857401,64 +12.858955,64 +12.860398,64 +12.861846,64 +12.863206,64 +12.864663,64 +12.866115,64 +12.867573,64 +12.869023,64 +12.870481,64 +12.871969,64 +12.873347,64 +12.874826,64 +12.876370,64 +12.877819,64 +12.879167,64 +12.880622,64 +12.882088,64 +12.883528,64 +12.884989,64 +12.886439,64 +12.887903,64 +12.889370,64 +12.890855,64 +12.892293,64 +12.893734,64 +12.895184,64 +12.896639,64 +12.898081,64 +12.899519,64 +12.900981,64 +12.902423,64 +12.903854,64 +12.905306,64 +12.906760,64 +12.908204,64 +12.909647,64 +12.911123,64 +12.912578,64 +12.914034,64 +12.915482,64 +12.916922,64 +12.918415,64 +12.919863,64 +12.921304,64 +12.922761,64 +12.924193,64 +12.925655,64 +12.927089,64 +12.928525,64 +12.930012,64 +12.931466,64 +12.932925,64 +12.934359,64 +12.935823,64 +12.937290,64 +12.938771,64 +12.940210,64 +12.941647,64 +12.943074,64 +12.944518,64 +12.945969,64 +12.947422,64 +12.948882,64 +12.950337,64 +12.951786,64 +12.953282,64 +12.954712,64 +12.956152,64 +12.957594,64 +12.959046,64 +12.960527,64 +12.961979,64 +12.963437,64 +12.964912,64 +12.966242,64 +12.967696,64 +12.969136,64 +12.970619,64 +12.972088,64 +12.973550,64 +12.975027,64 +12.976467,64 +12.977901,64 +12.979413,64 +12.980762,64 +12.982229,64 +12.983695,64 +12.985147,64 +12.986590,64 +12.988040,64 +12.989486,64 +12.990934,64 +12.992375,64 +12.993923,64 +12.995385,64 +12.996826,64 +12.998284,64 +12.999614,64 +13.001174,64 +13.002515,64 +13.004090,64 +13.005436,64 +13.006916,64 +13.008373,64 +13.009812,64 +13.011284,64 +13.012746,64 +13.014204,64 +13.015658,64 +13.017127,64 +13.018574,64 +13.020044,64 +13.021504,64 +13.022959,64 +13.024286,64 +13.025750,64 +13.027208,64 +13.028648,64 +13.030123,64 +13.031597,64 +13.033047,64 +13.034495,64 +13.035953,64 +13.037403,64 +13.038870,64 +13.040321,64 +13.041761,64 +13.043198,64 +13.044665,64 +13.046126,64 +13.047575,64 +13.049025,64 +13.050489,64 +13.051935,64 +13.053378,64 +13.054831,64 +13.056296,64 +13.057738,64 +13.059169,64 +13.060635,64 +13.062078,64 +13.063540,64 +13.065010,64 +13.066461,64 +13.067938,64 +13.069391,64 +13.070841,64 +13.072287,64 +13.073718,64 +13.075147,64 +13.076612,64 +13.078075,64 +13.079519,64 +13.080940,64 +13.082338,64 +13.083823,64 +13.085265,64 +13.086727,64 +13.088183,64 +13.089640,64 +13.091133,64 +13.092623,64 +13.093959,64 +13.095515,64 +13.096957,64 +13.098416,64 +13.099852,64 +13.101299,64 +13.102743,64 +13.104182,64 +13.105545,64 +13.107011,64 +13.108460,64 +13.110037,64 +13.111481,64 +13.112927,64 +13.114375,64 +13.115827,64 +13.117292,64 +13.118620,64 +13.120182,64 +13.121634,64 +13.123084,64 +13.124532,64 +13.125991,64 +13.127424,64 +13.128862,64 +13.130328,64 +13.131795,64 +13.133263,64 +13.134572,64 +13.136139,64 +13.137567,64 +13.139000,64 +13.140477,64 +13.141950,64 +13.143404,64 +13.144858,64 +13.146313,64 +13.147771,64 +13.149082,64 +13.150656,64 +13.152129,64 +13.153561,64 +13.155010,64 +13.156443,64 +13.157881,64 +13.159307,64 +13.160749,64 +13.162194,64 +13.163622,64 +13.165081,64 +13.166544,64 +13.167975,64 +13.169427,64 +13.170867,64 +13.172331,64 +13.173872,64 +13.175299,64 +13.176735,64 +13.178180,64 +13.179639,64 +13.181078,64 +13.182536,64 +13.183987,64 +13.185434,64 +13.186875,64 +13.188353,64 +13.189825,64 +13.191262,64 +13.192694,64 +13.194156,64 +13.195592,64 +13.197037,64 +13.198484,64 +13.199942,64 +13.201389,64 +13.202819,64 +13.204243,64 +13.205783,64 +13.207199,64 +13.208612,64 +13.210160,64 +13.211590,64 +13.212998,64 +13.214414,64 +13.215952,64 +13.217362,64 +13.218770,64 +13.220189,64 +13.221763,64 +13.223223,64 +13.224576,64 +13.226014,64 +13.227476,64 +13.228929,64 +13.230390,64 +13.231846,64 +13.233292,64 +13.234739,64 +13.236176,64 +13.237730,64 +13.239179,64 +13.240633,64 +13.242070,64 +13.243543,64 +13.244976,64 +13.246427,64 +13.247872,64 +13.249337,64 +13.250787,64 +13.252250,64 +13.253680,64 +13.255139,64 +13.256598,64 +13.258037,64 +13.259468,64 +13.260896,64 +13.262361,64 +13.263809,64 +13.265286,64 +13.266744,64 +13.268188,64 +13.269653,64 +13.271125,64 +13.272448,64 +13.273906,64 +13.275462,64 +13.276930,64 +13.278360,64 +13.279797,64 +13.281166,64 +13.282613,64 +13.284165,64 +13.285622,64 +13.287061,64 +13.288537,64 +13.289980,64 +13.291425,64 +13.292879,64 +13.294330,64 +13.295785,64 +13.297239,64 +13.298678,64 +13.300129,64 +13.301592,64 +13.302936,64 +13.304370,64 +13.305939,64 +13.307286,64 +13.308750,64 +13.310202,64 +13.311703,64 +13.313163,64 +13.314612,64 +13.316064,64 +13.317536,64 +13.318953,64 +13.320401,64 +13.321886,64 +13.323349,64 +13.324686,64 +13.326256,64 +13.327678,64 +13.329093,64 +13.330517,64 +13.331956,64 +13.333494,64 +13.334911,64 +13.336331,64 +13.337749,64 +13.339320,64 +13.340751,64 +13.342165,64 +13.343574,64 +13.345110,64 +13.346555,64 +13.347958,64 +13.349401,64 +13.350848,64 +13.352317,64 +13.353748,64 +13.355239,64 +13.356674,64 +13.358127,64 +13.359576,64 +13.361054,64 +13.362482,64 +13.363961,64 +13.365402,64 +13.366838,64 +13.368268,64 +13.369676,64 +13.371215,64 +13.372626,64 +13.374039,64 +13.375582,64 +13.377052,64 +13.378506,64 +13.379957,64 +13.381409,64 +13.382858,64 +13.384195,64 +13.385755,64 +13.387192,64 +13.388629,64 +13.390046,64 +13.391503,64 +13.392968,64 +13.394431,64 +13.395875,64 +13.397314,64 +13.398778,64 +13.400218,64 +13.401658,64 +13.403109,64 +13.404558,64 +13.406000,64 +13.407450,64 +13.408888,64 +13.410337,64 +13.411772,64 +13.413222,64 +13.414693,64 +13.416165,64 +13.417611,64 +13.419067,64 +13.420499,64 +13.421961,64 +13.423417,64 +13.424859,64 +13.426306,64 +13.427758,64 +13.429190,64 +13.430658,64 +13.432105,64 +13.433550,64 +13.435014,64 +13.436474,64 +13.438033,64 +13.439372,64 +13.440834,64 +13.442282,64 +13.443740,64 +13.445221,64 +13.446690,64 +13.448109,64 +13.449531,64 +13.450964,64 +13.452512,64 +13.453927,64 +13.455339,64 +13.456780,64 +13.458321,64 +13.459751,64 +13.461160,64 +13.462572,64 +13.464092,64 +13.465516,64 +13.466958,64 +13.468377,64 +13.469916,64 +13.471340,64 +13.472747,64 +13.474292,64 +13.475737,64 +13.477161,64 +13.478606,64 +13.480026,64 +13.481441,64 +13.482963,64 +13.484367,64 +13.485783,64 +13.487248,64 +13.488689,64 +13.490143,64 +13.491694,64 +13.493109,64 +13.494517,64 +13.496043,64 +13.497451,64 +13.498874,64 +13.500278,64 +13.501836,64 +13.503245,64 +13.504674,64 +13.506119,64 +13.507612,64 +13.509039,64 +13.510496,64 +13.511931,64 +13.513401,64 +13.514870,64 +13.516305,64 +13.517769,64 +13.519218,64 +13.520657,64 +13.522093,64 +13.523564,64 +13.525002,64 +13.526455,64 +13.527904,64 +13.529367,64 +13.530837,64 +13.532304,64 +13.533750,64 +13.535190,64 +13.536618,64 +13.538061,64 +13.539511,64 +13.540963,64 +13.542406,64 +13.543935,64 +13.545380,64 +13.546837,64 +13.548267,64 +13.549703,64 +13.551134,64 +13.552581,64 +13.554027,64 +13.555474,64 +13.556906,64 +13.558348,64 +13.559807,64 +13.561258,64 +13.562707,64 +13.564164,64 +13.565624,64 +13.567076,64 +13.568552,64 +13.570034,64 +13.571506,64 +13.572950,64 +13.574405,64 +13.575877,64 +13.577209,64 +13.578668,64 +13.580123,64 +13.581588,64 +13.583031,64 +13.584479,64 +13.585941,64 +13.587405,64 +13.588884,64 +13.590365,64 +13.591830,64 +13.593196,64 +13.594666,64 +13.596122,64 +13.597580,64 +13.599057,64 +13.600523,64 +13.601929,64 +13.603376,64 +13.604794,64 +13.606344,64 +13.607753,64 +13.609186,64 +13.610649,64 +13.612097,64 +13.613555,64 +13.614993,64 +13.616428,64 +13.617865,64 +13.619318,64 +13.620780,64 +13.622261,64 +13.623705,64 +13.625147,64 +13.626590,64 +13.628044,64 +13.629496,64 +13.630940,64 +13.632375,64 +13.633803,64 +13.635382,64 +13.636824,64 +13.638277,64 +13.639612,64 +13.641187,64 +13.642521,64 +13.643996,64 +13.645475,64 +13.646955,64 +13.648394,64 +13.649825,64 +13.651260,64 +13.652789,64 +13.654244,64 +13.655686,64 +13.657141,64 +13.658587,64 +13.660037,64 +13.661474,64 +13.662827,64 +13.664310,64 +13.665749,64 +13.667208,64 +13.668649,64 +13.670191,64 +13.671621,64 +13.673072,64 +13.674508,64 +13.675941,64 +13.677387,64 +13.678822,64 +13.680282,64 +13.681722,64 +13.683204,64 +13.684639,64 +13.686085,64 +13.687519,64 +13.688963,64 +13.690407,64 +13.691850,64 +13.693426,64 +13.694864,64 +13.696326,64 +13.697772,64 +13.699201,64 +13.700660,64 +13.702094,64 +13.703523,64 +13.704971,64 +13.706455,64 +13.707884,64 +13.709329,64 +13.710793,64 +13.712246,64 +13.713725,64 +13.715155,64 +13.716620,64 +13.718077,64 +13.719497,64 +13.720933,64 +13.722358,64 +13.723797,64 +13.725343,64 +13.726757,64 +13.728209,64 +13.729663,64 +13.731114,64 +13.732599,64 +13.734037,64 +13.735503,64 +13.736930,64 +13.738292,64 +13.739771,64 +13.741232,64 +13.742732,64 +13.744183,64 +13.745633,64 +13.747074,64 +13.748483,64 +13.750007,64 +13.751422,64 +13.752830,64 +13.754366,64 +13.755809,64 +13.757239,64 +13.758682,64 +13.760116,64 +13.761558,64 +13.763001,64 +13.764450,64 +13.765948,64 +13.767383,64 +13.768837,64 +13.770291,64 +13.771746,64 +13.773184,64 +13.774664,64 +13.776116,64 +13.777586,64 +13.779022,64 +13.780474,64 +13.781916,64 +13.783352,64 +13.784791,64 +13.786240,64 +13.787682,64 +13.789114,64 +13.790556,64 +13.792026,64 +13.793465,64 +13.794913,64 +13.796369,64 +13.797812,64 +13.799266,64 +13.800710,64 +13.802154,64 +13.803708,64 +13.805050,64 +13.806526,64 +13.807981,64 +13.809489,64 +13.810862,64 +13.812359,64 +13.813804,64 +13.815242,64 +13.816691,64 +13.818143,64 +13.819593,64 +13.821052,64 +13.822505,64 +13.823974,64 +13.825421,64 +13.826863,64 +13.828294,64 +13.829739,64 +13.831213,64 +13.832655,64 +13.834091,64 +13.835536,64 +13.836976,64 +13.838436,64 +13.839881,64 +13.841346,64 +13.842798,64 +13.844229,64 +13.845683,64 +13.847253,64 +13.848633,64 +13.850087,64 +13.851529,64 +13.852965,64 +13.854426,64 +13.855907,64 +13.857372,64 +13.858856,64 +13.860291,64 +13.861726,64 +13.863172,64 +13.864620,64 +13.866076,64 +13.867529,64 +13.868964,64 +13.870395,64 +13.871829,64 +13.873291,64 +13.874736,64 +13.876204,64 +13.877697,64 +13.879182,64 +13.880516,64 +13.882043,64 +13.883457,64 +13.884896,64 +13.886422,64 +13.887847,64 +13.889277,64 +13.890717,64 +13.892149,64 +13.893573,64 +13.895113,64 +13.896532,64 +13.897992,64 +13.899480,64 +13.900951,64 +13.902392,64 +13.903843,64 +13.905301,64 +13.906741,64 +13.908186,64 +13.909629,64 +13.911084,64 +13.912529,64 +13.913964,64 +13.915418,64 +13.916859,64 +13.918308,64 +13.919743,64 +13.921192,64 +13.922658,64 +13.924082,64 +13.925529,64 +13.926969,64 +13.928412,64 +13.929971,64 +13.931328,64 +13.932799,64 +13.934242,64 +13.935689,64 +13.937127,64 +13.938573,64 +13.940017,64 +13.941482,64 +13.942915,64 +13.944372,64 +13.945842,64 +13.947342,64 +13.948781,64 +13.950210,64 +13.951656,64 +13.953109,64 +13.954575,64 +13.956029,64 +13.957479,64 +13.958922,64 +13.960348,64 +13.961795,64 +13.963344,64 +13.964783,64 +13.966230,64 +13.967683,64 +13.969121,64 +13.970594,64 +13.972059,64 +13.973423,64 +13.974919,64 +13.976376,64 +13.977865,64 +13.979206,64 +13.980672,64 +13.982149,64 +13.983645,64 +13.985103,64 +13.986530,64 +13.987979,64 +13.989473,64 +13.990928,64 +13.992368,64 +13.993827,64 +13.995268,64 +13.996723,64 +13.998082,64 +13.999549,64 +14.001008,64 +14.002494,64 +14.003935,64 +14.005367,64 +14.006807,64 +14.008288,64 +14.009763,64 +14.011240,64 +14.012687,64 +14.014144,64 +14.015583,64 +14.017040,64 +14.018503,64 +14.019864,64 +14.021372,64 +14.022861,64 +14.024182,64 +14.025666,64 +14.027124,64 +14.028573,64 +14.030005,64 +14.031453,64 +14.032918,64 +14.034359,64 +14.035806,64 +14.037250,64 +14.038807,64 +14.040143,64 +14.041715,64 +14.043162,64 +14.044600,64 +14.046032,64 +14.047478,64 +14.048931,64 +14.050420,64 +14.051866,64 +14.053304,64 +14.054763,64 +14.056184,64 +14.057623,64 +14.059081,64 +14.060541,64 +14.062017,64 +14.063474,64 +14.064924,64 +14.066355,64 +14.067790,64 +14.069262,64 +14.070691,64 +14.072158,64 +14.073604,64 +14.075055,64 +14.076505,64 +14.077937,64 +14.079384,64 +14.080822,64 +14.082286,64 +14.083722,64 +14.085168,64 +14.086611,64 +14.088056,64 +14.089496,64 +14.090949,64 +14.092515,64 +14.093956,64 +14.095395,64 +14.096853,64 +14.098311,64 +14.099762,64 +14.101220,64 +14.102546,64 +14.104117,64 +14.105568,64 +14.107010,64 +14.108458,64 +14.109884,64 +14.111332,64 +14.112785,64 +14.114264,64 +14.115725,64 +14.117066,64 +14.118528,64 +14.120008,64 +14.121498,64 +14.122969,64 +14.124403,64 +14.125852,64 +14.127278,64 +14.128733,64 +14.130222,64 +14.131644,64 +14.133112,64 +14.134574,64 +14.136033,64 +14.137481,64 +14.138930,64 +14.140403,64 +14.141850,64 +14.143209,64 +14.144665,64 +14.146129,64 +14.147593,64 +14.149070,64 +14.150507,64 +14.151952,64 +14.153401,64 +14.154847,64 +14.156293,64 +14.157728,64 +14.159165,64 +14.160613,64 +14.162059,64 +14.163505,64 +14.165063,64 +14.166504,64 +14.167961,64 +14.169405,64 +14.170870,64 +14.172204,64 +14.173781,64 +14.175241,64 +14.176565,64 +14.178025,64 +14.179584,64 +14.180919,64 +14.182485,64 +14.183909,64 +14.185356,64 +14.186790,64 +14.188234,64 +14.189685,64 +14.191131,64 +14.192592,64 +14.194055,64 +14.195482,64 +14.196918,64 +14.198363,64 +14.199807,64 +14.201268,64 +14.202695,64 +14.204148,64 +14.205634,64 +14.207070,64 +14.208525,64 +14.209965,64 +14.211418,64 +14.212919,64 +14.214385,64 +14.215846,64 +14.217317,64 +14.218783,64 +14.220104,64 +14.221658,64 +14.223090,64 +14.224535,64 +14.225965,64 +14.227423,64 +14.228890,64 +14.230336,64 +14.231815,64 +14.233261,64 +14.234704,64 +14.236146,64 +14.237601,64 +14.239050,64 +14.240492,64 +14.241947,64 +14.243389,64 +14.244834,64 +14.246279,64 +14.247724,64 +14.249172,64 +14.250627,64 +14.252084,64 +14.253535,64 +14.254987,64 +14.256439,64 +14.257875,64 +14.259337,64 +14.260763,64 +14.262225,64 +14.263688,64 +14.265148,64 +14.266594,64 +14.268054,64 +14.269506,64 +14.270981,64 +14.272434,64 +14.273865,64 +14.275303,64 +14.276737,64 +14.278216,64 +14.279695,64 +14.281146,64 +14.282613,64 +14.284071,64 +14.285530,64 +14.286868,64 +14.288433,64 +14.289792,64 +14.291256,64 +14.292691,64 +14.294128,64 +14.295590,64 +14.297037,64 +14.298495,64 +14.299948,64 +14.301401,64 +14.302860,64 +14.304307,64 +14.305776,64 +14.307258,64 +14.308727,64 +14.310187,64 +14.311653,64 +14.313005,64 +14.314472,64 +14.315941,64 +14.317412,64 +14.318876,64 +14.320331,64 +14.321790,64 +14.323233,64 +14.324679,64 +14.326147,64 +14.327611,64 +14.329049,64 +14.330496,64 +14.331961,64 +14.333290,64 +14.334860,64 +14.336320,64 +14.337753,64 +14.339203,64 +14.340659,64 +14.342093,64 +14.343524,64 +14.344959,64 +14.346444,64 +14.347893,64 +14.349340,64 +14.350793,64 +14.352236,64 +14.353674,64 +14.355142,64 +14.356607,64 +14.358043,64 +14.359487,64 +14.360919,64 +14.362350,64 +14.363811,64 +14.365254,64 +14.366696,64 +14.368253,64 +14.369692,64 +14.371060,64 +14.372516,64 +14.373952,64 +14.375412,64 +14.376888,64 +14.378324,64 +14.379773,64 +14.381218,64 +14.382686,64 +14.384139,64 +14.385582,64 +14.387036,64 +14.388498,64 +14.389961,64 +14.391425,64 +14.392864,64 +14.394306,64 +14.395802,64 +14.397145,64 +14.398608,64 +14.400171,64 +14.401605,64 +14.403048,64 +14.404503,64 +14.405939,64 +14.407398,64 +14.408847,64 +14.410292,64 +14.411730,64 +14.413184,64 +14.414632,64 +14.416085,64 +14.417526,64 +14.418992,64 +14.420450,64 +14.421889,64 +14.423331,64 +14.424809,64 +14.426290,64 +14.427736,64 +14.429201,64 +14.430635,64 +14.432099,64 +14.433428,64 +14.434991,64 +14.436453,64 +14.437912,64 +14.439358,64 +14.440800,64 +14.442240,64 +14.443707,64 +14.445170,64 +14.446533,64 +14.447979,64 +14.449443,64 +14.450903,64 +14.452356,64 +14.453824,64 +14.455284,64 +14.456744,64 +14.458209,64 +14.459653,64 +14.461095,64 +14.462573,64 +14.464032,64 +14.465491,64 +14.466820,64 +14.468274,64 +14.469720,64 +14.471175,64 +14.472729,64 +14.474181,64 +14.475523,64 +14.476988,64 +14.478480,64 +14.479935,64 +14.481407,64 +14.482851,64 +14.484293,64 +14.485746,64 +14.487182,64 +14.488656,64 +14.490138,64 +14.491614,64 +14.493047,64 +14.494461,64 +14.495896,64 +14.497326,64 +14.498784,64 +14.500259,64 +14.501688,64 +14.503129,64 +14.504595,64 +14.506026,64 +14.507481,64 +14.508924,64 +14.510367,64 +14.511800,64 +14.513277,64 +14.514750,64 +14.516249,64 +14.517726,64 +14.519085,64 +14.520550,64 +14.521987,64 +14.523413,64 +14.524945,64 +14.526352,64 +14.527783,64 +14.529219,64 +14.530719,64 +14.532169,64 +14.533612,64 +14.535067,64 +14.536512,64 +14.537947,64 +14.539420,64 +14.540874,64 +14.542325,64 +14.543779,64 +14.545216,64 +14.546668,64 +14.548111,64 +14.549559,64 +14.551000,64 +14.552442,64 +14.553996,64 +14.555434,64 +14.556904,64 +14.558255,64 +14.559696,64 +14.561264,64 +14.562621,64 +14.564070,64 +14.565535,64 +14.566972,64 +14.568430,64 +14.569888,64 +14.571329,64 +14.572796,64 +14.574246,64 +14.575711,64 +14.577170,64 +14.578610,64 +14.580086,64 +14.581556,64 +14.583004,64 +14.584443,64 +14.585888,64 +14.587347,64 +14.588815,64 +14.590256,64 +14.591706,64 +14.593157,64 +14.594615,64 +14.596060,64 +14.597508,64 +14.598973,64 +14.600332,64 +14.601829,64 +14.603298,64 +14.604775,64 +14.606253,64 +14.607609,64 +14.609060,64 +14.610525,64 +14.611979,64 +14.613420,64 +14.614894,64 +14.616325,64 +14.617775,64 +14.619235,64 +14.620701,64 +14.622146,64 +14.623609,64 +14.625054,64 +14.626484,64 +14.627925,64 +14.629375,64 +14.630824,64 +14.632283,64 +14.633725,64 +14.635166,64 +14.636608,64 +14.638071,64 +14.639515,64 +14.640957,64 +14.642428,64 +14.643888,64 +14.645385,64 +14.646856,64 +14.648340,64 +14.649804,64 +14.651124,64 +14.652690,64 +14.654027,64 +14.655587,64 +14.657038,64 +14.658469,64 +14.659897,64 +14.661342,64 +14.662832,64 +14.664292,64 +14.665744,64 +14.667171,64 +14.668609,64 +14.670074,64 +14.671517,64 +14.672952,64 +14.674393,64 +14.675848,64 +14.677289,64 +14.678733,64 +14.680186,64 +14.681633,64 +14.683079,64 +14.684533,64 +14.685971,64 +14.687439,64 +14.688888,64 +14.690352,64 +14.691778,64 +14.693218,64 +14.694695,64 +14.696169,64 +14.697550,64 +14.699129,64 +14.700565,64 +14.702005,64 +14.703453,64 +14.704900,64 +14.706307,64 +14.707762,64 +14.709194,64 +14.710627,64 +14.712187,64 +14.713522,64 +14.715017,64 +14.716464,64 +14.717924,64 +14.719369,64 +14.720810,64 +14.722262,64 +14.723699,64 +14.725133,64 +14.726711,64 +14.728146,64 +14.729591,64 +14.731037,64 +14.732486,64 +14.733924,64 +14.735371,64 +14.736801,64 +14.738252,64 +14.739684,64 +14.741125,64 +14.742572,64 +14.744023,64 +14.745488,64 +14.746989,64 +14.748437,64 +14.749867,64 +14.751313,64 +14.752752,64 +14.754195,64 +14.755660,64 +14.757118,64 +14.758564,64 +14.760010,64 +14.761449,64 +14.762979,64 +14.764410,64 +14.765868,64 +14.767327,64 +14.768764,64 +14.770219,64 +14.771657,64 +14.773115,64 +14.774558,64 +14.775999,64 +14.777433,64 +14.778870,64 +14.780318,64 +14.781788,64 +14.783223,64 +14.784656,64 +14.786107,64 +14.787552,64 +14.789005,64 +14.790550,64 +14.791989,64 +14.793436,64 +14.794873,64 +14.796319,64 +14.797778,64 +14.799227,64 +14.800683,64 +14.802115,64 +14.803554,64 +14.805016,64 +14.806438,64 +14.807898,64 +14.809345,64 +14.810811,64 +14.812289,64 +14.813739,64 +14.815197,64 +14.816639,64 +14.818086,64 +14.819558,64 +14.821019,64 +14.822466,64 +14.823926,64 +14.825357,64 +14.826785,64 +14.828289,64 +14.829728,64 +14.831181,64 +14.832631,64 +14.834064,64 +14.835508,64 +14.836962,64 +14.838428,64 +14.839872,64 +14.841329,64 +14.842763,64 +14.844200,64 +14.845645,64 +14.847118,64 +14.848573,64 +14.850023,64 +14.851504,64 +14.852952,64 +14.854413,64 +14.855749,64 +14.857311,64 +14.858739,64 +14.860180,64 +14.861629,64 +14.863081,64 +14.864513,64 +14.865936,64 +14.867375,64 +14.868834,64 +14.870287,64 +14.871743,64 +14.873175,64 +14.874720,64 +14.876146,64 +14.877579,64 +14.879026,64 +14.880488,64 +14.881945,64 +14.883392,64 +14.884844,64 +14.886293,64 +14.887740,64 +14.889214,64 +14.890689,64 +14.892134,64 +14.893574,64 +14.895013,64 +14.896476,64 +14.897924,64 +14.899377,64 +14.900812,64 +14.902238,64 +14.903674,64 +14.905116,64 +14.906560,64 +14.908022,64 +14.909471,64 +14.910909,64 +14.912365,64 +14.913816,64 +14.915298,64 +14.916728,64 +14.918162,64 +14.919623,64 +14.921075,64 +14.922521,64 +14.923979,64 +14.925439,64 +14.926864,64 +14.928309,64 +14.929759,64 +14.931224,64 +14.932683,64 +14.934137,64 +14.935595,64 +14.937040,64 +14.938506,64 +14.939959,64 +14.941397,64 +14.942858,64 +14.944288,64 +14.945741,64 +14.947205,64 +14.948665,64 +14.950101,64 +14.951559,64 +14.953029,64 +14.954493,64 +14.955948,64 +14.957410,64 +14.958849,64 +14.960326,64 +14.961775,64 +14.963220,64 +14.964685,64 +14.966130,64 +14.967571,64 +14.969008,64 +14.970452,64 +14.971914,64 +14.973288,64 +14.974772,64 +14.976252,64 +14.977749,64 +14.979199,64 +14.980642,64 +14.982070,64 +14.983553,64 +14.984982,64 +14.986432,64 +14.987875,64 +14.989346,64 +14.990777,64 +14.992221,64 +14.993674,64 +14.995116,64 +14.996541,64 +14.997989,64 +14.999454,64 +15.000919,64 +15.002366,64 +15.003812,64 +15.005277,64 +15.006729,64 +15.008171,64 +15.009610,64 +15.011083,64 +15.012538,64 +15.013987,64 +15.015429,64 +15.016894,64 +15.018379,64 +15.019847,64 +15.021303,64 +15.022638,64 +15.024088,64 +15.025530,64 +15.027106,64 +15.028546,64 +15.029892,64 +15.031478,64 +15.032906,64 +15.034342,64 +15.035778,64 +15.037227,64 +15.038662,64 +15.040142,64 +15.041599,64 +15.043041,64 +15.044479,64 +15.045937,64 +15.047383,64 +15.048838,64 +15.050308,64 +15.051782,64 +15.053225,64 +15.054557,64 +15.056136,64 +15.057492,64 +15.058942,64 +15.060415,64 +15.061855,64 +15.063330,64 +15.064774,64 +15.066218,64 +15.067657,64 +15.069110,64 +15.070578,64 +15.072029,64 +15.073467,64 +15.074904,64 +15.076350,64 +15.077812,64 +15.079257,64 +15.080704,64 +15.082163,64 +15.083603,64 +15.085052,64 +15.086526,64 +15.087964,64 +15.089430,64 +15.090878,64 +15.092334,64 +15.093775,64 +15.095238,64 +15.096717,64 +15.098160,64 +15.099645,64 +15.101079,64 +15.102523,64 +15.103948,64 +15.105413,64 +15.106858,64 +15.108308,64 +15.109755,64 +15.111233,64 +15.112678,64 +15.114119,64 +15.115573,64 +15.117004,64 +15.118436,64 +15.119888,64 +15.121321,64 +15.122783,64 +15.124349,64 +15.125667,64 +15.127126,64 +15.128583,64 +15.130039,64 +15.131510,64 +15.132964,64 +15.134410,64 +15.135854,64 +15.137306,64 +15.138783,64 +15.140254,64 +15.141713,64 +15.143175,64 +15.144649,64 +15.146105,64 +15.147560,64 +15.148886,64 +15.150352,64 +15.151817,64 +15.153256,64 +15.154711,64 +15.156270,64 +15.157717,64 +15.159165,64 +15.160594,64 +15.162034,64 +15.163471,64 +15.164906,64 +15.166352,64 +15.167795,64 +15.169253,64 +15.170689,64 +15.172134,64 +15.173590,64 +15.175028,64 +15.176508,64 +15.177953,64 +15.179400,64 +15.180833,64 +15.182297,64 +15.183745,64 +15.185199,64 +15.186651,64 +15.188119,64 +15.189585,64 +15.191026,64 +15.192479,64 +15.193910,64 +15.195355,64 +15.196827,64 +15.198305,64 +15.199736,64 +15.201170,64 +15.202600,64 +15.204049,64 +15.205516,64 +15.206988,64 +15.208429,64 +15.209890,64 +15.211336,64 +15.212785,64 +15.214247,64 +15.215703,64 +15.217162,64 +15.218620,64 +15.220100,64 +15.221550,64 +15.222970,64 +15.224438,64 +15.225916,64 +15.227366,64 +15.228799,64 +15.230276,64 +15.231708,64 +15.233140,64 +15.234583,64 +15.236028,64 +15.237503,64 +15.238939,64 +15.240403,64 +15.241855,64 +15.243313,64 +15.244751,64 +15.246219,64 +15.247661,64 +15.249096,64 +15.250545,64 +15.251996,64 +15.253427,64 +15.254871,64 +15.256319,64 +15.257781,64 +15.259219,64 +15.260675,64 +15.262116,64 +15.263568,64 +15.265043,64 +15.266473,64 +15.267918,64 +15.269346,64 +15.270933,64 +15.272248,64 +15.273706,64 +15.275176,64 +15.276627,64 +15.278054,64 +15.279635,64 +15.281040,64 +15.282494,64 +15.283949,64 +15.285388,64 +15.286822,64 +15.288290,64 +15.289733,64 +15.291189,64 +15.292635,64 +15.294075,64 +15.295510,64 +15.296946,64 +15.298381,64 +15.299848,64 +15.301286,64 +15.302830,64 +15.304278,64 +15.305713,64 +15.307087,64 +15.308524,64 +15.309991,64 +15.311474,64 +15.312936,64 +15.314409,64 +15.315850,64 +15.317325,64 +15.318792,64 +15.320227,64 +15.321693,64 +15.323155,64 +15.324592,64 +15.326040,64 +15.327500,64 +15.328946,64 +15.330411,64 +15.331753,64 +15.333220,64 +15.334773,64 +15.336119,64 +15.337593,64 +15.339076,64 +15.340547,64 +15.341989,64 +15.343472,64 +15.344918,64 +15.346357,64 +15.347788,64 +15.349253,64 +15.350681,64 +15.352128,64 +15.353575,64 +15.355037,64 +15.356491,64 +15.357934,64 +15.359407,64 +15.360862,64 +15.362339,64 +15.363680,64 +15.365139,64 +15.366579,64 +15.368028,64 +15.369590,64 +15.371048,64 +15.372493,64 +15.373931,64 +15.375382,64 +15.376821,64 +15.378267,64 +15.379721,64 +15.381164,64 +15.382597,64 +15.384078,64 +15.385521,64 +15.386985,64 +15.388425,64 +15.389863,64 +15.391310,64 +15.392763,64 +15.394212,64 +15.395630,64 +15.397106,64 +15.398549,64 +15.400010,64 +15.401467,64 +15.402930,64 +15.404363,64 +15.405841,64 +15.407283,64 +15.408728,64 +15.410165,64 +15.411606,64 +15.413046,64 +15.414501,64 +15.415935,64 +15.417485,64 +15.418939,64 +15.420289,64 +15.421757,64 +15.423209,64 +15.424652,64 +15.426091,64 +15.427543,64 +15.429002,64 +15.430485,64 +15.431980,64 +15.433429,64 +15.434894,64 +15.436345,64 +15.437793,64 +15.439224,64 +15.440674,64 +15.442115,64 +15.443560,64 +15.445007,64 +15.446456,64 +15.447902,64 +15.449345,64 +15.450793,64 +15.452263,64 +15.453743,64 +15.455173,64 +15.456605,64 +15.458065,64 +15.459526,64 +15.460962,64 +15.462408,64 +15.463903,64 +15.465325,64 +15.466769,64 +15.468200,64 +15.469641,64 +15.471081,64 +15.472526,64 +15.474011,64 +15.475464,64 +15.476934,64 +15.478422,64 +15.479896,64 +15.481361,64 +15.482791,64 +15.484233,64 +15.485664,64 +15.487101,64 +15.488551,64 +15.490004,64 +15.491466,64 +15.492912,64 +15.494347,64 +15.495790,64 +15.497245,64 +15.498711,64 +15.500160,64 +15.501597,64 +15.503047,64 +15.504504,64 +15.505961,64 +15.507451,64 +15.508895,64 +15.510346,64 +15.511795,64 +15.513273,64 +15.514733,64 +15.516056,64 +15.517634,64 +15.519068,64 +15.520513,64 +15.521950,64 +15.523396,64 +15.524848,64 +15.526298,64 +15.527760,64 +15.529209,64 +15.530684,64 +15.532132,64 +15.533569,64 +15.535013,64 +15.536461,64 +15.537894,64 +15.539329,64 +15.540802,64 +15.542261,64 +15.543708,64 +15.545152,64 +15.546605,64 +15.548062,64 +15.549519,64 +15.550967,64 +15.552400,64 +15.553848,64 +15.555308,64 +15.556783,64 +15.558219,64 +15.559678,64 +15.561120,64 +15.562546,64 +15.564009,64 +15.565451,64 +15.566907,64 +15.568351,64 +15.569805,64 +15.571277,64 +15.572722,64 +15.574190,64 +15.575636,64 +15.577087,64 +15.578513,64 +15.579994,64 +15.581459,64 +15.582906,64 +15.584353,64 +15.585799,64 +15.587234,64 +15.588698,64 +15.590180,64 +15.591618,64 +15.593052,64 +15.594505,64 +15.595963,64 +15.597386,64 +15.598842,64 +15.600290,64 +15.601731,64 +15.603172,64 +15.604621,64 +15.606075,64 +15.607525,64 +15.608991,64 +15.610460,64 +15.611928,64 +15.613362,64 +15.614830,64 +15.616272,64 +15.617717,64 +15.619159,64 +15.620650,64 +15.621987,64 +15.623566,64 +15.625013,64 +15.626462,64 +15.627902,64 +15.629354,64 +15.630804,64 +15.632267,64 +15.633712,64 +15.635168,64 +15.636612,64 +15.638036,64 +15.639501,64 +15.640975,64 +15.642436,64 +15.643788,64 +15.645242,64 +15.646711,64 +15.648162,64 +15.649611,64 +15.651069,64 +15.652513,64 +15.653935,64 +15.655492,64 +15.656836,64 +15.658304,64 +15.659750,64 +15.661193,64 +15.662640,64 +15.664108,64 +15.665565,64 +15.667009,64 +15.668446,64 +15.669895,64 +15.671348,64 +15.672908,64 +15.674345,64 +15.675778,64 +15.677255,64 +15.678691,64 +15.680053,64 +15.681522,64 +15.682979,64 +15.684443,64 +15.685901,64 +15.687354,64 +15.688806,64 +15.690299,64 +15.691749,64 +15.693184,64 +15.694627,64 +15.696093,64 +15.697528,64 +15.698969,64 +15.700406,64 +15.701844,64 +15.703289,64 +15.704731,64 +15.706171,64 +15.707652,64 +15.709092,64 +15.710539,64 +15.711980,64 +15.713541,64 +15.715005,64 +15.716427,64 +15.717875,64 +15.719318,64 +15.720777,64 +15.722231,64 +15.723676,64 +15.725123,64 +15.726552,64 +15.728036,64 +15.729488,64 +15.730957,64 +15.732308,64 +15.733849,64 +15.735284,64 +15.736734,64 +15.738185,64 +15.739665,64 +15.740995,64 +15.742469,64 +15.743902,64 +15.745343,64 +15.746797,64 +15.748369,64 +15.749724,64 +15.751172,64 +15.752600,64 +15.754166,64 +15.755609,64 +15.757081,64 +15.758409,64 +15.759859,64 +15.761429,64 +15.762876,64 +15.764316,64 +15.765785,64 +15.767211,64 +15.768656,64 +15.770113,64 +15.771471,64 +15.772928,64 +15.774393,64 +15.775853,64 +15.777287,64 +15.778841,64 +15.780299,64 +15.781756,64 +15.783209,64 +15.784630,64 +15.786069,64 +15.787506,64 +15.788947,64 +15.790430,64 +15.791894,64 +15.793319,64 +15.794770,64 +15.796216,64 +15.797648,64 +15.799088,64 +15.800554,64 +15.801986,64 +15.803429,64 +15.804876,64 +15.806335,64 +15.807826,64 +15.809270,64 +15.810720,64 +15.812193,64 +15.813670,64 +15.815025,64 +15.816489,64 +15.817939,64 +15.819413,64 +15.820853,64 +15.822319,64 +15.823750,64 +15.825199,64 +15.826658,64 +15.828122,64 +15.829577,64 +15.831029,64 +15.832482,64 +15.833921,64 +15.835384,64 +15.836812,64 +15.838266,64 +15.839729,64 +15.841185,64 +15.842622,64 +15.844059,64 +15.845502,64 +15.846947,64 +15.848381,64 +15.849968,64 +15.851408,64 +15.852851,64 +15.854294,64 +15.855752,64 +15.857217,64 +15.858663,64 +15.860099,64 +15.861459,64 +15.862920,64 +15.864397,64 +15.865846,64 +15.867346,64 +15.868775,64 +15.870210,64 +15.871674,64 +15.873136,64 +15.874568,64 +15.876020,64 +15.877458,64 +15.878900,64 +15.880354,64 +15.881783,64 +15.883229,64 +15.884781,64 +15.886210,64 +15.887645,64 +15.889089,64 +15.890572,64 +15.892008,64 +15.893465,64 +15.894927,64 +15.896354,64 +15.897809,64 +15.899275,64 +15.900753,64 +15.902201,64 +15.903531,64 +15.904996,64 +15.906471,64 +15.907957,64 +15.909407,64 +15.910888,64 +15.912350,64 +15.913701,64 +15.915194,64 +15.916665,64 +15.918130,64 +15.919565,64 +15.921035,64 +15.922510,64 +15.923843,64 +15.925305,64 +15.926753,64 +15.928321,64 +15.929652,64 +15.931153,64 +15.932628,64 +15.934062,64 +15.935552,64 +15.937005,64 +15.938463,64 +15.939912,64 +15.941354,64 +15.942794,64 +15.944241,64 +15.945683,64 +15.947157,64 +15.948613,64 +15.950067,64 +15.951518,64 +15.952961,64 +15.954415,64 +15.955895,64 +15.957364,64 +15.958687,64 +15.960151,64 +15.961600,64 +15.963037,64 +15.964612,64 +15.965939,64 +15.967501,64 +15.968941,64 +15.970373,64 +15.971824,64 +15.973307,64 +15.974641,64 +15.976098,64 +15.977589,64 +15.979055,64 +15.980515,64 +15.981959,64 +15.983403,64 +15.984847,64 +15.986282,64 +15.987737,64 +15.989164,64 +15.990725,64 +15.992176,64 +15.993614,64 +15.995060,64 +15.996494,64 +15.997951,64 +15.999395,64 +16.000841,64 +16.002275,64 +16.003701,64 +16.005217,64 +16.006565,64 +16.008133,64 +16.009572,64 +16.011041,64 +16.012489,64 +16.013942,64 +16.015283,64 +16.016730,64 +16.018283,64 +16.019744,64 +16.021143,64 +16.022646,64 +16.024115,64 +16.025550,64 +16.026906,64 +16.028342,64 +16.029905,64 +16.031362,64 +16.032798,64 +16.034229,64 +16.035676,64 +16.037122,64 +16.038574,64 +16.040039,64 +16.041489,64 +16.042926,64 +16.044373,64 +16.045814,64 +16.047288,64 +16.048736,64 +16.050203,64 +16.051641,64 +16.053087,64 +16.054524,64 +16.055991,64 +16.057428,64 +16.058841,64 +16.060281,64 +16.061722,64 +16.063193,64 +16.064650,64 +16.066097,64 +16.067546,64 +16.068984,64 +16.070546,64 +16.071882,64 +16.073442,64 +16.074802,64 +16.076248,64 +16.077691,64 +16.079252,64 +16.080682,64 +16.082120,64 +16.083555,64 +16.084988,64 +16.086436,64 +16.087868,64 +16.089320,64 +16.090768,64 +16.092206,64 +16.093663,64 +16.095117,64 +16.096565,64 +16.098033,64 +16.099511,64 +16.100972,64 +16.102409,64 +16.103849,64 +16.105312,64 +16.106767,64 +16.108221,64 +16.109650,64 +16.111116,64 +16.112576,64 +16.114028,64 +16.115478,64 +16.116927,64 +16.118371,64 +16.119824,64 +16.121294,64 +16.122749,64 +16.124204,64 +16.125662,64 +16.127109,64 +16.128574,64 +16.130061,64 +16.131420,64 +16.132856,64 +16.134307,64 +16.135755,64 +16.137248,64 +16.138710,64 +16.140173,64 +16.141626,64 +16.143077,64 +16.144519,64 +16.145989,64 +16.147456,64 +16.148788,64 +16.150358,64 +16.151793,64 +16.153265,64 +16.154709,64 +16.156171,64 +16.157511,64 +16.158965,64 +16.160415,64 +16.161959,64 +16.163307,64 +16.164867,64 +16.166313,64 +16.167672,64 +16.169137,64 +16.170592,64 +16.172069,64 +16.173526,64 +16.174964,64 +16.176422,64 +16.177858,64 +16.179342,64 +16.180808,64 +16.182293,64 +16.183722,64 +16.185164,64 +16.186591,64 +16.188028,64 +16.189466,64 +16.190916,64 +16.192375,64 +16.193834,64 +16.195276,64 +16.196761,64 +16.198226,64 +16.199663,64 +16.201074,64 +16.202512,64 +16.203955,64 +16.205434,64 +16.206904,64 +16.208342,64 +16.209778,64 +16.211235,64 +16.212681,64 +16.214136,64 +16.215588,64 +16.217033,64 +16.218463,64 +16.220017,64 +16.221377,64 +16.222827,64 +16.224297,64 +16.225781,64 +16.227206,64 +16.228621,64 +16.230177,64 +16.231588,64 +16.233006,64 +16.234415,64 +16.235956,64 +16.237365,64 +16.238774,64 +16.240330,64 +16.241764,64 +16.243236,64 +16.244731,64 +16.246068,64 +16.247524,64 +16.248994,64 +16.250467,64 +16.251909,64 +16.253361,64 +16.254821,64 +16.256269,64 +16.257699,64 +16.259127,64 +16.260607,64 +16.262067,64 +16.263514,64 +16.265020,64 +16.266451,64 +16.267892,64 +16.269353,64 +16.270798,64 +16.272260,64 +16.273693,64 +16.275139,64 +16.276577,64 +16.278027,64 +16.279466,64 +16.280904,64 +16.282362,64 +16.283816,64 +16.285250,64 +16.286696,64 +16.288155,64 +16.289621,64 +16.291063,64 +16.292509,64 +16.293957,64 +16.295399,64 +16.296862,64 +16.298301,64 +16.299734,64 +16.301188,64 +16.302634,64 +16.304068,64 +16.305640,64 +16.307102,64 +16.308548,64 +16.309878,64 +16.311342,64 +16.312824,64 +16.314275,64 +16.315731,64 +16.317190,64 +16.318651,64 +16.320104,64 +16.321548,64 +16.323016,64 +16.324496,64 +16.325835,64 +16.327308,64 +16.328776,64 +16.330226,64 +16.331725,64 +16.333182,64 +16.334629,64 +16.336061,64 +16.337511,64 +16.338998,64 +16.340451,64 +16.341909,64 +16.343347,64 +16.344790,64 +16.346228,64 +16.347695,64 +16.349089,64 +16.350538,64 +16.351986,64 +16.353535,64 +16.354898,64 +16.356346,64 +16.357797,64 +16.359229,64 +16.360673,64 +16.362243,64 +16.363596,64 +16.365041,64 +16.366605,64 +16.368074,64 +16.369379,64 +16.370891,64 +16.372361,64 +16.373803,64 +16.375244,64 +16.376668,64 +16.378115,64 +16.379563,64 +16.381017,64 +16.382468,64 +16.383928,64 +16.385368,64 +16.386805,64 +16.388269,64 +16.389746,64 +16.391185,64 +16.392625,64 +16.394044,64 +16.395599,64 +16.397033,64 +16.398471,64 +16.399916,64 +16.401359,64 +16.402786,64 +16.404225,64 +16.405687,64 +16.407125,64 +16.408586,64 +16.410045,64 +16.411515,64 +16.412966,64 +16.414437,64 +16.415877,64 +16.417329,64 +16.418778,64 +16.420272,64 +16.421717,64 +16.423125,64 +16.424576,64 +16.426017,64 +16.427468,64 +16.428900,64 +16.430374,64 +16.431826,64 +16.433302,64 +16.434748,64 +16.436183,64 +16.437613,64 +16.439080,64 +16.440546,64 +16.441984,64 +16.443432,64 +16.444879,64 +16.446325,64 +16.447780,64 +16.449239,64 +16.450692,64 +16.452141,64 +16.453594,64 +16.455084,64 +16.456520,64 +16.457978,64 +16.459425,64 +16.460882,64 +16.462312,64 +16.463751,64 +16.465226,64 +16.466674,64 +16.468114,64 +16.469566,64 +16.471024,64 +16.472473,64 +16.473953,64 +16.475401,64 +16.476862,64 +16.478351,64 +16.479702,64 +16.481164,64 +16.482610,64 +16.484059,64 +16.485516,64 +16.486959,64 +16.488403,64 +16.489853,64 +16.491328,64 +16.492770,64 +16.494216,64 +16.495654,64 +16.497123,64 +16.498556,64 +16.500025,64 +16.501478,64 +16.502929,64 +16.504372,64 +16.505812,64 +16.507255,64 +16.508696,64 +16.510142,64 +16.511724,64 +16.513168,64 +16.514636,64 +16.515985,64 +16.517435,64 +16.518892,64 +16.520332,64 +16.521830,64 +16.523275,64 +16.524735,64 +16.526171,64 +16.527613,64 +16.529063,64 +16.530528,64 +16.531979,64 +16.533418,64 +16.534862,64 +16.536330,64 +16.537784,64 +16.539230,64 +16.540681,64 +16.542119,64 +16.543558,64 +16.544997,64 +16.546436,64 +16.547894,64 +16.549344,64 +16.550788,64 +16.552246,64 +16.553707,64 +16.555173,64 +16.556645,64 +16.558098,64 +16.559548,64 +16.560977,64 +16.562417,64 +16.563865,64 +16.565353,64 +16.566831,64 +16.568268,64 +16.569735,64 +16.571182,64 +16.572679,64 +16.574016,64 +16.575461,64 +16.576926,64 +16.578392,64 +16.579885,64 +16.581348,64 +16.582774,64 +16.584222,64 +16.585679,64 +16.587122,64 +16.588558,64 +16.590011,64 +16.591455,64 +16.592903,64 +16.594356,64 +16.595800,64 +16.597267,64 +16.598719,64 +16.600161,64 +16.601600,64 +16.603049,64 +16.604519,64 +16.605995,64 +16.607451,64 +16.608887,64 +16.610326,64 +16.611775,64 +16.613227,64 +16.614677,64 +16.616138,64 +16.617600,64 +16.619044,64 +16.620476,64 +16.621935,64 +16.623400,64 +16.624834,64 +16.626285,64 +16.627739,64 +16.629184,64 +16.630632,64 +16.632096,64 +16.633547,64 +16.634999,64 +16.636445,64 +16.637893,64 +16.639361,64 +16.640809,64 +16.642260,64 +16.643739,64 +16.645211,64 +16.646662,64 +16.648111,64 +16.649579,64 +16.651029,64 +16.652463,64 +16.653900,64 +16.655309,64 +16.656768,64 +16.658203,64 +16.659641,64 +16.661094,64 +16.662568,64 +16.663990,64 +16.665478,64 +16.666907,64 +16.668347,64 +16.669780,64 +16.671230,64 +16.672803,64 +16.674237,64 +16.675679,64 +16.677137,64 +16.678572,64 +16.680009,64 +16.681450,64 +16.682902,64 +16.684354,64 +16.685792,64 +16.687215,64 +16.688670,64 +16.690143,64 +16.691607,64 +16.693058,64 +16.694521,64 +16.695972,64 +16.697434,64 +16.698875,64 +16.700329,64 +16.701760,64 +16.703195,64 +16.704646,64 +16.706098,64 +16.707574,64 +16.709036,64 +16.710477,64 +16.711931,64 +16.713371,64 +16.714828,64 +16.716287,64 +16.717732,64 +16.719177,64 +16.720610,64 +16.722057,64 +16.723512,64 +16.724953,64 +16.726493,64 +16.727928,64 +16.729419,64 +16.730748,64 +16.732203,64 +16.733671,64 +16.735154,64 +16.736593,64 +16.738047,64 +16.739500,64 +16.740967,64 +16.742406,64 +16.743873,64 +16.745312,64 +16.746759,64 +16.748221,64 +16.749664,64 +16.751108,64 +16.752565,64 +16.754004,64 +16.755453,64 +16.756889,64 +16.758336,64 +16.759788,64 +16.761242,64 +16.762693,64 +16.764138,64 +16.765574,64 +16.767131,64 +16.768569,64 +16.770001,64 +16.771455,64 +16.772898,64 +16.774345,64 +16.775785,64 +16.777236,64 +16.778664,64 +16.780116,64 +16.781556,64 +16.783021,64 +16.784461,64 +16.785921,64 +16.787352,64 +16.788802,64 +16.790268,64 +16.791726,64 +16.793175,64 +16.794627,64 +16.796086,64 +16.797523,64 +16.798978,64 +16.800413,64 +16.801868,64 +16.803313,64 +16.804792,64 +16.806248,64 +16.807691,64 +16.809159,64 +16.810661,64 +16.812043,64 +16.813512,64 +16.814940,64 +16.816424,64 +16.817867,64 +16.819342,64 +16.820803,64 +16.822259,64 +16.823699,64 +16.825132,64 +16.826566,64 +16.828002,64 +16.829450,64 +16.830932,64 +16.832367,64 +16.833801,64 +16.835225,64 +16.836674,64 +16.838247,64 +16.839713,64 +16.841027,64 +16.842494,64 +16.843960,64 +16.845396,64 +16.846874,64 +16.848332,64 +16.849804,64 +16.851256,64 +16.852728,64 +16.854172,64 +16.855641,64 +16.857087,64 +16.858515,64 +16.859964,64 +16.861398,64 +16.862844,64 +16.864298,64 +16.865743,64 +16.867178,64 +16.868618,64 +16.870078,64 +16.871539,64 +16.872983,64 +16.874437,64 +16.875891,64 +16.877352,64 +16.878820,64 +16.880281,64 +16.881714,64 +16.883187,64 +16.884631,64 +16.886077,64 +16.887508,64 +16.888979,64 +16.890437,64 +16.891876,64 +16.893323,64 +16.894775,64 +16.896213,64 +16.897700,64 +16.899149,64 +16.900609,64 +16.902086,64 +16.903520,64 +16.905000,64 +16.906334,64 +16.907904,64 +16.909355,64 +16.910717,64 +16.912173,64 +16.913632,64 +16.915095,64 +16.916536,64 +16.917983,64 +16.919440,64 +16.920887,64 +16.922380,64 +16.923831,64 +16.925273,64 +16.926747,64 +16.928223,64 +16.929686,64 +16.931014,64 +16.932536,64 +16.933984,64 +16.935452,64 +16.936907,64 +16.938357,64 +16.939803,64 +16.941292,64 +16.942614,64 +16.944180,64 +16.945638,64 +16.947089,64 +16.948540,64 +16.949978,64 +16.951435,64 +16.952893,64 +16.954345,64 +16.955693,64 +16.957147,64 +16.958603,64 +16.960044,64 +16.961491,64 +16.963054,64 +16.964495,64 +16.965964,64 +16.967407,64 +16.968847,64 +16.970306,64 +16.971756,64 +16.973194,64 +16.974551,64 +16.975996,64 +16.977451,64 +16.978923,64 +16.980388,64 +16.981857,64 +16.983307,64 +16.984748,64 +16.986201,64 +16.987675,64 +16.989125,64 +16.990576,64 +16.992013,64 +16.993474,64 +16.994906,64 +16.996345,64 +16.997809,64 +16.999243,64 +17.000691,64 +17.002136,64 +17.003599,64 +17.005079,64 +17.006593,64 +17.008032,64 +17.009484,64 +17.010943,64 +17.012387,64 +17.013830,64 +17.015304,64 +17.016627,64 +17.018192,64 +17.019530,64 +17.021027,64 +17.022513,64 +17.023963,64 +17.025410,64 +17.026851,64 +17.028314,64 +17.029750,64 +17.031187,64 +17.032636,64 +17.034064,64 +17.035633,64 +17.037059,64 +17.038511,64 +17.039972,64 +17.041372,64 +17.042841,64 +17.044226,64 +17.045726,64 +17.047215,64 +17.048554,64 +17.050034,64 +17.051492,64 +17.052939,64 +17.054378,64 +17.055825,64 +17.057276,64 +17.058746,64 +17.060217,64 +17.061659,64 +17.063110,64 +17.064578,64 +17.066026,64 +17.067468,64 +17.068922,64 +17.070356,64 +17.071806,64 +17.073273,64 +17.074716,64 +17.076181,64 +17.077646,64 +17.079090,64 +17.080533,64 +17.082007,64 +17.083462,64 +17.084907,64 +17.086358,64 +17.087785,64 +17.089265,64 +17.090712,64 +17.092159,64 +17.093610,64 +17.095045,64 +17.096539,64 +17.097975,64 +17.099437,64 +17.100914,64 +17.102354,64 +17.103806,64 +17.105253,64 +17.106674,64 +17.108156,64 +17.109586,64 +17.111032,64 +17.112454,64 +17.113890,64 +17.115349,64 +17.116803,64 +17.118276,64 +17.119746,64 +17.121180,64 +17.122637,64 +17.124085,64 +17.125527,64 +17.126969,64 +17.128410,64 +17.129876,64 +17.131340,64 +17.132804,64 +17.134249,64 +17.135704,64 +17.137160,64 +17.138614,64 +17.140068,64 +17.141530,64 +17.143018,64 +17.144421,64 +17.145896,64 +17.147338,64 +17.148747,64 +17.150161,64 +17.151625,64 +17.153061,64 +17.154525,64 +17.155978,64 +17.157430,64 +17.158868,64 +17.160312,64 +17.161763,64 +17.163229,64 +17.164682,64 +17.166125,64 +17.167580,64 +17.169047,64 +17.170495,64 +17.171956,64 +17.173399,64 +17.174853,64 +17.176301,64 +17.177734,64 +17.179287,64 +17.180647,64 +17.182125,64 +17.183565,64 +17.184999,64 +17.186451,64 +17.187911,64 +17.189371,64 +17.190844,64 +17.192291,64 +17.193765,64 +17.195210,64 +17.196685,64 +17.198128,64 +17.199574,64 +17.201010,64 +17.202479,64 +17.203926,64 +17.205377,64 +17.206839,64 +17.208271,64 +17.209702,64 +17.211144,64 +17.212589,64 +17.214058,64 +17.215527,64 +17.216963,64 +17.218409,64 +17.219857,64 +17.221320,64 +17.222779,64 +17.224244,64 +17.225717,64 +17.227152,64 +17.228571,64 +17.230027,64 +17.231479,64 +17.232924,64 +17.234371,64 +17.235833,64 +17.237280,64 +17.238736,64 +17.240201,64 +17.241647,64 +17.243085,64 +17.244526,64 +17.245973,64 +17.247448,64 +17.248908,64 +17.250399,64 +17.251780,64 +17.253280,64 +17.254716,64 +17.256172,64 +17.257628,64 +17.259082,64 +17.260519,64 +17.261953,64 +17.263407,64 +17.264852,64 +17.266319,64 +17.267751,64 +17.269188,64 +17.270625,64 +17.272098,64 +17.273575,64 +17.275001,64 +17.276459,64 +17.277897,64 +17.279309,64 +17.280785,64 +17.282210,64 +17.283655,64 +17.285152,64 +17.286610,64 +17.288093,64 +17.289597,64 +17.291014,64 +17.292486,64 +17.293937,64 +17.295267,64 +17.296725,64 +17.298183,64 +17.299622,64 +17.301065,64 +17.302638,64 +17.304101,64 +17.305428,64 +17.306891,64 +17.308392,64 +17.309848,64 +17.311328,64 +17.312690,64 +17.314168,64 +17.315636,64 +17.317080,64 +17.318509,64 +17.319971,64 +17.321440,64 +17.322920,64 +17.324364,64 +17.325795,64 +17.327253,64 +17.328738,64 +17.330109,64 +17.331600,64 +17.333030,64 +17.334469,64 +17.335919,64 +17.337484,64 +17.338827,64 +17.340290,64 +17.341756,64 +17.343200,64 +17.344637,64 +17.346100,64 +17.347557,64 +17.349053,64 +17.350511,64 +17.351951,64 +17.353383,64 +17.354866,64 +17.356293,64 +17.357768,64 +17.359218,64 +17.360672,64 +17.362176,64 +17.363511,64 +17.364980,64 +17.366426,64 +17.367866,64 +17.369348,64 +17.370807,64 +17.372250,64 +17.373763,64 +17.375201,64 +17.376642,64 +17.378091,64 +17.379555,64 +17.381007,64 +17.382444,64 +17.383891,64 +17.385347,64 +17.386809,64 +17.388274,64 +17.389721,64 +17.391065,64 +17.392507,64 +17.393956,64 +17.395514,64 +17.396963,64 +17.398433,64 +17.399760,64 +17.401317,64 +17.402770,64 +17.404201,64 +17.405657,64 +17.407109,64 +17.408546,64 +17.409971,64 +17.411420,64 +17.412862,64 +17.414312,64 +17.415782,64 +17.417229,64 +17.418676,64 +17.420132,64 +17.421573,64 +17.423015,64 +17.424479,64 +17.425936,64 +17.427374,64 +17.428804,64 +17.430265,64 +17.431739,64 +17.433178,64 +17.434626,64 +17.436074,64 +17.437513,64 +17.438977,64 +17.440444,64 +17.441894,64 +17.443341,64 +17.444793,64 +17.446249,64 +17.447712,64 +17.449160,64 +17.450604,64 +17.452045,64 +17.453495,64 +17.454950,64 +17.456389,64 +17.457846,64 +17.459294,64 +17.460758,64 +17.462194,64 +17.463649,64 +17.465101,64 +17.466540,64 +17.467992,64 +17.469434,64 +17.470876,64 +17.472329,64 +17.473802,64 +17.475250,64 +17.476737,64 +17.478199,64 +17.479667,64 +17.481111,64 +17.482549,64 +17.484006,64 +17.485454,64 +17.486904,64 +17.488367,64 +17.489818,64 +17.491263,64 +17.492700,64 +17.494128,64 +17.495570,64 +17.497022,64 +17.498461,64 +17.499932,64 +17.501365,64 +17.502796,64 +17.504381,64 +17.505691,64 +17.507139,64 +17.508709,64 +17.510144,64 +17.511578,64 +17.513023,64 +17.514465,64 +17.515912,64 +17.517348,64 +17.518809,64 +17.520263,64 +17.521738,64 +17.523187,64 +17.524676,64 +17.526109,64 +17.527551,64 +17.529020,64 +17.530467,64 +17.531899,64 +17.533359,64 +17.534802,64 +17.536242,64 +17.537679,64 +17.539126,64 +17.540569,64 +17.542016,64 +17.543466,64 +17.544903,64 +17.546366,64 +17.547797,64 +17.549243,64 +17.550798,64 +17.552245,64 +17.553691,64 +17.555042,64 +17.556599,64 +17.557955,64 +17.559506,64 +17.560921,64 +17.562345,64 +17.563849,64 +17.565333,64 +17.566715,64 +17.568488,64 +17.569560,64 +17.571027,64 +17.572501,64 +17.574174,64 +17.575454,64 +17.576929,64 +17.578388,64 +17.579798,64 +17.581259,64 +17.582704,64 +17.584062,64 +17.585511,64 +17.587014,64 +17.588468,64 +17.589937,64 +17.591456,64 +17.592884,64 +17.594245,64 +17.595817,64 +17.597252,64 +17.598579,64 +17.600111,64 +17.601535,64 +17.603027,64 +17.604389,64 +17.605830,64 +17.607361,64 +17.608803,64 +17.610245,64 +17.611730,64 +17.613170,64 +17.614606,64 +17.616055,64 +17.617529,64 +17.618973,64 +17.620435,64 +17.621883,64 +17.623379,64 +17.624711,64 +17.626261,64 +17.627711,64 +17.629071,64 +17.630575,64 +17.632030,64 +17.633484,64 +17.634940,64 +17.636387,64 +17.637861,64 +17.639310,64 +17.640784,64 +17.642224,64 +17.643568,64 +17.645068,64 +17.646529,64 +17.647978,64 +17.650157,64 +17.650865,64 +17.652391,64 +17.653803,64 +17.655223,64 +17.656680,64 +17.658124,64 +17.659584,64 +17.661043,64 +17.662475,64 +17.663926,64 +17.665365,64 +17.666817,64 +17.668267,64 +17.669714,64 +17.671159,64 +17.672602,64 +17.674057,64 +17.675512,64 +17.676961,64 +17.678513,64 +17.679951,64 +17.681393,64 +17.682840,64 +17.684288,64 +17.685707,64 +17.687161,64 +17.688616,64 +17.690080,64 +17.691548,64 +17.693002,64 +17.694455,64 +17.695884,64 +17.697360,64 +17.698833,64 +17.700253,64 +17.701692,64 +17.703140,64 +17.704630,64 +17.706080,64 +17.707506,64 +17.708956,64 +17.710430,64 +17.711882,64 +17.713331,64 +17.714806,64 +17.716144,64 +17.717598,64 +17.719053,64 +17.720502,64 +17.721962,64 +17.723407,64 +17.724886,64 +17.726368,64 +17.727818,64 +17.729240,64 +17.730699,64 +17.732157,64 +17.733596,64 +17.735045,64 +17.736483,64 +17.737947,64 +17.739404,64 +17.740873,64 +17.742322,64 +17.743769,64 +17.745250,64 +17.746728,64 +17.748059,64 +17.749506,64 +17.751068,64 +17.752526,64 +17.753973,64 +17.755426,64 +17.756877,64 +17.758314,64 +17.759744,64 +17.761191,64 +17.762627,64 +17.764093,64 +17.765539,64 +17.766984,64 +17.768443,64 +17.769885,64 +17.771332,64 +17.772792,64 +17.774238,64 +17.775713,64 +17.777166,64 +17.778620,64 +17.780077,64 +17.781528,64 +17.782973,64 +17.784429,64 +17.785859,64 +17.787284,64 +17.788708,64 +17.790267,64 +17.791699,64 +17.793111,64 +17.794546,64 +17.795994,64 +17.797433,64 +17.798856,64 +17.800393,64 +17.801816,64 +17.803241,64 +17.804676,64 +17.806175,64 +17.807668,64 +17.809113,64 +17.810449,64 +17.811928,64 +17.813391,64 +17.814834,64 +17.816372,64 +17.817823,64 +17.819268,64 +17.820723,64 +17.822165,64 +17.823642,64 +17.824978,64 +17.826421,64 +17.827987,64 +17.829444,64 +17.830892,64 +17.832227,64 +17.833793,64 +17.835244,64 +17.836675,64 +17.838142,64 +17.839587,64 +17.840930,64 +17.842404,64 +17.843867,64 +17.845312,64 +17.846770,64 +17.848217,64 +17.849696,64 +17.851148,64 +17.852587,64 +17.854032,64 +17.855483,64 +17.856941,64 +17.858393,64 +17.859823,64 +17.861283,64 +17.862750,64 +17.864196,64 +17.865659,64 +17.867093,64 +17.868554,64 +17.870012,64 +17.871446,64 +17.872899,64 +17.874337,64 +17.875784,64 +17.877246,64 +17.878685,64 +17.880223,64 +17.881667,64 +17.883115,64 +17.884566,64 +17.886017,64 +17.887451,64 +17.888897,64 +17.890336,64 +17.891783,64 +17.893244,64 +17.894684,64 +17.896138,64 +17.897622,64 +17.899088,64 +17.900514,64 +17.901959,64 +17.903410,64 +17.904865,64 +17.906330,64 +17.907808,64 +17.909263,64 +17.910627,64 +17.912073,64 +17.913538,64 +17.914972,64 +17.916415,64 +17.917963,64 +17.919391,64 +17.920835,64 +17.922299,64 +17.923735,64 +17.925180,64 +17.926624,64 +17.928069,64 +17.929519,64 +17.930966,64 +17.932429,64 +17.933930,64 +17.935364,64 +17.936822,64 +17.938282,64 +17.939742,64 +17.941183,64 +17.942631,64 +17.944080,64 +17.945409,64 +17.946980,64 +17.948417,64 +17.949857,64 +17.951323,64 +17.952794,64 +17.954131,64 +17.955600,64 +17.957058,64 +17.958514,64 +17.959956,64 +17.961393,64 +17.962852,64 +17.964402,64 +17.965860,64 +17.967185,64 +17.968741,64 +17.970174,64 +17.971632,64 +17.973087,64 +17.974542,64 +17.976003,64 +17.977474,64 +17.978811,64 +17.980283,64 +17.981762,64 +17.983204,64 +17.984648,64 +17.986124,64 +17.987563,64 +17.989026,64 +17.990492,64 +17.991952,64 +17.993379,64 +17.994821,64 +17.996268,64 +17.997722,64 +17.999194,64 +18.000661,64 +18.002092,64 +18.003553,64 +18.005016,64 +18.006464,64 +18.007897,64 +18.009332,64 +18.010775,64 +18.012211,64 +18.013654,64 +18.015143,64 +18.016594,64 +18.018061,64 +18.019513,64 +18.020985,64 +18.022428,64 +18.023890,64 +18.025330,64 +18.026783,64 +18.028247,64 +18.029704,64 +18.031136,64 +18.032576,64 +18.034023,64 +18.035485,64 +18.036846,64 +18.038338,64 +18.039816,64 +18.041261,64 +18.042712,64 +18.044167,64 +18.045614,64 +18.047051,64 +18.048504,64 +18.049960,64 +18.051438,64 +18.052873,64 +18.054299,64 +18.055772,64 +18.057238,64 +18.058676,64 +18.060121,64 +18.061563,64 +18.063058,64 +18.064514,64 +18.065949,64 +18.067402,64 +18.068891,64 +18.070322,64 +18.071777,64 +18.073228,64 +18.074679,64 +18.076119,64 +18.077565,64 +18.078999,64 +18.080459,64 +18.081914,64 +18.083364,64 +18.084807,64 +18.086256,64 +18.087704,64 +18.089145,64 +18.090672,64 +18.092106,64 +18.093536,64 +18.094968,64 +18.096442,64 +18.097895,64 +18.099335,64 +18.100802,64 +18.102252,64 +18.103697,64 +18.105150,64 +18.106620,64 +18.108067,64 +18.109532,64 +18.110964,64 +18.112429,64 +18.113873,64 +18.115336,64 +18.116781,64 +18.118233,64 +18.119588,64 +18.121035,64 +18.122510,64 +18.123980,64 +18.125427,64 +18.126873,64 +18.128333,64 +18.129777,64 +18.131237,64 +18.132696,64 +18.134174,64 +18.135629,64 +18.137068,64 +18.138515,64 +18.140029,64 +18.141337,64 +18.142809,64 +18.144281,64 +18.145739,64 +18.147202,64 +18.148641,64 +18.150100,64 +18.151578,64 +18.153049,64 +18.154497,64 +18.155939,64 +18.157376,64 +18.158816,64 +18.160249,64 +18.161699,64 +18.163153,64 +18.164601,64 +18.166039,64 +18.167472,64 +18.168954,64 +18.170407,64 +18.171851,64 +18.173305,64 +18.174753,64 +18.176202,64 +18.177648,64 +18.179095,64 +18.180568,64 +18.182027,64 +18.183466,64 +18.184938,64 +18.186392,64 +18.187848,64 +18.189316,64 +18.190777,64 +18.192227,64 +18.193669,64 +18.195108,64 +18.196587,64 +18.198049,64 +18.199487,64 +18.200927,64 +18.202376,64 +18.203812,64 +18.205270,64 +18.206726,64 +18.208172,64 +18.209602,64 +18.211051,64 +18.212496,64 +18.213953,64 +18.215415,64 +18.216860,64 +18.218314,64 +18.219744,64 +18.221220,64 +18.222677,64 +18.224136,64 +18.225604,64 +18.227043,64 +18.228477,64 +18.229964,64 +18.231412,64 +18.232844,64 +18.234293,64 +18.235742,64 +18.237199,64 +18.238639,64 +18.240088,64 +18.241538,64 +18.242967,64 +18.244419,64 +18.245861,64 +18.247294,64 +18.248756,64 +18.250225,64 +18.251697,64 +18.253146,64 +18.254597,64 +18.256023,64 +18.257465,64 +18.258927,64 +18.260373,64 +18.261819,64 +18.263283,64 +18.264746,64 +18.266189,64 +18.267635,64 +18.269074,64 +18.270538,64 +18.271984,64 +18.273450,64 +18.274911,64 +18.276372,64 +18.277817,64 +18.279289,64 +18.280744,64 +18.282170,64 +18.283606,64 +18.285059,64 +18.286500,64 +18.287973,64 +18.289426,64 +18.290919,64 +18.292367,64 +18.293836,64 +18.295161,64 +18.296614,64 +18.298191,64 +18.299530,64 +18.300967,64 +18.302429,64 +18.303994,64 +18.305446,64 +18.306903,64 +18.308249,64 +18.309721,64 +18.311201,64 +18.312663,64 +18.314121,64 +18.315595,64 +18.317029,64 +18.318476,64 +18.319904,64 +18.321351,64 +18.322792,64 +18.324275,64 +18.325753,64 +18.327199,64 +18.328650,64 +18.330115,64 +18.331550,64 +18.333002,64 +18.334342,64 +18.335905,64 +18.337344,64 +18.338803,64 +18.340238,64 +18.341690,64 +18.343146,64 +18.344595,64 +18.346051,64 +18.347496,64 +18.348955,64 +18.350394,64 +18.351832,64 +18.353264,64 +18.354712,64 +18.356160,64 +18.357595,64 +18.359057,64 +18.360511,64 +18.361964,64 +18.363418,64 +18.364867,64 +18.366306,64 +18.367739,64 +18.369181,64 +18.370654,64 +18.372101,64 +18.373562,64 +18.375012,64 +18.376481,64 +18.377914,64 +18.379370,64 +18.380833,64 +18.382315,64 +18.383775,64 +18.385230,64 +18.386702,64 +18.388140,64 +18.389609,64 +18.391069,64 +18.392507,64 +18.393862,64 +18.395335,64 +18.396830,64 +18.398324,64 +18.399770,64 +18.401234,64 +18.402659,64 +18.404102,64 +18.405576,64 +18.407031,64 +18.408476,64 +18.409915,64 +18.411367,64 +18.412832,64 +18.414284,64 +18.415732,64 +18.417173,64 +18.418660,64 +18.420090,64 +18.421418,64 +18.422871,64 +18.424434,64 +18.425868,64 +18.427307,64 +18.428759,64 +18.430202,64 +18.431672,64 +18.433135,64 +18.434581,64 +18.436030,64 +18.437472,64 +18.438931,64 +18.440305,64 +18.441754,64 +18.443199,64 +18.444657,64 +18.446102,64 +18.447555,64 +18.449021,64 +18.450487,64 +18.451965,64 +18.453406,64 +18.454861,64 +18.456315,64 +18.457790,64 +18.459231,64 +18.460677,64 +18.462145,64 +18.463606,64 +18.465047,64 +18.466485,64 +18.467939,64 +18.469397,64 +18.470837,64 +18.472303,64 +18.473765,64 +18.475217,64 +18.476694,64 +18.478040,64 +18.479535,64 +18.480995,64 +18.482430,64 +18.483876,64 +18.485317,64 +18.486769,64 +18.488206,64 +18.489658,64 +18.491102,64 +18.492649,64 +18.494095,64 +18.495547,64 +18.497002,64 +18.498471,64 +18.499801,64 +18.501258,64 +18.502718,64 +18.504201,64 +18.505675,64 +18.507143,64 +18.508592,64 +18.510026,64 +18.511465,64 +18.512909,64 +18.514338,64 +18.515816,64 +18.517261,64 +18.518736,64 +18.520193,64 +18.521661,64 +18.523116,64 +18.524562,64 +18.526013,64 +18.527448,64 +18.528886,64 +18.530343,64 +18.531805,64 +18.533246,64 +18.534681,64 +18.536151,64 +18.537595,64 +18.539037,64 +18.540477,64 +18.541924,64 +18.543369,64 +18.544829,64 +18.546298,64 +18.547782,64 +18.549213,64 +18.550661,64 +18.552139,64 +18.553581,64 +18.555033,64 +18.556478,64 +18.557920,64 +18.559354,64 +18.560819,64 +18.562300,64 +18.563670,64 +18.565115,64 +18.566561,64 +18.568012,64 +18.569479,64 +18.570932,64 +18.572397,64 +18.573838,64 +18.575325,64 +18.576771,64 +18.578212,64 +18.579655,64 +18.581127,64 +18.582578,64 +18.584027,64 +18.585465,64 +18.586917,64 +18.588369,64 +18.589855,64 +18.591322,64 +18.592786,64 +18.594214,64 +18.595664,64 +18.597121,64 +18.598581,64 +18.600046,64 +18.601378,64 +18.602832,64 +18.604279,64 +18.605844,64 +18.607191,64 +18.608658,64 +18.610125,64 +18.611576,64 +18.613059,64 +18.614507,64 +18.615935,64 +18.617382,64 +18.618906,64 +18.620358,64 +18.621794,64 +18.623270,64 +18.624720,64 +18.626045,64 +18.627619,64 +18.629075,64 +18.630525,64 +18.631981,64 +18.633409,64 +18.634872,64 +18.636201,64 +18.637679,64 +18.639147,64 +18.640620,64 +18.642070,64 +18.643540,64 +18.645026,64 +18.646493,64 +18.647937,64 +18.649275,64 +18.650727,64 +18.652187,64 +18.653662,64 +18.655106,64 +18.656628,64 +18.658008,64 +18.659451,64 +18.660893,64 +18.662327,64 +18.663887,64 +18.665358,64 +18.666795,64 +18.668212,64 +18.669644,64 +18.671094,64 +18.672531,64 +18.673980,64 +18.675442,64 +18.676885,64 +18.678331,64 +18.679789,64 +18.681232,64 +18.682721,64 +18.684155,64 +18.685590,64 +18.687084,64 +18.688554,64 +18.689953,64 +18.691402,64 +18.692848,64 +18.694346,64 +18.695792,64 +18.697224,64 +18.698664,64 +18.700132,64 +18.701594,64 +18.703014,64 +18.704465,64 +18.705931,64 +18.707378,64 +18.708830,64 +18.710305,64 +18.711769,64 +18.713222,64 +18.714643,64 +18.716067,64 +18.717494,64 +18.718924,64 +18.720484,64 +18.721936,64 +18.723374,64 +18.724807,64 +18.726256,64 +18.727693,64 +18.729107,64 +18.730654,64 +18.732084,64 +18.733523,64 +18.734943,64 +18.736366,64 +18.737903,64 +18.739317,64 +18.740746,64 +18.742168,64 +18.743713,64 +18.745129,64 +18.746544,64 +18.747991,64 +18.749416,64 +18.750855,64 +18.752336,64 +18.753784,64 +18.755278,64 +18.756731,64 +18.758183,64 +18.759650,64 +18.761077,64 +18.762515,64 +18.763980,64 +18.765414,64 +18.766866,64 +18.768316,64 +18.769786,64 +18.771226,64 +18.772692,64 +18.774146,64 +18.775602,64 +18.777025,64 +18.778480,64 +18.779901,64 +18.781351,64 +18.782804,64 +18.784277,64 +18.785742,64 +18.787219,64 +18.788668,64 +18.790111,64 +18.791558,64 +18.793010,64 +18.794469,64 +18.795903,64 +18.797350,64 +18.798808,64 +18.800276,64 +18.801747,64 +18.803183,64 +18.804620,64 +18.806092,64 +18.807552,64 +18.809016,64 +18.810372,64 +18.811865,64 +18.813331,64 +18.814797,64 +18.816229,64 +18.817661,64 +18.819099,64 +18.820589,64 +18.822041,64 +18.823493,64 +18.824946,64 +18.826382,64 +18.827812,64 +18.829271,64 +18.830726,64 +18.832194,64 +18.833631,64 +18.835089,64 +18.836526,64 +18.837989,64 +18.839457,64 +18.840928,64 +18.842278,64 +18.843861,64 +18.845321,64 +18.846760,64 +18.848208,64 +18.849658,64 +18.850989,64 +18.852442,64 +18.854008,64 +18.855366,64 +18.856829,64 +18.858287,64 +18.859754,64 +18.861199,64 +18.862669,64 +18.864093,64 +18.865552,64 +18.867029,64 +18.868469,64 +18.869915,64 +18.871421,64 +18.872886,64 +18.874332,64 +18.875772,64 +18.877228,64 +18.878669,64 +18.880125,64 +18.881580,64 +18.882915,64 +18.884479,64 +18.885933,64 +18.887383,64 +18.888845,64 +18.890199,64 +18.891649,64 +18.893104,64 +18.894548,64 +18.895988,64 +18.897427,64 +18.898995,64 +18.900435,64 +18.901873,64 +18.903316,64 +18.904772,64 +18.906275,64 +18.907587,64 +18.909147,64 +18.910585,64 +18.912053,64 +18.913439,64 +18.914906,64 +18.916354,64 +18.917776,64 +18.919303,64 +18.920708,64 +18.922120,64 +18.923559,64 +18.925118,64 +18.926555,64 +18.928007,64 +18.929482,64 +18.930833,64 +18.932302,64 +18.933795,64 +18.935244,64 +18.936684,64 +18.938140,64 +18.939584,64 +18.941037,64 +18.942505,64 +18.943881,64 +18.945343,64 +18.946801,64 +18.948332,64 +18.949764,64 +18.951184,64 +18.952628,64 +18.954083,64 +18.955526,64 +18.956940,64 +18.958487,64 +18.959927,64 +18.961343,64 +18.962806,64 +18.964244,64 +18.965675,64 +18.967139,64 +18.968569,64 +18.970107,64 +18.971533,64 +18.972967,64 +18.974406,64 +18.975840,64 +18.977279,64 +18.978747,64 +18.980176,64 +18.981599,64 +18.983169,64 +18.984616,64 +18.986034,64 +18.987451,64 +18.988878,64 +18.990322,64 +18.991876,64 +18.993321,64 +18.994747,64 +18.996188,64 +18.997607,64 +18.999038,64 +19.000466,64 +19.001920,64 +19.003383,64 +19.004880,64 +19.006317,64 +19.007723,64 +19.009269,64 +19.010689,64 +19.012134,64 +19.013575,64 +19.015022,64 +19.016470,64 +19.017904,64 +19.019387,64 +19.020817,64 +19.022259,64 +19.023681,64 +19.025137,64 +19.026689,64 +19.028108,64 +19.029527,64 +19.030936,64 +19.032482,64 +19.033907,64 +19.035325,64 +19.036864,64 +19.038298,64 +19.039761,64 +19.041215,64 +19.042629,64 +19.044044,64 +19.045485,64 +19.046954,64 +19.048413,64 +19.049855,64 +19.051295,64 +19.052748,64 +19.054174,64 +19.055737,64 +19.057164,64 +19.058643,64 +19.059983,64 +19.061441,64 +19.062902,64 +19.064409,64 +19.065870,64 +19.067290,64 +19.068710,64 +19.070131,64 +19.071589,64 +19.073051,64 +19.074501,64 +19.076036,64 +19.077468,64 +19.078909,64 +19.080334,64 +19.081754,64 +19.083298,64 +19.084728,64 +19.086151,64 +19.087578,64 +19.089003,64 +19.090555,64 +19.091981,64 +19.093398,64 +19.094822,64 +19.096243,64 +19.097811,64 +19.099236,64 +19.100652,64 +19.102078,64 +19.103497,64 +19.105074,64 +19.106537,64 +19.107963,64 +19.109374,64 +19.110787,64 +19.112227,64 +19.113682,64 +19.115156,64 +19.116623,64 +19.118043,64 +19.119573,64 +19.120979,64 +19.122391,64 +19.123936,64 +19.125387,64 +19.126851,64 +19.128177,64 +19.129738,64 +19.131156,64 +19.132576,64 +19.133992,64 +19.135549,64 +19.136977,64 +19.138438,64 +19.139864,64 +19.141301,64 +19.142723,64 +19.144179,64 +19.145609,64 +19.147159,64 +19.148504,64 +19.150057,64 +19.151486,64 +19.152907,64 +19.154318,64 +19.155766,64 +19.157200,64 +19.158744,64 +19.160188,64 +19.161617,64 +19.163040,64 +19.164472,64 +19.165909,64 +19.167461,64 +19.168885,64 +19.170328,64 +19.171758,64 +19.173180,64 +19.174723,64 +19.176132,64 +19.177545,64 +19.178957,64 +19.180427,64 +19.181879,64 +19.183334,64 +19.184876,64 +19.186292,64 +19.187729,64 +19.189141,64 +19.190683,64 +19.192110,64 +19.193555,64 +19.194976,64 +19.196396,64 +19.197851,64 +19.199286,64 +19.200855,64 +19.202296,64 +19.203714,64 +19.205137,64 +19.206577,64 +19.208000,64 +19.209437,64 +19.210997,64 +19.212413,64 +19.213837,64 +19.215265,64 +19.216809,64 +19.218218,64 +19.219633,64 +19.221053,64 +19.222508,64 +19.223965,64 +19.225520,64 +19.226976,64 +19.228426,64 +19.229883,64 +19.231216,64 +19.232760,64 +19.234185,64 +19.235607,64 +19.237131,64 +19.238570,64 +19.240016,64 +19.241494,64 +19.242949,64 +19.244401,64 +19.245731,64 +19.247209,64 +19.248646,64 +19.250086,64 +19.251643,64 +19.253087,64 +19.254532,64 +19.255984,64 +19.257441,64 +19.258891,64 +19.260348,64 +19.261818,64 +19.263249,64 +19.264611,64 +19.266056,64 +19.267507,64 +19.268983,64 +19.270422,64 +19.271860,64 +19.273412,64 +19.274848,64 +19.276286,64 +19.277761,64 +19.279198,64 +19.280631,64 +19.282130,64 +19.283471,64 +19.284932,64 +19.286394,64 +19.287864,64 +19.289377,64 +19.290811,64 +19.292242,64 +19.293682,64 +19.295108,64 +19.296535,64 +19.297963,64 +19.299505,64 +19.300929,64 +19.302374,64 +19.303805,64 +19.305260,64 +19.306782,64 +19.308231,64 +19.309577,64 +19.311106,64 +19.312546,64 +19.313991,64 +19.315435,64 +19.316873,64 +19.318337,64 +19.319793,64 +19.321259,64 +19.322757,64 +19.324096,64 +19.325550,64 +19.327093,64 +19.328537,64 +19.329974,64 +19.331417,64 +19.332854,64 +19.334272,64 +19.335699,64 +19.337238,64 +19.338660,64 +19.340082,64 +19.341622,64 +19.343028,64 +19.344440,64 +19.345856,64 +19.347401,64 +19.348843,64 +19.350285,64 +19.351719,64 +19.353173,64 +19.354646,64 +19.356103,64 +19.357546,64 +19.358981,64 +19.360421,64 +19.361885,64 +19.363362,64 +19.364853,64 +19.366300,64 +19.367750,64 +19.369194,64 +19.370628,64 +19.372101,64 +19.373449,64 +19.374918,64 +19.376394,64 +19.377802,64 +19.379273,64 +19.380710,64 +19.382147,64 +19.383711,64 +19.385151,64 +19.386567,64 +19.387996,64 +19.389466,64 +19.390948,64 +19.392397,64 +19.393863,64 +19.395199,64 +19.396778,64 +19.398241,64 +19.399572,64 +19.401024,64 +19.402460,64 +19.403914,64 +19.405389,64 +19.406845,64 +19.408295,64 +19.409750,64 +19.411210,64 +19.412666,64 +19.414140,64 +19.415596,64 +19.417026,64 +19.418498,64 +19.419960,64 +19.421439,64 +19.422784,64 +19.424254,64 +19.425722,64 +19.427169,64 +19.428604,64 +19.430057,64 +19.431489,64 +19.432953,64 +19.434414,64 +19.435853,64 +19.437304,64 +19.438805,64 +19.440274,64 +19.441721,64 +19.443200,64 +19.444659,64 +19.446115,64 +19.447542,64 +19.448966,64 +19.450376,64 +19.451806,64 +19.453340,64 +19.454767,64 +19.456183,64 +19.457647,64 +19.459070,64 +19.460620,64 +19.462055,64 +19.463474,64 +19.464910,64 +19.466325,64 +19.467873,64 +19.469308,64 +19.470742,64 +19.472174,64 +19.473604,64 +19.475026,64 +19.476473,64 +19.478000,64 +19.479444,64 +19.480874,64 +19.482314,64 +19.483731,64 +19.485266,64 +19.486681,64 +19.488161,64 +19.489625,64 +19.491103,64 +19.492427,64 +19.494007,64 +19.495331,64 +19.496913,64 +19.498394,64 +19.499719,64 +19.501188,64 +19.502642,64 +19.504104,64 +19.505559,64 +19.507014,64 +19.508443,64 +19.509889,64 +19.511324,64 +19.512770,64 +19.514256,64 +19.515709,64 +19.517159,64 +19.518603,64 +19.520067,64 +19.521533,64 +19.522980,64 +19.524426,64 +19.525878,64 +19.527328,64 +19.528773,64 +19.530224,64 +19.531687,64 +19.533127,64 +19.534608,64 +19.536054,64 +19.537493,64 +19.538950,64 +19.540406,64 +19.541862,64 +19.543293,64 +19.544749,64 +19.546203,64 +19.547645,64 +19.549074,64 +19.550528,64 +19.551960,64 +19.553403,64 +19.554862,64 +19.556318,64 +19.557772,64 +19.559211,64 +19.560679,64 +19.562122,64 +19.563568,64 +19.565037,64 +19.566491,64 +19.567965,64 +19.569436,64 +19.570883,64 +19.572339,64 +19.573798,64 +19.575251,64 +19.576708,64 +19.578187,64 +19.579547,64 +19.580992,64 +19.582425,64 +19.583970,64 +19.585388,64 +19.586809,64 +19.588222,64 +19.589769,64 +19.591185,64 +19.592623,64 +19.594073,64 +19.595501,64 +19.596932,64 +19.598475,64 +19.599884,64 +19.601316,64 +19.602743,64 +19.604199,64 +19.605677,64 +19.607134,64 +19.608573,64 +19.610030,64 +19.611504,64 +19.612935,64 +19.614384,64 +19.615831,64 +19.617260,64 +19.618804,64 +19.620253,64 +19.621695,64 +19.623122,64 +19.624558,64 +19.625978,64 +19.627429,64 +19.629625,64 +19.630339,64 +19.631774,64 +19.633209,64 +19.634713,64 +19.636137,64 +19.637570,64 +19.639005,64 +19.640483,64 +19.642041,64 +19.643460,64 +19.644930,64 +19.646377,64 +19.647829,64 +19.649299,64 +19.650628,64 +19.652087,64 +19.653563,64 +19.655040,64 +19.656519,64 +19.657963,64 +19.659375,64 +19.660900,64 +19.662251,64 +19.663728,64 +19.665175,64 +19.666625,64 +19.668083,64 +19.669529,64 +19.670962,64 +19.672409,64 +19.673995,64 +19.675336,64 +19.676787,64 +19.678237,64 +19.679688,64 +19.681149,64 +19.682612,64 +19.684041,64 +19.685557,64 +19.686965,64 +19.688432,64 +19.689864,64 +19.691323,64 +19.692767,64 +19.694174,64 +19.695656,64 +19.697161,64 +19.698509,64 +19.699965,64 +19.701543,64 +19.702980,64 +19.704428,64 +19.705890,64 +19.707334,64 +19.708781,64 +19.710233,64 +19.711689,64 +19.713142,64 +19.714477,64 +19.715997,64 +19.717460,64 +19.718929,64 +19.720366,64 +19.721784,64 +19.723198,64 +19.724628,64 +19.726164,64 +19.727584,64 +19.729006,64 +19.730458,64 +19.732001,64 +19.733411,64 +19.734864,64 +19.736286,64 +19.737816,64 +19.739259,64 +19.740603,64 +19.742074,64 +19.743500,64 +19.744969,64 +19.746520,64 +19.747979,64 +19.749416,64 +19.750859,64 +19.752291,64 +19.753739,64 +19.755187,64 +19.756627,64 +19.758068,64 +19.759509,64 +19.760955,64 +19.762407,64 +19.763867,64 +19.765340,64 +19.766775,64 +19.768214,64 +19.769673,64 +19.771142,64 +19.772637,64 +19.774072,64 +19.775515,64 +19.776960,64 +19.778324,64 +19.779903,64 +19.781355,64 +19.782789,64 +19.784225,64 +19.785685,64 +19.787120,64 +19.788588,64 +19.790054,64 +19.791388,64 +19.792960,64 +19.794397,64 +19.795842,64 +19.797293,64 +19.798771,64 +19.800220,64 +19.801665,64 +19.803109,64 +19.804568,64 +19.805997,64 +19.807448,64 +19.808909,64 +19.810277,64 +19.811764,64 +19.813210,64 +19.814661,64 +19.816098,64 +19.817550,64 +19.818980,64 +19.820451,64 +19.821921,64 +19.823407,64 +19.824863,64 +19.826320,64 +19.827764,64 +19.829246,64 +19.830692,64 +19.832021,64 +19.833494,64 +19.834959,64 +19.836406,64 +19.837847,64 +19.839301,64 +19.840780,64 +19.842239,64 +19.843719,64 +19.845141,64 +19.846556,64 +19.848110,64 +19.849536,64 +19.850945,64 +19.852359,64 +19.853798,64 +19.855366,64 +19.856697,64 +19.858171,64 +19.859641,64 +19.861112,64 +19.862582,64 +19.864025,64 +19.865449,64 +19.866857,64 +19.868412,64 +19.869879,64 +19.871321,64 +19.872764,64 +19.874205,64 +19.875660,64 +19.877080,64 +19.878498,64 +19.879923,64 +19.881465,64 +19.882892,64 +19.884318,64 +19.885753,64 +19.887184,64 +19.888725,64 +19.890170,64 +19.891608,64 +19.893058,64 +19.894490,64 +19.895959,64 +19.897394,64 +19.898833,64 +19.900262,64 +19.901717,64 +19.903158,64 +19.904598,64 +19.906036,64 +19.907498,64 +19.908961,64 +19.910398,64 +19.911848,64 +19.913293,64 +19.914776,64 +19.916222,64 +19.917764,64 +19.919195,64 +19.920638,64 +19.922099,64 +19.923458,64 +19.924916,64 +19.926465,64 +19.927918,64 +19.929366,64 +19.930808,64 +19.932159,64 +19.933643,64 +19.935110,64 +19.936541,64 +19.938001,64 +19.939465,64 +19.940938,64 +19.942416,64 +19.943818,64 +19.945299,64 +19.946744,64 +19.948222,64 +19.949667,64 +19.951119,64 +19.952605,64 +19.953937,64 +19.955419,64 +19.956923,64 +19.958394,64 +19.959858,64 +19.961197,64 +19.962667,64 +19.964127,64 +19.965621,64 +19.967052,64 +19.968532,64 +19.969979,64 +19.971444,64 +19.972905,64 +19.974347,64 +19.975792,64 +19.977253,64 +19.978607,64 +19.980097,64 +19.981528,64 +19.982990,64 +19.984464,64 +19.985966,64 +19.987420,64 +19.988870,64 +19.990341,64 +19.991752,64 +19.993196,64 +19.994631,64 +19.996115,64 +19.997563,64 +19.999030,64 +20.000366,64 +20.001826,64 +20.003375,64 +20.004784,64 +20.006235,64 +20.007675,64 +20.009193,64 +20.010634,64 +20.012080,64 +20.013446,64 +20.014922,64 +20.016377,64 +20.017867,64 +20.019311,64 +20.020798,64 +20.022256,64 +20.023623,64 +20.025092,64 +20.026581,64 +20.028059,64 +20.029497,64 +20.030937,64 +20.032411,64 +20.033850,64 +20.035282,64 +20.036750,64 +20.038198,64 +20.039658,64 +20.041087,64 +20.042523,64 +20.043959,64 +20.045389,64 +20.046844,64 +20.048292,64 +20.049731,64 +20.051194,64 +20.052640,64 +20.054094,64 +20.055555,64 +20.057011,64 +20.058462,64 +20.059915,64 +20.061361,64 +20.062800,64 +20.064253,64 +20.065731,64 +20.067227,64 +20.068691,64 +20.070148,64 +20.071472,64 +20.072923,64 +20.074503,64 +20.075943,64 +20.077380,64 +20.078813,64 +20.080270,64 +20.081709,64 +20.083179,64 +20.084650,64 +20.086109,64 +20.087444,64 +20.088894,64 +20.090380,64 +20.091823,64 +20.093267,64 +20.094720,64 +20.096167,64 +20.097725,64 +20.099055,64 +20.100636,64 +20.101970,64 +20.103416,64 +20.104985,64 +20.106422,64 +20.107875,64 +20.109214,64 +20.110676,64 +20.112224,64 +20.113672,64 +20.115032,64 +20.116495,64 +20.117949,64 +20.119386,64 +20.120831,64 +20.122279,64 +20.123844,64 +20.125279,64 +20.126725,64 +20.128140,64 +20.129622,64 +20.131085,64 +20.132544,64 +20.134003,64 +20.135457,64 +20.136890,64 +20.138345,64 +20.139708,64 +20.141208,64 +20.142678,64 +20.144129,64 +20.145587,64 +20.147054,64 +20.148519,64 +20.149960,64 +20.151407,64 +20.152858,64 +20.154300,64 +20.155687,64 +20.157153,64 +20.158601,64 +20.160055,64 +20.161510,64 +20.162961,64 +20.164399,64 +20.165848,64 +20.167286,64 +20.168730,64 +20.170185,64 +20.171635,64 +20.173119,64 +20.174563,64 +20.176009,64 +20.177440,64 +20.178890,64 +20.180336,64 +20.181891,64 +20.183333,64 +20.184779,64 +20.186223,64 +20.187664,64 +20.189117,64 +20.190564,64 +20.192011,64 +20.193494,64 +20.194930,64 +20.196390,64 +20.197851,64 +20.199304,64 +20.200733,64 +20.202197,64 +20.203628,64 +20.205090,64 +20.206518,64 +20.207988,64 +20.209422,64 +20.210866,64 +20.212321,64 +20.213760,64 +20.215219,64 +20.216661,64 +20.218098,64 +20.219546,64 +20.221018,64 +20.222487,64 +20.223947,64 +20.225387,64 +20.226824,64 +20.228263,64 +20.229706,64 +20.231169,64 +20.232639,64 +20.234082,64 +20.235518,64 +20.236975,64 +20.238432,64 +20.239881,64 +20.241326,64 +20.242773,64 +20.244212,64 +20.245650,64 +20.247105,64 +20.248553,64 +20.250027,64 +20.251480,64 +20.252948,64 +20.254416,64 +20.255868,64 +20.257335,64 +20.258778,64 +20.260215,64 +20.261671,64 +20.263110,64 +20.264597,64 +20.266053,64 +20.267507,64 +20.268968,64 +20.270304,64 +20.271781,64 +20.273222,64 +20.274679,64 +20.276132,64 +20.277567,64 +20.279015,64 +20.280583,64 +20.281919,64 +20.283393,64 +20.284832,64 +20.286267,64 +20.287823,64 +20.289257,64 +20.290703,64 +20.292144,64 +20.293587,64 +20.295042,64 +20.296474,64 +20.297925,64 +20.299372,64 +20.300822,64 +20.302259,64 +20.303714,64 +20.305177,64 +20.306640,64 +20.308087,64 +20.309532,64 +20.311048,64 +20.312390,64 +20.313862,64 +20.315326,64 +20.316787,64 +20.318254,64 +20.319720,64 +20.321178,64 +20.322619,64 +20.324087,64 +20.325533,64 +20.326989,64 +20.328443,64 +20.329913,64 +20.331252,64 +20.332732,64 +20.334187,64 +20.335659,64 +20.337106,64 +20.338529,64 +20.339986,64 +20.341436,64 +20.342888,64 +20.344338,64 +20.345810,64 +20.347257,64 +20.348723,64 +20.350153,64 +20.351599,64 +20.353046,64 +20.354495,64 +20.355939,64 +20.357418,64 +20.358870,64 +20.360324,64 +20.361755,64 +20.363191,64 +20.364659,64 +20.366104,64 +20.367531,64 +20.369103,64 +20.370555,64 +20.371984,64 +20.373501,64 +20.374829,64 +20.376277,64 +20.377734,64 +20.379179,64 +20.380656,64 +20.382136,64 +20.383583,64 +20.385013,64 +20.386452,64 +20.387911,64 +20.389355,64 +20.390835,64 +20.392284,64 +20.393739,64 +20.395200,64 +20.396634,64 +20.398094,64 +20.399560,64 +20.401026,64 +20.402362,64 +20.403927,64 +20.405274,64 +20.406735,64 +20.408193,64 +20.409634,64 +20.411112,64 +20.412556,64 +20.414010,64 +20.415443,64 +20.416874,64 +20.418323,64 +20.419884,64 +20.421324,64 +20.422766,64 +20.424227,64 +20.425689,64 +20.427152,64 +20.428602,64 +20.430051,64 +20.431500,64 +20.432952,64 +20.434391,64 +20.435853,64 +20.437194,64 +20.438652,64 +20.440095,64 +20.441555,64 +20.443000,64 +20.444445,64 +20.445916,64 +20.447358,64 +20.448826,64 +20.450277,64 +20.451736,64 +20.453187,64 +20.454617,64 +20.456070,64 +20.457534,64 +20.458999,64 +20.460475,64 +20.461936,64 +20.463427,64 +20.464780,64 +20.466247,64 +20.467700,64 +20.469222,64 +20.470654,64 +20.472080,64 +20.473517,64 +20.474968,64 +20.476412,64 +20.477860,64 +20.479342,64 +20.480781,64 +20.482208,64 +20.483639,64 +20.485075,64 +20.486649,64 +20.488067,64 +20.489473,64 +20.490903,64 +20.492347,64 +20.493795,64 +20.495360,64 +20.496806,64 +20.498259,64 +20.499706,64 +20.501153,64 +20.502580,64 +20.504023,64 +20.505519,64 +20.506965,64 +20.508412,64 +20.509853,64 +20.511300,64 +20.512747,64 +20.514196,64 +20.515686,64 +20.517018,64 +20.518477,64 +20.519953,64 +20.521411,64 +20.522863,64 +20.524300,64 +20.525779,64 +20.527227,64 +20.528662,64 +20.530124,64 +20.531598,64 +20.533054,64 +20.534482,64 +20.535935,64 +20.537424,64 +20.538890,64 +20.540348,64 +20.541791,64 +20.543229,64 +20.544657,64 +20.546100,64 +20.547562,64 +20.549024,64 +20.550471,64 +20.551910,64 +20.553361,64 +20.554801,64 +20.556250,64 +20.557715,64 +20.559168,64 +20.560614,64 +20.562048,64 +20.563504,64 +20.564948,64 +20.566382,64 +20.567841,64 +20.569288,64 +20.570798,64 +20.572259,64 +20.573715,64 +20.575152,64 +20.576597,64 +20.578062,64 +20.579519,64 +20.580966,64 +20.582427,64 +20.583873,64 +20.585300,64 +20.586749,64 +20.588210,64 +20.589654,64 +20.591115,64 +20.592550,64 +20.593982,64 +20.595425,64 +20.596897,64 +20.598356,64 +20.599799,64 +20.601247,64 +20.602681,64 +20.604113,64 +20.605558,64 +20.607013,64 +20.608483,64 +20.609943,64 +20.611392,64 +20.612843,64 +20.614303,64 +20.615764,64 +20.617207,64 +20.618642,64 +20.620075,64 +20.621552,64 +20.623016,64 +20.624456,64 +20.625916,64 +20.627388,64 +20.628826,64 +20.630265,64 +20.631719,64 +20.633168,64 +20.634626,64 +20.636079,64 +20.637521,64 +20.638994,64 +20.640398,64 +20.641877,64 +20.643362,64 +20.644847,64 +20.646184,64 +20.647666,64 +20.649169,64 +20.650611,64 +20.652076,64 +20.653525,64 +20.654876,64 +20.656433,64 +20.657858,64 +20.659245,64 +20.660789,64 +20.662198,64 +20.663608,64 +20.665153,64 +20.666563,64 +20.667982,64 +20.669414,64 +20.670850,64 +20.672321,64 +20.673754,64 +20.675201,64 +20.676768,64 +20.678090,64 +20.679540,64 +20.681110,64 +20.682563,64 +20.684016,64 +20.685447,64 +20.686873,64 +20.688312,64 +20.689747,64 +20.691192,64 +20.692645,64 +20.694095,64 +20.695554,64 +20.697039,64 +20.698506,64 +20.699955,64 +20.701383,64 +20.702819,64 +20.704258,64 +20.705703,64 +20.707216,64 +20.708611,64 +20.710045,64 +20.711508,64 +20.712958,64 +20.714396,64 +20.715843,64 +20.717281,64 +20.718721,64 +20.720298,64 +20.721639,64 +20.723137,64 +20.724664,64 +20.726098,64 +20.727478,64 +20.728928,64 +20.730441,64 +20.731899,64 +20.733369,64 +20.734714,64 +20.736164,64 +20.737607,64 +20.739060,64 +20.740532,64 +20.741997,64 +20.743434,64 +20.744866,64 +20.746333,64 +20.747761,64 +20.749326,64 +20.750765,64 +20.752211,64 +20.753665,64 +20.755142,64 +20.756584,64 +20.757915,64 +20.759493,64 +20.760823,64 +20.762290,64 +20.763721,64 +20.765178,64 +20.766626,64 +20.768117,64 +20.769567,64 +20.771030,64 +20.772483,64 +20.773917,64 +20.775360,64 +20.776800,64 +20.778252,64 +20.779716,64 +20.781172,64 +20.782623,64 +20.784076,64 +20.785517,64 +20.786962,64 +20.788423,64 +20.789872,64 +20.791332,64 +20.792782,64 +20.794212,64 +20.795648,64 +20.797109,64 +20.798545,64 +20.800006,64 +20.801452,64 +20.803004,64 +20.804435,64 +20.805821,64 +20.807287,64 +20.808736,64 +20.810230,64 +20.811725,64 +20.813186,64 +20.814630,64 +20.816072,64 +20.817502,64 +20.818942,64 +20.820392,64 +20.821839,64 +20.823276,64 +20.824714,64 +20.826163,64 +20.827597,64 +20.829045,64 +20.830590,64 +20.832060,64 +20.833379,64 +20.834825,64 +20.836400,64 +20.837839,64 +20.839289,64 +20.840746,64 +20.842205,64 +20.843672,64 +20.844995,64 +20.846453,64 +20.847945,64 +20.849412,64 +20.850872,64 +20.852311,64 +20.853736,64 +20.855190,64 +20.856645,64 +20.858105,64 +20.859537,64 +20.860995,64 +20.862463,64 +20.863926,64 +20.865363,64 +20.866805,64 +20.868263,64 +20.869726,64 +20.871168,64 +20.872612,64 +20.874076,64 +20.875525,64 +20.876959,64 +20.878413,64 +20.879867,64 +20.881320,64 +20.882800,64 +20.884244,64 +20.885669,64 +20.887136,64 +20.888589,64 +20.890071,64 +20.891528,64 +20.892970,64 +20.894430,64 +20.895890,64 +20.897341,64 +20.898796,64 +20.900264,64 +20.901694,64 +20.903148,64 +20.904639,64 +20.905947,64 +20.907500,64 +20.908919,64 +20.910382,64 +20.911824,64 +20.913278,64 +20.914719,64 +20.916200,64 +20.917648,64 +20.919085,64 +20.920535,64 +20.921990,64 +20.923437,64 +20.924890,64 +20.926320,64 +20.927777,64 +20.929212,64 +20.930659,64 +20.932119,64 +20.933636,64 +20.935082,64 +20.936562,64 +20.937974,64 +20.939360,64 +20.940791,64 +20.942246,64 +20.943743,64 +20.945176,64 +20.946620,64 +20.948112,64 +20.949548,64 +20.950987,64 +20.952422,64 +20.953886,64 +20.955340,64 +20.956805,64 +20.958201,64 +20.959669,64 +20.961186,64 +20.962694,64 +20.964003,64 +20.965551,64 +20.967081,64 +20.968405,64 +20.969875,64 +20.971343,64 +20.972796,64 +20.974270,64 +20.975603,64 +20.977085,64 +20.978571,64 +20.980036,64 +20.981397,64 +20.982872,64 +20.984329,64 +20.985824,64 +20.987286,64 +20.988727,64 +20.990190,64 +20.991656,64 +20.993108,64 +20.994571,64 +20.996025,64 +20.997462,64 +20.998916,64 +21.000380,64 +21.001817,64 +21.003283,64 +21.004743,64 +21.006198,64 +21.007646,64 +21.009091,64 +21.010545,64 +21.011991,64 +21.013430,64 +21.014863,64 +21.016302,64 +21.017765,64 +21.019229,64 +21.020670,64 +21.022120,64 +21.023579,64 +21.025011,64 +21.026460,64 +21.027923,64 +21.029373,64 +21.030833,64 +21.032273,64 +21.033705,64 +21.035159,64 +21.036612,64 +21.038042,64 +21.039544,64 +21.040996,64 +21.042429,64 +21.043868,64 +21.045311,64 +21.046831,64 +21.048177,64 +21.049633,64 +21.051109,64 +21.052558,64 +21.054007,64 +21.055456,64 +21.056913,64 +21.058354,64 +21.059795,64 +21.061257,64 +21.062714,64 +21.064155,64 +21.065631,64 +21.067068,64 +21.068511,64 +21.069946,64 +21.071397,64 +21.072880,64 +21.074334,64 +21.075768,64 +21.077215,64 +21.078654,64 +21.080113,64 +21.081584,64 +21.083047,64 +21.084492,64 +21.085927,64 +21.087400,64 +21.088854,64 +21.090290,64 +21.091755,64 +21.093202,64 +21.094649,64 +21.096085,64 +21.097554,64 +21.099015,64 +21.100454,64 +21.101939,64 +21.103378,64 +21.104823,64 +21.106297,64 +21.107726,64 +21.109185,64 +21.110635,64 +21.112074,64 +21.113546,64 +21.115020,64 +21.116478,64 +21.117945,64 +21.119387,64 +21.120711,64 +21.122198,64 +21.123655,64 +21.125100,64 +21.126565,64 +21.128000,64 +21.129455,64 +21.130923,64 +21.132395,64 +21.133844,64 +21.135281,64 +21.136724,64 +21.138180,64 +21.139627,64 +21.141110,64 +21.142574,64 +21.144042,64 +21.145394,64 +21.146861,64 +21.148299,64 +21.149857,64 +21.151303,64 +21.152773,64 +21.154209,64 +21.155653,64 +21.157004,64 +21.158453,64 +21.160017,64 +21.161355,64 +21.162821,64 +21.164298,64 +21.165749,64 +21.167195,64 +21.168664,64 +21.170107,64 +21.171580,64 +21.173026,64 +21.174473,64 +21.175939,64 +21.177372,64 +21.178822,64 +21.180309,64 +21.181758,64 +21.183204,64 +21.184652,64 +21.186087,64 +21.187527,64 +21.189012,64 +21.190470,64 +21.191922,64 +21.193368,64 +21.194819,64 +21.196304,64 +21.197750,64 +21.199210,64 +21.200641,64 +21.202082,64 +21.203514,64 +21.204966,64 +21.206407,64 +21.207887,64 +21.209333,64 +21.210777,64 +21.212229,64 +21.213663,64 +21.215129,64 +21.216591,64 +21.218030,64 +21.219502,64 +21.220935,64 +21.222420,64 +21.223876,64 +21.225309,64 +21.226766,64 +21.228124,64 +21.229585,64 +21.231073,64 +21.232517,64 +21.233981,64 +21.235439,64 +21.236885,64 +21.238314,64 +21.239766,64 +21.241223,64 +21.242688,64 +21.244132,64 +21.245571,64 +21.247028,64 +21.248473,64 +21.249895,64 +21.251388,64 +21.252837,64 +21.254290,64 +21.255735,64 +21.257182,64 +21.258661,64 +21.260117,64 +21.261576,64 +21.263026,64 +21.264474,64 +21.265933,64 +21.267382,64 +21.268817,64 +21.270274,64 +21.271727,64 +21.273202,64 +21.274668,64 +21.276121,64 +21.277465,64 +21.278912,64 +21.280376,64 +21.281839,64 +21.283279,64 +21.284822,64 +21.286254,64 +21.287694,64 +21.289128,64 +21.290595,64 +21.292050,64 +21.293512,64 +21.294973,64 +21.296407,64 +21.297772,64 +21.299234,64 +21.300801,64 +21.302238,64 +21.303691,64 +21.305137,64 +21.306485,64 +21.307949,64 +21.309425,64 +21.310914,64 +21.312357,64 +21.313801,64 +21.315249,64 +21.316692,64 +21.318139,64 +21.319607,64 +21.321053,64 +21.322506,64 +21.323965,64 +21.325406,64 +21.326852,64 +21.328292,64 +21.329737,64 +21.331219,64 +21.332692,64 +21.334129,64 +21.335574,64 +21.337016,64 +21.338450,64 +21.339923,64 +21.341382,64 +21.342815,64 +21.344269,64 +21.345710,64 +21.347150,64 +21.348602,64 +21.350054,64 +21.351490,64 +21.352946,64 +21.354400,64 +21.355847,64 +21.357292,64 +21.358736,64 +21.360193,64 +21.361637,64 +21.363109,64 +21.364559,64 +21.366000,64 +21.367462,64 +21.368937,64 +21.370396,64 +21.371860,64 +21.373327,64 +21.374786,64 +21.376225,64 +21.377680,64 +21.379111,64 +21.380583,64 +21.382048,64 +21.383482,64 +21.384922,64 +21.386356,64 +21.387801,64 +21.389260,64 +21.390737,64 +21.392179,64 +21.393641,64 +21.395087,64 +21.396533,64 +21.397972,64 +21.399404,64 +21.400862,64 +21.402317,64 +21.403747,64 +21.405189,64 +21.406660,64 +21.408148,64 +21.409620,64 +21.410971,64 +21.412432,64 +21.413878,64 +21.415351,64 +21.416837,64 +21.418284,64 +21.419746,64 +21.421198,64 +21.422637,64 +21.424079,64 +21.425515,64 +21.426994,64 +21.428442,64 +21.429875,64 +21.431320,64 +21.432797,64 +21.434243,64 +21.435686,64 +21.437145,64 +21.438604,64 +21.440083,64 +21.441515,64 +21.442963,64 +21.444402,64 +21.445855,64 +21.447296,64 +21.448750,64 +21.450187,64 +21.451637,64 +21.453075,64 +21.454528,64 +21.455984,64 +21.457434,64 +21.458899,64 +21.460348,64 +21.461822,64 +21.463282,64 +21.464726,64 +21.466183,64 +21.467638,64 +21.469076,64 +21.470529,64 +21.471972,64 +21.473423,64 +21.474901,64 +21.476366,64 +21.477839,64 +21.479186,64 +21.480635,64 +21.482199,64 +21.483665,64 +21.484986,64 +21.486457,64 +21.487900,64 +21.489373,64 +21.490837,64 +21.492285,64 +21.493756,64 +21.495191,64 +21.496641,64 +21.498092,64 +21.499527,64 +21.500978,64 +21.502442,64 +21.503900,64 +21.505355,64 +21.506793,64 +21.508223,64 +21.509682,64 +21.511144,64 +21.512590,64 +21.514058,64 +21.515543,64 +21.516992,64 +21.518439,64 +21.519885,64 +21.521352,64 +21.522800,64 +21.524270,64 +21.525729,64 +21.527177,64 +21.528624,64 +21.530083,64 +21.531549,64 +21.532888,64 +21.534444,64 +21.535892,64 +21.537351,64 +21.538805,64 +21.540241,64 +21.541695,64 +21.543135,64 +21.544574,64 +21.546010,64 +21.547474,64 +21.548933,64 +21.550393,64 +21.551854,64 +21.553300,64 +21.554736,64 +21.556165,64 +21.557540,64 +21.558997,64 +21.560439,64 +21.561997,64 +21.563360,64 +21.564811,64 +21.566266,64 +21.567714,64 +21.569198,64 +21.570658,64 +21.572124,64 +21.573587,64 +21.575038,64 +21.576462,64 +21.577900,64 +21.579344,64 +21.580787,64 +21.582251,64 +21.583713,64 +21.585161,64 +21.586612,64 +21.588050,64 +21.589494,64 +21.590932,64 +21.592493,64 +21.593950,64 +21.595388,64 +21.596827,64 +21.598278,64 +21.599737,64 +21.601171,64 +21.602634,64 +21.604055,64 +21.605481,64 +21.606947,64 +21.608386,64 +21.609834,64 +21.611304,64 +21.612747,64 +21.614201,64 +21.615623,64 +21.617078,64 +21.618508,64 +21.619986,64 +21.621434,64 +21.622891,64 +21.624340,64 +21.625828,64 +21.627271,64 +21.628728,64 +21.630186,64 +21.631633,64 +21.633103,64 +21.634547,64 +21.636001,64 +21.637442,64 +21.638890,64 +21.640379,64 +21.641829,64 +21.643273,64 +21.644626,64 +21.646116,64 +21.647573,64 +21.649025,64 +21.650478,64 +21.651910,64 +21.653358,64 +21.654802,64 +21.656247,64 +21.657692,64 +21.659142,64 +21.660631,64 +21.662071,64 +21.663532,64 +21.664980,64 +21.666414,64 +21.667856,64 +21.669304,64 +21.670734,64 +21.672206,64 +21.673678,64 +21.675114,64 +21.676541,64 +21.678105,64 +21.679545,64 +21.680980,64 +21.682445,64 +21.683903,64 +21.685341,64 +21.686776,64 +21.688212,64 +21.689685,64 +21.691143,64 +21.692575,64 +21.694018,64 +21.695457,64 +21.696902,64 +21.698364,64 +21.699800,64 +21.701236,64 +21.702683,64 +21.704154,64 +21.705588,64 +21.707032,64 +21.708598,64 +21.709929,64 +21.711499,64 +21.712927,64 +21.714369,64 +21.715818,64 +21.717256,64 +21.718694,64 +21.720138,64 +21.721589,64 +21.723059,64 +21.724497,64 +21.725934,64 +21.727373,64 +21.728833,64 +21.730281,64 +21.731749,64 +21.733232,64 +21.734720,64 +21.736064,64 +21.737618,64 +21.738944,64 +21.740419,64 +21.741881,64 +21.743329,64 +21.744775,64 +21.746226,64 +21.747686,64 +21.749135,64 +21.750572,64 +21.752002,64 +21.753586,64 +21.755059,64 +21.756375,64 +21.757827,64 +21.759388,64 +21.760841,64 +21.762265,64 +21.763731,64 +21.765191,64 +21.766523,64 +21.767987,64 +21.769541,64 +21.770986,64 +21.772443,64 +21.773793,64 +21.775343,64 +21.776770,64 +21.778218,64 +21.779669,64 +21.781135,64 +21.782579,64 +21.784014,64 +21.785460,64 +21.786898,64 +21.788339,64 +21.789779,64 +21.791211,64 +21.792767,64 +21.794190,64 +21.795632,64 +21.797084,64 +21.798534,64 +21.799978,64 +21.801412,64 +21.802870,64 +21.804302,64 +21.805757,64 +21.807223,64 +21.808666,64 +21.810131,64 +21.811569,64 +21.813041,64 +21.814510,64 +21.815940,64 +21.817400,64 +21.818861,64 +21.820291,64 +21.821754,64 +21.823222,64 +21.824671,64 +21.826125,64 +21.827587,64 +21.829039,64 +21.830502,64 +21.831935,64 +21.833383,64 +21.834820,64 +21.836248,64 +21.837687,64 +21.839134,64 +21.840576,64 +21.842042,64 +21.843475,64 +21.844906,64 +21.846360,64 +21.847849,64 +21.849309,64 +21.850751,64 +21.852193,64 +21.853642,64 +21.855087,64 +21.856541,64 +21.858004,64 +21.859433,64 +21.860882,64 +21.862345,64 +21.863817,64 +21.865279,64 +21.866738,64 +21.868181,64 +21.869637,64 +21.871086,64 +21.872534,64 +21.874007,64 +21.875449,64 +21.876883,64 +21.878311,64 +21.879764,64 +21.881212,64 +21.882686,64 +21.884133,64 +21.885578,64 +21.887049,64 +21.888518,64 +21.889967,64 +21.891420,64 +21.892863,64 +21.894319,64 +21.895791,64 +21.897258,64 +21.898618,64 +21.900084,64 +21.901533,64 +21.902978,64 +21.904417,64 +21.905855,64 +21.907425,64 +21.908855,64 +21.910269,64 +21.911709,64 +21.913095,64 +21.914576,64 +21.916052,64 +21.917516,64 +21.918960,64 +21.920407,64 +21.921871,64 +21.923327,64 +21.924791,64 +21.926231,64 +21.927673,64 +21.929143,64 +21.930613,64 +21.932055,64 +21.933531,64 +21.934857,64 +21.936335,64 +21.937791,64 +21.939251,64 +21.940723,64 +21.942184,64 +21.943615,64 +21.945065,64 +21.946496,64 +21.947956,64 +21.949420,64 +21.950868,64 +21.952308,64 +21.953763,64 +21.955216,64 +21.956685,64 +21.958147,64 +21.959590,64 +21.961052,64 +21.962506,64 +21.963988,64 +21.965442,64 +21.966890,64 +21.968335,64 +21.969762,64 +21.971204,64 +21.972665,64 +21.974128,64 +21.975574,64 +21.976995,64 +21.978482,64 +21.979941,64 +21.981372,64 +21.982805,64 +21.984329,64 +21.985778,64 +21.987209,64 +21.988619,64 +21.990063,64 +21.991476,64 +21.993024,64 +21.994449,64 +21.995893,64 +21.997342,64 +21.998796,64 +22.000242,64 +22.001667,64 +22.003136,64 +22.004597,64 +22.006048,64 +22.007517,64 +22.008953,64 +22.010413,64 +22.011848,64 +22.013278,64 +22.014733,64 +22.016169,64 +22.017595,64 +22.019160,64 +22.020610,64 +22.021931,64 +22.023396,64 +22.024845,64 +22.026290,64 +22.027857,64 +22.029300,64 +22.030765,64 +22.032210,64 +22.033659,64 +22.035094,64 +22.036522,64 +22.037996,64 +22.039459,64 +22.040917,64 +22.042267,64 +22.043717,64 +22.045168,64 +22.046608,64 +22.048080,64 +22.049551,64 +22.051027,64 +22.052478,64 +22.053914,64 +22.055369,64 +22.056841,64 +22.058295,64 +22.059733,64 +22.061198,64 +22.062675,64 +22.064149,64 +22.065578,64 +22.067033,64 +22.068473,64 +22.069940,64 +22.071383,64 +22.072850,64 +22.074296,64 +22.075738,64 +22.077179,64 +22.078653,64 +22.080097,64 +22.081539,64 +22.082985,64 +22.084439,64 +22.085884,64 +22.087336,64 +22.088790,64 +22.090263,64 +22.091692,64 +22.093177,64 +22.094613,64 +22.096051,64 +22.097498,64 +22.098943,64 +22.100400,64 +22.101860,64 +22.103305,64 +22.104743,64 +22.106247,64 +22.107560,64 +22.109011,64 +22.110577,64 +22.112017,64 +22.113481,64 +22.114828,64 +22.116286,64 +22.117766,64 +22.119207,64 +22.120668,64 +22.122151,64 +22.123610,64 +22.125067,64 +22.126516,64 +22.127972,64 +22.129434,64 +22.130893,64 +22.132230,64 +22.133677,64 +22.135252,64 +22.136580,64 +22.138034,64 +22.139502,64 +22.140959,64 +22.142409,64 +22.143880,64 +22.145323,64 +22.146770,64 +22.148238,64 +22.149672,64 +22.151110,64 +22.152544,64 +22.154115,64 +22.155459,64 +22.156942,64 +22.158387,64 +22.159832,64 +22.161288,64 +22.162736,64 +22.164235,64 +22.165710,64 +22.167151,64 +22.168612,64 +22.170061,64 +22.171528,64 +22.172961,64 +22.174324,64 +22.175880,64 +22.177326,64 +22.178750,64 +22.180198,64 +22.181675,64 +22.183125,64 +22.184582,64 +22.186038,64 +22.187376,64 +22.188942,64 +22.190411,64 +22.191732,64 +22.193180,64 +22.194754,64 +22.196083,64 +22.197564,64 +22.199033,64 +22.200475,64 +22.201908,64 +22.203375,64 +22.204856,64 +22.206317,64 +22.207775,64 +22.209231,64 +22.210657,64 +22.212087,64 +22.213537,64 +22.214995,64 +22.216449,64 +22.217899,64 +22.219364,64 +22.220819,64 +22.222275,64 +22.223720,64 +22.225180,64 +22.226630,64 +22.228072,64 +22.229520,64 +22.230958,64 +22.232414,64 +22.233856,64 +22.235288,64 +22.236736,64 +22.238176,64 +22.239642,64 +22.241134,64 +22.242569,64 +22.244017,64 +22.245477,64 +22.246923,64 +22.248380,64 +22.249845,64 +22.251278,64 +22.252723,64 +22.254186,64 +22.255643,64 +22.257097,64 +22.258543,64 +22.259986,64 +22.261471,64 +22.262930,64 +22.264370,64 +22.265832,64 +22.267272,64 +22.268724,64 +22.270155,64 +22.271612,64 +22.273105,64 +22.274561,64 +22.276006,64 +22.277446,64 +22.278887,64 +22.280336,64 +22.281809,64 +22.283254,64 +22.284707,64 +22.286151,64 +22.287600,64 +22.289085,64 +22.290412,64 +22.291879,64 +22.293329,64 +22.294767,64 +22.296223,64 +22.297680,64 +22.299155,64 +22.300616,64 +22.302077,64 +22.303551,64 +22.304997,64 +22.306453,64 +22.307891,64 +22.309343,64 +22.310806,64 +22.312274,64 +22.313727,64 +22.315181,64 +22.316621,64 +22.318076,64 +22.319534,64 +22.320970,64 +22.322428,64 +22.323917,64 +22.325363,64 +22.326816,64 +22.328266,64 +22.329601,64 +22.331088,64 +22.332551,64 +22.334008,64 +22.335471,64 +22.336900,64 +22.338360,64 +22.339829,64 +22.341272,64 +22.342711,64 +22.344156,64 +22.345595,64 +22.347049,64 +22.348502,64 +22.349949,64 +22.351380,64 +22.352830,64 +22.354382,64 +22.355747,64 +22.357229,64 +22.358661,64 +22.360096,64 +22.361543,64 +22.362984,64 +22.364443,64 +22.365885,64 +22.367328,64 +22.368796,64 +22.370241,64 +22.371809,64 +22.373135,64 +22.374712,64 +22.376150,64 +22.377579,64 +22.379046,64 +22.380501,64 +22.381918,64 +22.383348,64 +22.384781,64 +22.386227,64 +22.387660,64 +22.389134,64 +22.390590,64 +22.392032,64 +22.393500,64 +22.394956,64 +22.396394,64 +22.397863,64 +22.399324,64 +22.400788,64 +22.402231,64 +22.403696,64 +22.405139,64 +22.406589,64 +22.408034,64 +22.409481,64 +22.410927,64 +22.412368,64 +22.413821,64 +22.415290,64 +22.416754,64 +22.418219,64 +22.419591,64 +22.421049,64 +22.422510,64 +22.423977,64 +22.425434,64 +22.426873,64 +22.428346,64 +22.429819,64 +22.431302,64 +22.432750,64 +22.434209,64 +22.435552,64 +22.437015,64 +22.438481,64 +22.439931,64 +22.441363,64 +22.442796,64 +22.444361,64 +22.445794,64 +22.447254,64 +22.448695,64 +22.450136,64 +22.451612,64 +22.453067,64 +22.454530,64 +22.455973,64 +22.457420,64 +22.458764,64 +22.460218,64 +22.461665,64 +22.463140,64 +22.464601,64 +22.466085,64 +22.467531,64 +22.469012,64 +22.470391,64 +22.471870,64 +22.473335,64 +22.474818,64 +22.476251,64 +22.477699,64 +22.479154,64 +22.480608,64 +22.482052,64 +22.483496,64 +22.484932,64 +22.486390,64 +22.487833,64 +22.489271,64 +22.490780,64 +22.492224,64 +22.493683,64 +22.495123,64 +22.496542,64 +22.497990,64 +22.499449,64 +22.500920,64 +22.502355,64 +22.503809,64 +22.505262,64 +22.506702,64 +22.508133,64 +22.509657,64 +22.511065,64 +22.512475,64 +22.514027,64 +22.515437,64 +22.516858,64 +22.518300,64 +22.519725,64 +22.521275,64 +22.522614,64 +22.524062,64 +22.525557,64 +22.526995,64 +22.528457,64 +22.529896,64 +22.531350,64 +22.532795,64 +22.534246,64 +22.535679,64 +22.537241,64 +22.538691,64 +22.540032,64 +22.541490,64 +22.542936,64 +22.544415,64 +22.545875,64 +22.547357,64 +22.548803,64 +22.550256,64 +22.551735,64 +22.553161,64 +22.554599,64 +22.556089,64 +22.557530,64 +22.558963,64 +22.560397,64 +22.561839,64 +22.563315,64 +22.564786,64 +22.566258,64 +22.567724,64 +22.569051,64 +22.570529,64 +22.571981,64 +22.573454,64 +22.574936,64 +22.576367,64 +22.577804,64 +22.579243,64 +22.580710,64 +22.582170,64 +22.583619,64 +22.585041,64 +22.586480,64 +22.588039,64 +22.589484,64 +22.590932,64 +22.592390,64 +22.593839,64 +22.595280,64 +22.596733,64 +22.598183,64 +22.599620,64 +22.601054,64 +22.602495,64 +22.603942,64 +22.605404,64 +22.606868,64 +22.608301,64 +22.609729,64 +22.611185,64 +22.612607,64 +22.614048,64 +22.615501,64 +22.616973,64 +22.618418,64 +22.619889,64 +22.621328,64 +22.622782,64 +22.624259,64 +22.625700,64 +22.627152,64 +22.628624,64 +22.630083,64 +22.631587,64 +22.632936,64 +22.634378,64 +22.635843,64 +22.637305,64 +22.638731,64 +22.640281,64 +22.641707,64 +22.643131,64 +22.644553,64 +22.645988,64 +22.647529,64 +22.648953,64 +22.650401,64 +22.651824,64 +22.653256,64 +22.654718,64 +22.656135,64 +22.657674,64 +22.659078,64 +22.660561,64 +22.661956,64 +22.663492,64 +22.664911,64 +22.666323,64 +22.667780,64 +22.669239,64 +22.670679,64 +22.672131,64 +22.673630,64 +22.675048,64 +22.676465,64 +22.677914,64 +22.679452,64 +22.680887,64 +22.682323,64 +22.683745,64 +22.685169,64 +22.686721,64 +22.688138,64 +22.689618,64 +22.691054,64 +22.692471,64 +22.693882,64 +22.695417,64 From e054a849ab6c15ae90989876034b9115ff1c1bb3 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:57:40 +0300 Subject: [PATCH 13/51] fix(audio): dip instead of stepping when a swap changes what is heard Carried sources follow the new graph's clock lock, short splices end on the incoming audio, a speaker retired early or mid fade-in hands back cleanly. --- docs/CONCEPT.md | 4 +- src-tauri/src/audio/pipeline/dag.rs | 390 +++++++++++++++++- src-tauri/src/audio/pipeline/mod.rs | 66 +++ src-tauri/src/audio/pipeline/output/render.rs | 64 ++- src-tauri/src/audio/pipeline/worker.rs | 228 ++++++---- 5 files changed, 671 insertions(+), 81 deletions(-) diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 8757b128..7fe9d431 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -21,7 +21,7 @@ Inside cpal / SCK callbacks and `DspWorker::run`: - Syscalls (I/O, logging, IPC) - Non-deterministic latency ops -One exception: waking a sleeping helper thread with `Thread::unpark` (an offload worker after its input was pushed). It never blocks; when the thread is awake it is a single atomic swap, and otherwise one futex/ulock/`WakeByAddress` wake. Polling with `sleep` instead costs the worker up to a poll period of its deadline. +One exception: waking a sleeping helper thread with `Thread::unpark` (an offload worker after its input was pushed). It never blocks; when the thread is awake it is a single atomic swap, and otherwise one futex, `dispatch_semaphore_signal` or `WakeByAddressSingle` wake. Polling with `sleep` instead costs the worker up to a poll period of its deadline. Shared RT↔UI state: `Arc` (f32-as-bits, `Ordering::Relaxed`). See `MeterHandle` / `EffectControl` in `audio/effects.rs`. @@ -46,7 +46,7 @@ Without these, one chunk takes ~16 ms and the worker stalls. - `Clock` paces the timer workers (monitoring, wire sender, recording) at `TIMER_BLOCK_FRAMES`. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder. - Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. The network receiver (`stream_recv.rs`) runs the same loop against its packets' write timing, holding the fill right after a packet at its start level. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block. - An edit rebuilds only what it changed. A node whose build inputs are unchanged (same id, structural spec, width, rate, block) is built as an empty placeholder, and when the new graph swaps in on the running worker it takes over the old node's live state (`OutputGraph::adopt_from`): a source its ring, queue and resamplers, with the bridge feeding that ring kept; an effect its instances, controls and handles; an edge its delay line when the delay is unchanged. Only a graph that is not swapped in place is built from scratch. -- Nothing steps the output: a speaker fades in over 10 ms when it starts and out over 10 ms before it retires, and a swap that carries nothing over renders the old and new graphs together for 10 ms, crossfading from one to the other (`worker.rs`). +- Nothing steps the output: a speaker fades in over 10 ms when it starts and out over 10 ms before it retires. A swap whose heard part is unchanged (every node reaching the output carried over, fed by the same edges at the same delays; `OutputGraph::plays_like`) goes in at once; any other fades the running graph out over 10 ms, swaps, carrying over what it can, and fades the new one in (`worker.rs`). - An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs on an offload thread (`effects/offload.rs`). One that can only step in whole blocks of its own (a model hop) names that block, and the offload gathers exactly those before calling it; the effect never pads or buffers for it itself. The offload's pad is the least that never starves at the worst alignment of those blocks against the engine's, plus a 5 ms turnaround. The effect declares only its own latency (a model's algorithmic delay, a resampler's queues), the offload adds its pad, PDC aligns against the sum, and the node shows it. - Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed. - RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it. diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 9418ddd6..1b42e89d 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -330,11 +330,20 @@ impl SourceState { fn take_over(&mut self, old: &mut SourceState) { std::mem::swap(&mut self.consumer, &mut old.consumer); std::mem::swap(&mut self.resampler, &mut old.resampler); - std::mem::swap(&mut self.asrc, &mut old.asrc); + // Whether the source reads through the drift resampler is this graph's + // decision: the output may have moved to another clock since. + if self.asrc.is_some() == old.asrc.is_some() { + std::mem::swap(&mut self.asrc, &mut old.asrc); + } std::mem::swap(&mut self.input_staging, &mut old.input_staging); std::mem::swap(&mut self.splice_tmp, &mut old.splice_tmp); std::mem::swap(&mut self.out_pending, &mut old.out_pending); std::mem::swap(&mut self.chunk_tmp, &mut old.chunk_tmp); + // Only the fixed-rate path queues resampled output; the drift + // resampler never reads it. + if self.asrc.is_some() { + self.out_pending.clear(); + } std::mem::swap(&mut self.cushion, &mut old.cushion); self.queue_avg = old.queue_avg; self.queued_after = old.queued_after; @@ -1015,12 +1024,18 @@ fn add_block_at(src: &[f32], dst: &mut [f32], off: usize, frames: usize) { /// as a ring's two halves. Both ends stay continuous: frame 0 is pure `dst` and /// the last frame is pure incoming, so neither join is a step. fn crossfade_into(dst: &mut [f32], first: &[f32], second: &[f32], channels: usize) { - let span = (SPLICE_FADE_FRAMES - 1).max(1) as f32; + // A join shorter than a fade still has to end fully on the incoming + // audio; a single frame is all incoming. + let span = (dst.len() / channels.max(1)).saturating_sub(1) as f32; for (i, s) in first.iter().chain(second.iter()).enumerate() { if i >= dst.len() { break; } - let w = ((i / channels) as f32 / span).min(1.0); + let w = if span == 0.0 { + 1.0 + } else { + ((i / channels) as f32 / span).min(1.0) + }; dst[i] = dst[i] * (1.0 - w) + s * w; } } @@ -1031,6 +1046,29 @@ struct DelayLine { pos: usize, } +fn delay_len(d: &Option) -> usize { + d.as_ref().map_or(0, |d| d.buf.len()) +} + +/// Whether two nodes are fed by the same edges from the same nodes, at the +/// same delays. +fn same_edges( + new_edges: &[IncomingEdge], + new_ids: &[String], + old_edges: &[IncomingEdge], + old_ids: &[String], +) -> bool { + new_edges.len() == old_edges.len() + && new_edges.iter().all(|e| { + old_edges.iter().any(|o| { + old_ids[o.src_idx] == new_ids[e.src_idx] + && o.source_handle == e.source_handle + && o.target_handle == e.target_handle + && delay_len(&o.delay) == delay_len(&e.delay) + }) + }) +} + /// Hands each delay line of `old_edges` to the edge of `new_edges` that runs /// between the same nodes and handles at the same delay, so the audio it holds /// keeps flowing instead of restarting from silence. @@ -1102,6 +1140,8 @@ pub(super) struct OutputGraph { /// What each node was built from, where a later graph may carry the node /// over: equal keys build equal nodes. node_keys: Vec>, + /// Whether each node is heard: it reaches a terminal or a wire sink. + audible: Vec, terminals: Vec, /// Lookahead the graph's delay compensation has aligned every path to: the /// deepest cumulative effect latency from any source to this output. The @@ -1190,6 +1230,54 @@ impl OutputGraph { } } + /// RT-safe. Whether swapping this graph in for `old` changes nothing that + /// is heard: every node that reaches the output is carried over from + /// `old` and fed by the same edges at the same delays, and nothing heard + /// in `old` is gone. Call before `adopt_from`. + pub(super) fn plays_like(&self, old: &OutputGraph) -> bool { + let heard = |g: &OutputGraph| g.audible.iter().filter(|&&a| a).count(); + if heard(self) != heard(old) || self.terminals.len() != old.terminals.len() { + return false; + } + for i in (0..self.nodes.len()).filter(|&i| self.audible[i]) { + let Some(j) = (0..old.nodes.len()).find(|&j| { + old.audible[j] + && !self.node_ids[i].is_empty() + && old.node_ids[j] == self.node_ids[i] + }) else { + return false; + }; + if self.node_keys[i].is_none() || old.node_keys[j] != self.node_keys[i] { + return false; + } + let same = match (&self.nodes[i], &old.nodes[j]) { + (DagNode::Source(new), DagNode::Source(_)) => new.carry_over, + (DagNode::Effect(new), DagNode::Effect(prev)) => { + new.effects.is_empty() + && same_edges(&new.incoming, &self.node_ids, &prev.incoming, &old.node_ids) + && same_edges( + &new.sidechain, + &self.node_ids, + &prev.sidechain, + &old.node_ids, + ) + } + _ => false, + }; + if !same { + return false; + } + } + self.terminals.iter().all(|t| { + old.terminals.iter().any(|o| { + old.node_ids[o.src_idx] == self.node_ids[t.src_idx] + && o.source_handle == t.source_handle + && o.route == t.route + && delay_len(&o.delay) == delay_len(&t.delay) + }) + }) + } + /// RT-safe. Takes over the running state of every node this graph was /// built to carry over from `old`, the graph it replaces, and every delay /// line on an edge both share at the same length. Returns whether any @@ -2078,6 +2166,7 @@ pub(super) fn build_output_graph( })); let (node_ids, node_keys) = node_identities(nodes.len(), &id_to_index, &carried); + let audible = audible_nodes(&nodes, &[]); return Ok(BuiltOutputGraph { graph: OutputGraph { sample_rate: output_sr, @@ -2086,6 +2175,7 @@ pub(super) fn build_output_graph( nodes, node_ids, node_keys, + audible, terminals: Vec::new(), latency_frames: max_up, blocks: blocks.clone(), @@ -2158,6 +2248,7 @@ pub(super) fn build_output_graph( }; let (node_ids, node_keys) = node_identities(nodes.len(), &id_to_index, &carried); + let audible = audible_nodes(&nodes, &terminals); Ok(BuiltOutputGraph { graph: OutputGraph { sample_rate: output_sr, @@ -2166,6 +2257,7 @@ pub(super) fn build_output_graph( nodes, node_ids, node_keys, + audible, terminals, latency_frames: node_latencies.iter().copied().max().unwrap_or(0), blocks: blocks.clone(), @@ -2192,6 +2284,34 @@ pub(super) fn build_output_graph( }) } +/// Which nodes reach a terminal or a wire sink. Nodes come in topological +/// order, so walking them backwards visits every consumer before its inputs. +fn audible_nodes(nodes: &[DagNode], terminals: &[TerminalEdge]) -> Vec { + let mut audible = vec![false; nodes.len()]; + for t in terminals { + audible[t.src_idx] = true; + } + for i in (0..nodes.len()).rev() { + let edges: &[IncomingEdge] = match &nodes[i] { + DagNode::Consumer(c) => { + audible[i] = true; + &c.incoming + } + DagNode::Effect(e) if audible[i] => { + for s in &e.sidechain { + audible[s.src_idx] = true; + } + &e.incoming + } + _ => continue, + }; + for e in edges { + audible[e.src_idx] = true; + } + } + audible +} + /// Each node's graph id (empty for a node no graph id names) and carry-over /// key, by index. fn node_identities( @@ -2574,6 +2694,21 @@ mod tests { } } + // The last splice of the startup correction carries what is left of it, + // often less than a whole fade; its join must still end on the incoming + // audio, which is what the stream continues with. + #[test] + fn a_short_splice_still_ends_on_the_incoming_audio() { + const CH: usize = 2; + for frames in [1, 4, SPLICE_FADE_FRAMES / 2, SPLICE_FADE_FRAMES - 1] { + let mut dst = vec![1.0_f32; frames * CH]; + let incoming = vec![0.0_f32; frames * CH]; + crossfade_into(&mut dst, &incoming, &[], CH); + let last = (frames - 1) * CH; + assert_eq!(dst[last], 0.0, "{frames}-frame join ends at {}", dst[last]); + } + } + #[test] fn add_mapped_maps_by_channel() { // 4->2: first two channels pass, rest dropped. @@ -3130,12 +3265,259 @@ pub(super) mod graph_tests { } } + /// `passthrough_graph` plus a level meter off the mic: an edit that + /// leaves the audible chain as it was. + pub(in crate::audio::pipeline) fn passthrough_with_meter() -> ValidGraph { + GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + gain_node("g", 0.0), + speaker("s"), + node("lm", NodeKind::LevelMeter, serde_json::json!({})), + ], + edges: vec![ + edge("e1", "m", None, "g", None), + edge("e2", "g", None, "s", None), + edge("e3", "m", None, "lm", None), + ], + } + .validate() + .expect("valid") + } + + fn worst_step(samples: &[f32]) -> f32 { + samples + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max) + } + + #[test] + fn inserting_an_effect_on_a_carried_source_does_not_step() { + // The mic is carried over, so the swap does not crossfade; the gain + // put in its path must still come in without a step. + let mut registry = fresh_registry(); + let valid_b = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + gain_node("g", 0.0), + gain_node("g2", -20.0), + speaker("s"), + ], + edges: vec![ + edge("e1", "m", None, "g", None), + edge("e2", "g", None, "g2", None), + edge("e3", "g2", None, "s", None), + ], + } + .validate() + .expect("valid"); + let (mut a, mut producers) = rebuild( + &passthrough_graph().0, + &mut registry, + &HashMap::new(), + false, + ); + push_all(producers.get_mut("m").unwrap(), &vec![0.5; 64 * 2 * 60]); + let (b, _) = rebuild(&valid_b, &mut registry, &a.carried, false); + assert!( + !b.graph.plays_like(&a.graph), + "the gain changes what is heard" + ); + let mut played = Vec::new(); + played.extend(render(&mut a.graph, 10).into_iter().step_by(2)); + let (mut worker, mut ctrl) = super::super::worker::dsp_worker(a.graph); + ctrl.send_graph(b.graph).expect("swap"); + let mut out = vec![0.0; 64 * 2]; + for _ in 0..30 { + worker.next_block(&mut out); + played.extend(out.iter().step_by(2)); + } + assert!( + (played[played.len() - 1] - 0.05).abs() < 1e-4, + "the new gain plays" + ); + let worst = worst_step(&played[64 * 4..]); + let fade = (SR as usize * 10 / 1000) as f32; + assert!(worst <= 0.5 / fade + 1e-4, "a step of {worst}"); + } + + #[test] + fn a_carried_source_follows_the_new_graphs_clock_lock() { + // The tap was on the speaker's clock; the default output moved, so the + // new graph reads it through the drift resampler. Carrying the source + // over must not bring the old lock with it. + let mut registry = fresh_registry(); + let (mut a, _p) = rebuild(&passthrough_graph().0, &mut registry, &HashMap::new(), true); + a.graph.lock_inputs(&HashSet::from(["m".to_string()])); + let (mut b, _) = rebuild(&passthrough_with_meter(), &mut registry, &a.carried, true); + b.graph.lock_inputs(&HashSet::new()); + assert!(b.graph.adopt_from(&mut a.graph), "the mic is carried over"); + let steered = b + .graph + .nodes + .iter() + .any(|n| matches!(n, DagNode::Source(s) if s.asrc.is_some())); + assert!(steered, "the carried source still reads locked"); + } + + #[test] + fn live_updates_reach_a_carried_effect() { + // A carried gain keeps the instance the pipeline's control drives. + let mut registry = fresh_registry(); + let (mut a, mut producers) = rebuild( + &passthrough_graph().0, + &mut registry, + &HashMap::new(), + false, + ); + let control = a + .controls + .iter() + .find(|(id, _)| id == "g") + .map(|(_, c)| c.clone()) + .expect("gain control"); + let bypass = a + .bypasses + .iter() + .find(|(id, _)| id == "g") + .map(|(_, b)| b.clone()) + .expect("gain bypass"); + push_all(producers.get_mut("m").unwrap(), &vec![0.5; 64 * 2 * 60]); + render(&mut a.graph, 4); + let (mut b, _) = rebuild(&passthrough_with_meter(), &mut registry, &a.carried, false); + assert!(b.graph.adopt_from(&mut a.graph)); + + control.apply_update(&serde_json::json!({ "gainDb": -6.0 })); + let got = render(&mut b.graph, 20); + let want = 0.5 * 10f32.powf(-6.0 / 20.0); + assert!( + (got[got.len() - 1] - want).abs() < 1e-3, + "gain update: got {} want {want}", + got[got.len() - 1] + ); + + bypass.store(true, Ordering::Relaxed); + let got = render(&mut b.graph, 4); + assert!( + (got[got.len() - 1] - 0.5).abs() < 1e-6, + "bypass: got {}", + got[got.len() - 1] + ); + } + + #[test] + fn a_chain_of_carried_swaps_keeps_every_node() { + // Two edits queued before the worker takes either: the second graph + // was built against the first, which was built against the running + // one, and the worker adopts them in order in one drain. + let mut registry = fresh_registry(); + let (mut a, mut producers) = rebuild( + ¶llel_with_lookahead(false, 2.0, false), + &mut registry, + &HashMap::new(), + false, + ); + let fed = quiet_ramp(64 * 60); + push_all(producers.get_mut("m").unwrap(), &fed); + let mut got = render(&mut a.graph, 20); + let (mut b, _) = rebuild( + ¶llel_with_lookahead(true, 2.0, false), + &mut registry, + &a.carried, + false, + ); + let (mut c, _) = rebuild( + ¶llel_with_lookahead(false, 2.0, false), + &mut registry, + &b.carried, + false, + ); + assert!(b.graph.adopt_from(&mut a.graph)); + assert!(c.graph.adopt_from(&mut b.graph)); + for n in &c.graph.nodes { + if let DagNode::Effect(e) = n { + assert!(!e.effects.is_empty(), "a placeholder was never filled"); + } + } + got.extend(render(&mut c.graph, 20)); + let pad = 96; + for f in pad..64 * 40 { + let want = 2.0 * fed[(f - pad) * 2]; + assert!( + (got[f * 2] - want).abs() < 1e-5, + "frame {f}: got {} want {want}", + got[f * 2] + ); + } + } + + #[test] + fn a_resampled_source_that_goes_quiet_keeps_its_queue() { + // A 44.1 kHz capture read into a 48 kHz graph: the judge counts time + // in the capture's frames, which a block covers only approximately. + // Pauses in its delivery must still not deepen its queue. + const IN_SR: u32 = 44_100; + const BURST: usize = 441; + let worst_queue = |gaps: &[(f64, f64)]| -> u64 { + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = + build_with_block(Some("s"), SR, 64, &valid, IN_SR, true); + let clock = Arc::new(WriteClock::default()); + built.graph.attach_write_clock("m", &clock); + let stats = built.sources[0].stats.clone(); + let input = producers.get_mut("m").unwrap(); + let out_period = 64.0 / SR as f64; + let in_period = BURST as f64 / IN_SR as f64; + let (mut t_in, mut t_out) = (in_period * 0.37, 0.0); + let chunk = vec![0.25_f32; BURST * 2]; + let mut out = vec![0.0; 64 * 2]; + let mut worst = 0; + while t_out < 60.0 { + if t_in <= t_out { + if !gaps.iter().any(|&(a, b)| t_in >= a && t_in < b) { + clock.record(chunk.len(), t_in); + push_all(input, &chunk); + } + t_in += in_period; + continue; + } + built.graph.process_block_at(&mut out, t_out); + if t_out >= 5.0 { + worst = worst.max(stats.queue_frames.load(Ordering::Relaxed)); + } + t_out += out_period; + } + worst + }; + let gaps: Vec<(f64, f64)> = (0..100) + .map(|k| { + let start = 5.0 + k as f64 * 0.5 + 0.2; + (start, start + 0.06 + (k % 7) as f64 * 0.05) + }) + .collect(); + let steady = worst_queue(&[]); + let gappy = worst_queue(&gaps); + assert!( + gappy <= steady + BURST as u64, + "silence deepened the queue: {gappy} frames vs {steady} steady" + ); + } + /// Mic → flat EQ → Mute → 0 dB gain → speaker, with `meter` adding a /// level meter off the mute: the chain from the field report. fn mute_chain(meter: bool) -> ValidGraph { let mut nodes = vec![ mic("m"), - node("e", NodeKind::Eq, serde_json::json!({ "gainsDb": vec![0.0; 10] })), + node( + "e", + NodeKind::Eq, + serde_json::json!({ "gainsDb": vec![0.0; 10] }), + ), node("mu", NodeKind::Mute, serde_json::json!({ "muted": false })), gain_node("g", 0.0), speaker("s"), diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index c98dea00..a251b6a0 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -1710,6 +1710,72 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn carrying_one_output_leaves_the_others_bridges_to_retire() { + use crate::audio::pipeline::file_reader::file_reader_test_emitter::TestEmitter; + + let path = + std::env::temp_dir().join(format!("pipeline_carry_two_{}.wav", std::process::id())); + let mut enc = crate::audio::encoders::build_encoder( + &path, + 48_000, + 2, + crate::audio::graph::RecordingFormat::Wav { + bit_depth: crate::audio::graph::WavBitDepth::F32, + }, + false, + ) + .unwrap(); + enc.write_interleaved(&[0.0; 64]).unwrap(); + enc.finalize().unwrap(); + let reader = file_reader::start_audio_file_reader( + "f".into(), + path.clone(), + broadcast_channel().1, + false, + Arc::new(AtomicBool::new(true)), + TestEmitter::default(), + ) + .expect("reader"); + let (mut bridge_tx, _rx) = broadcast_channel(); + let (kept, kept_capture) = bridge_tx.add(rtrb::RingBuffer::new(8).0).unwrap(); + let (retired, retired_capture) = bridge_tx.add(rtrb::RingBuffer::new(8).0).unwrap(); + let mut p = ActivePipeline::new(); + p.inputs.insert( + "m".into(), + InputState { + _handle: InputHandle::AudioFile(reader), + sample_rate: 48_000, + channels: 2, + io: None, + normalizer_frames: 0, + bridge_tx, + bridges_by_output: HashMap::new(), + capture_by_slot: HashMap::from([(kept, kept_capture), (retired, retired_capture)]), + volume: Arc::new(AtomicU32::new(1.0f32.to_bits())), + paused: None, + drain: None, + }, + ); + // Both outputs swap in place; only "s" carries its source over. + p.swapping_slots + .insert(("s".into(), "m".into()), vec![kept]); + p.swapping_slots + .insert(("r".into(), "m".into()), vec![retired]); + p.stale_bridges.push(("m".into(), kept)); + p.stale_bridges.push(("m".into(), retired)); + + let mut captured = Vec::new(); + p.keep_carried_bridges("s", &["m".to_string()], &mut captured); + + assert_eq!(p.stale_bridges, vec![("m".to_string(), retired)]); + assert_eq!(p.inputs["m"].bridges_by_output["s"], vec![kept]); + assert!(!p.inputs["m"].bridges_by_output.contains_key("r")); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].1, "s", "stats follow the carried output only"); + let _ = std::fs::remove_file(&path); + } + #[test] fn file_commands_drive_the_audio_file_reader_atoms() { use crate::audio::pipeline::file_reader::file_reader_test_emitter::TestEmitter; diff --git a/src-tauri/src/audio/pipeline/output/render.rs b/src-tauri/src/audio/pipeline/output/render.rs index 431c8ae0..6cd593ea 100644 --- a/src-tauri/src/audio/pipeline/output/render.rs +++ b/src-tauri/src/audio/pipeline/output/render.rs @@ -266,11 +266,16 @@ impl SpeakerCallback { /// Plays the stop fade, then hands the renderer back and goes silent. fn fade_out(&mut self, out: &mut [f32], callback_frames: usize) { let Some(r) = self.renderer.as_mut() else { + // Retired before it ever played: hand it straight back. + if let Ok(r) = self.incoming.pop() { + let _ = self.outgoing.push(r); + } out.fill(0.0); return; }; let total = (r.io.sample_rate * STOP_FADE_MS / 1000).max(1) as usize; - let left = self.fade_left.get_or_insert(total); + // A stop during the start fade goes down from where that fade got to. + let left = self.fade_left.get_or_insert(self.faded_in.min(total)); if *left > 0 { r.render(out, callback_frames); for frame in out.chunks_exact_mut(r.channels) { @@ -491,13 +496,19 @@ mod tests { let blocks = built.output.blocks.clone(); t.ctrl.send_graph(next).expect("swap"); t.r.render(&mut out, 64); - assert_eq!(blocks.load(Ordering::Relaxed), 1, "new graph renders"); - assert_eq!(t.blocks.load(Ordering::Relaxed), 2, "old graph fades out"); - // 10 ms at 48 kHz is 7.5 blocks of 64: the old graph renders 8 more. + assert_eq!( + blocks.load(Ordering::Relaxed), + 0, + "the old graph fades out first" + ); + assert_eq!(t.blocks.load(Ordering::Relaxed), 2); + // 10 ms at 48 kHz is 7.5 blocks of 64: the old graph renders 8, then + // the new one takes over at the next block boundary. for _ in 0..20 { t.r.render(&mut out, 64); } assert_eq!(t.blocks.load(Ordering::Relaxed), 9, "old graph stopped"); + assert_eq!(blocks.load(Ordering::Relaxed), 13, "new graph renders"); assert_eq!(t.io.callbacks.load(Ordering::Relaxed), 22); } @@ -594,6 +605,51 @@ mod tests { ); } + #[test] + fn retire_before_the_first_callback_hands_the_renderer_back() { + // A stream torn down right after opening: the renderer is attached but + // the device has not called back yet when the retire lands. + let (mut link, mut cb) = speaker_link(); + let t = rig(64, SR); + link.attach(t.r); + let handle = std::thread::spawn(move || link.retire().is_some()); + std::thread::sleep(Duration::from_millis(5)); + let deadline = Instant::now() + Duration::from_secs(2); + let mut out = vec![0.0; 64 * 2]; + while !handle.is_finished() && Instant::now() < deadline { + cb.fill(&mut out, 64); + std::thread::sleep(Duration::from_millis(1)); + } + assert!( + handle.join().unwrap(), + "renderer stayed in the callback's queue" + ); + } + + #[test] + fn retiring_during_the_fade_in_does_not_jump() { + let (mut link, mut cb) = speaker_link(); + let mut t = rig(64, SR); + feed(&mut t.input, &vec![0.5; 48_000]); + link.attach(t.r); + let mut out = vec![0.0; 64 * 2]; + let mut played: Vec = Vec::new(); + cb.fill(&mut out, 64); + played.extend(out.iter().step_by(2)); + cb.retire.store(true, Ordering::Release); + for _ in 0..10 { + cb.fill(&mut out, 64); + played.extend(out.iter().step_by(2)); + } + let fade = (SR * STOP_FADE_MS / 1000) as f32; + let worst = played + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max); + assert!(worst <= 0.5 / fade + 1e-6, "a step of {worst}"); + assert_eq!(*played.last().unwrap(), 0.0); + } + #[test] fn retire_gives_up_on_a_dead_device() { let (mut link, cb) = speaker_link(); diff --git a/src-tauri/src/audio/pipeline/worker.rs b/src-tauri/src/audio/pipeline/worker.rs index 96107a46..92e0b27d 100644 --- a/src-tauri/src/audio/pipeline/worker.rs +++ b/src-tauri/src/audio/pipeline/worker.rs @@ -47,22 +47,25 @@ impl Drop for RtThread { } } -/// A swapped-in graph fades in over this while the one it replaces fades out, -/// so an edit to a running pipeline never steps the output. +/// A swap that changes what is heard fades the running graph out over this, +/// swaps, and fades the new one in over it again, so an edit never steps the +/// output. const SWAP_FADE_MS: usize = 10; pub(super) struct DspWorker { pub graph: OutputGraph, - /// The graph being replaced, still rendering while it fades out. - fading: Option, - fade_pos: usize, - /// Holds the fading graph's block. - fade_block: Box<[f32]>, + /// A graph waiting for the running one to fade out. + pending: Option, + /// Gain the running graph plays at: ramping to 0 while `pending` waits, + /// back to 1 after the swap. + gain: f32, /// Hot-swap channel: main thread pushes a freshly-built `OutputGraph` /// here; worker takes ownership at the next block boundary. cmd_rx: Consumer, /// Returns the old `OutputGraph` to main so its `Drop` (which may free - /// ring buffers) doesn't run on the RT thread. + /// ring buffers) doesn't run on the RT thread. At most three go back + /// between two sends (two queued swaps and one pending), so it never + /// fills. old_graph_tx: Producer, } @@ -90,15 +93,12 @@ impl WorkerCtrl { pub(super) fn dsp_worker(graph: OutputGraph) -> (DspWorker, WorkerCtrl) { let (cmd_tx, cmd_rx) = RingBuffer::::new(2); - // Room for a graph queued behind one still fading out. let (old_tx, old_rx) = RingBuffer::::new(4); - let fade_block = vec![0.0; graph.block_frames() * graph.out_channels()].into_boxed_slice(); ( DspWorker { graph, - fading: None, - fade_pos: 0, - fade_block, + pending: None, + gain: 1.0, cmd_rx, old_graph_tx: old_tx, }, @@ -114,30 +114,36 @@ impl DspWorker { /// alloc-free push of the displaced graph back to main. A swap never /// changes the block size: that is an engine format change, which reopens /// the stream instead. + /// + /// A graph that plays exactly what is heard now, only carried over, goes + /// in at once. Any other waits for the running graph to fade out; later + /// swaps wait behind it, since each was built against the one before. #[inline] fn drain_swaps(&mut self) { - while let Ok(mut new_graph) = self.cmd_rx.pop() { + while self.pending.is_none() { + let Ok(new_graph) = self.cmd_rx.pop() else { + return; + }; debug_assert_eq!(new_graph.block_frames(), self.graph.block_frames()); - // Nodes the new graph carries over keep playing without a seam; - // the old graph, emptied of them, cannot render a fade. - let carried = new_graph.adopt_from(&mut self.graph); - let old = std::mem::replace(&mut self.graph, new_graph); - if carried { - if let Some(older) = self.fading.take() { - let _ = self.old_graph_tx.push(older); - } - let _ = self.old_graph_tx.push(old); - continue; - } - // A swap landing mid-fade cuts the older graph short; the newer - // one fades from the graph that was playing. - if let Some(older) = self.fading.replace(old) { - let _ = self.old_graph_tx.push(older); + // A different width cannot share the caller's block, so it goes in + // at once. + if new_graph.plays_like(&self.graph) + || new_graph.out_channels() != self.graph.out_channels() + { + self.swap_in(new_graph); + } else { + self.pending = Some(new_graph); } - self.fade_pos = 0; } } + /// Makes `new_graph` the running one, carrying over what it can. + fn swap_in(&mut self, mut new_graph: OutputGraph) { + new_graph.adopt_from(&mut self.graph); + let old = std::mem::replace(&mut self.graph, new_graph); + let _ = self.old_graph_tx.push(old); + } + /// Take any queued graph swap, then render one block into `block` /// (`block_frames * out_channels` long). Returns the output channels the /// graph actually drives. RT-safe. @@ -146,36 +152,28 @@ impl DspWorker { self.drain_swaps(); self.graph.process_block(block); let active = self.graph.active_output_channels(); - let Some(old) = self.fading.as_mut() else { - return active; - }; - let channels = self.graph.out_channels().max(1); - let frames = block.len() / channels; - let fade_len = (self.graph.sample_rate() as usize * SWAP_FADE_MS / 1000).max(1); - let old_active = old.active_output_channels(); - if old.out_channels() == channels && self.fade_block.len() == block.len() { - old.process_block(&mut self.fade_block); - for (f, (new, old)) in block - .chunks_exact_mut(channels) - .zip(self.fade_block.chunks_exact(channels)) - .enumerate() - { - let t = ((self.fade_pos + f) as f32 / fade_len as f32).min(1.0); - for (n, o) in new.iter_mut().zip(old) { - *n = *n * t + *o * (1.0 - t); + let target = if self.pending.is_some() { 0.0 } else { 1.0 }; + if self.gain != target || self.gain != 1.0 { + let channels = self.graph.out_channels().max(1); + let fade_len = (self.graph.sample_rate() as usize * SWAP_FADE_MS / 1000).max(1); + let step = 1.0 / fade_len as f32; + for frame in block.chunks_exact_mut(channels) { + self.gain = if target > self.gain { + (self.gain + step).min(target) + } else { + (self.gain - step).max(target) + }; + for s in frame.iter_mut() { + *s *= self.gain; } } - self.fade_pos += frames; - } else { - self.fade_pos = fade_len; } - if self.fade_pos >= fade_len { - if let Some(old) = self.fading.take() { - let _ = self.old_graph_tx.push(old); + if self.gain <= 0.0 { + if let Some(new_graph) = self.pending.take() { + self.swap_in(new_graph); } - return active; } - active.max(old_active) + active } pub(super) fn graph(&self) -> &OutputGraph { @@ -246,14 +244,8 @@ mod tests { let (mut worker, mut ctrl) = dsp_worker(graph(2)); ctrl.send_graph(graph(4)).expect("queue swap"); worker.drain_swaps(); + // A new width cannot share the caller's block, so it goes in at once. assert_eq!(worker.graph.out_channels(), 4, "worker runs the new graph"); - assert_eq!( - ctrl.old_graph_rx.slots(), - 0, - "the old graph is still fading out" - ); - let mut block = block_for(&worker); - worker.next_block(&mut block); assert_eq!( ctrl.old_graph_rx.slots(), 1, @@ -274,13 +266,6 @@ mod tests { ctrl.send_graph(graph(6)).expect("second swap"); worker.drain_swaps(); assert_eq!(worker.graph.out_channels(), 6); - assert_eq!( - ctrl.old_graph_rx.slots(), - 1, - "the skipped graph never plays" - ); - let mut block = block_for(&worker); - worker.next_block(&mut block); assert_eq!(ctrl.old_graph_rx.slots(), 2); } @@ -307,7 +292,7 @@ mod tests { } #[test] - fn a_swap_crossfades_instead_of_stepping() { + fn a_swap_that_changes_the_sound_dips_instead_of_stepping() { let (old, _old_in) = constant_graph(0.5); let (new, _new_in) = constant_graph(-0.5); let (mut worker, mut ctrl) = dsp_worker(old); @@ -353,7 +338,10 @@ mod tests { ctrl.send_graph(b.graph).expect("queue swap"); let mut block = block_for(&worker); worker.next_block(&mut block); - assert!(worker.fading.is_none(), "an emptied graph cannot fade out"); + assert!( + worker.pending.is_none(), + "an edit nobody hears goes in at once" + ); assert_eq!( ctrl.old_graph_rx.slots(), 1, @@ -361,6 +349,104 @@ mod tests { ); } + /// `valid` at 64-frame blocks with `previous` to carry from, its mic fed + /// `value` throughout. + fn carrying_graph( + valid: &crate::audio::graph::ValidGraph, + registry: &mut crate::audio::effects::EffectRegistry, + previous: &std::collections::HashMap, + value: f32, + ) -> (super::super::dag::BuiltOutputGraph, rtrb::Producer) { + let (built, mut producers) = + super::super::dag::graph_tests::rebuild(valid, registry, previous, false); + let mut input = producers + .remove("m") + .unwrap_or_else(|| RingBuffer::new(1).0); + let _ = input.push_partial_slice(&vec![value; 48_000]); + (built, input) + } + + #[test] + fn a_carrying_swap_mid_crossfade_does_not_step() { + // An edit lands while the previous one is still fading: the graph + // fading out must finish its fade, not stop dead. + use super::super::dag::graph_tests::{ + fresh_registry, passthrough_graph, passthrough_with_meter, + }; + let none = std::collections::HashMap::new(); + let mut reg_a = fresh_registry(); + let (a, _in_a) = carrying_graph(&passthrough_graph().0, &mut reg_a, &none, 0.25); + let mut reg_b = fresh_registry(); + let (b, _in_b) = carrying_graph(&passthrough_graph().0, &mut reg_b, &none, -0.25); + let (c, _) = carrying_graph(&passthrough_with_meter(), &mut reg_b, &b.carried, 0.0); + let (mut worker, mut ctrl) = dsp_worker(a.graph); + let mut block = block_for(&worker); + let mut played: Vec = Vec::new(); + let mut play = |worker: &mut DspWorker, played: &mut Vec| { + worker.next_block(&mut block); + played.extend(block.iter().step_by(2)); + }; + for _ in 0..8 { + play(&mut worker, &mut played); + } + ctrl.send_graph(b.graph).expect("first swap"); + play(&mut worker, &mut played); + play(&mut worker, &mut played); + ctrl.send_graph(c.graph).expect("second swap"); + for _ in 0..12 { + play(&mut worker, &mut played); + } + let worst = played[64 * 6..] + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0_f32, f32::max); + let fade = 48_000 * SWAP_FADE_MS / 1000; + assert!(worst <= 1.0 / fade as f32 + 1e-4, "a step of {worst}"); + } + + #[test] + fn swapping_never_touches_the_heap() { + // Adopting carried nodes, crossfading and handing old graphs back all + // happen on the audio thread. + use super::super::dag::graph_tests::{fresh_registry, parallel_with_lookahead}; + let none = std::collections::HashMap::new(); + let mut reg = fresh_registry(); + let (a, _in_a) = carrying_graph( + ¶llel_with_lookahead(false, 2.0, false), + &mut reg, + &none, + 0.25, + ); + let mut reg_fresh = fresh_registry(); + let (fresh, _in_fresh) = carrying_graph( + ¶llel_with_lookahead(false, 2.0, false), + &mut reg_fresh, + &none, + -0.25, + ); + let (carried, _) = carrying_graph( + ¶llel_with_lookahead(true, 2.0, false), + &mut reg_fresh, + &fresh.carried, + 0.0, + ); + let (mut worker, mut ctrl) = dsp_worker(a.graph); + let mut block = block_for(&worker); + worker.next_block(&mut block); + ctrl.send_graph(fresh.graph).expect("fresh swap"); + crate::audio::rt_guard::assert_no_alloc("crossfading swap", || { + for _ in 0..4 { + worker.next_block(&mut block); + } + }); + ctrl.send_graph(carried.graph).expect("carrying swap"); + crate::audio::rt_guard::assert_no_alloc("carrying swap", || { + for _ in 0..16 { + worker.next_block(&mut block); + } + }); + } + #[test] fn full_swap_queue_is_an_error_not_a_block() { let (_worker, mut ctrl) = dsp_worker(graph(2)); From eb61bf91613da1daf4e603d2a1fa9b6bab73d01c Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:26:55 +0300 Subject: [PATCH 14/51] fix(audio): offload worker blocks when overloaded, speaker renderer comes back from a dead device The renderer is built before the stream opens; a plugin no longer reports its offload pad as a working block. --- docs/CONCEPT.md | 1 + src-tauri/src/audio/effects/mod.rs | 1 - src-tauri/src/audio/effects/offload.rs | 63 ++++++- src-tauri/src/audio/pipeline/output/linux.rs | 10 +- src-tauri/src/audio/pipeline/output/render.rs | 172 ++++++++++-------- .../src/audio/pipeline/output/windows.rs | 8 +- src-tauri/src/audio/plugins/host_api.rs | 11 +- src/stories/nodes/plugin.stories.svelte | 3 +- 8 files changed, 178 insertions(+), 91 deletions(-) diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 7fe9d431..aaf1a594 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -96,6 +96,7 @@ A backend that cannot support the feature returns an error; it does not substitu - PipeWire process callbacks and promoted DSP workers are RT code. They may touch only preallocated buffers, SPSC rings and relaxed atomics. - `audio_thread_priority` obtains real-time scheduling through RTKit. Its frame argument is the maximum uninterrupted render quantum, not a latency target. Pass the actual known block size; pass `0` when PipeWire owns an unknown callback quantum. - Linux `RLIMIT_RTTIME` measures CPU time spent under real-time scheduling without a blocking syscall. Crossing the soft limit sends `SIGXCPU`; crossing the hard limit sends `SIGKILL`. Preemption and `sched_yield` do not reset it. Startup prefill runs before promotion, and deadline catch-up must block between blocks. Never raise or disable the OS limit to hide an overload. +- An offload worker runs promoted but parks only when its input is empty; one whose effect cannot keep up would never park, so after 20 ms of uninterrupted work it sleeps 1 ms (`effects/offload.rs`). - A `SIGXCPU` followed by `SIGKILL` from the audio thread with `si_code=SI_KERNEL` is an RT-budget failure. A Rust panic hook and in-process crash modal cannot observe `SIGKILL`; preserve the previous-run unexpected-exit report. ### macOS: CoreAudio diff --git a/src-tauri/src/audio/effects/mod.rs b/src-tauri/src/audio/effects/mod.rs index 4f3924ef..736b61eb 100644 --- a/src-tauri/src/audio/effects/mod.rs +++ b/src-tauri/src/audio/effects/mod.rs @@ -104,7 +104,6 @@ impl RuntimeEffect { pub fn working_block(&self) -> Option { match self { RuntimeEffect::NoiseSuppressor(e) => e.working_block(), - RuntimeEffect::HostedPlugin(e) => e.working_block(), _ => None, } } diff --git a/src-tauri/src/audio/effects/offload.rs b/src-tauri/src/audio/effects/offload.rs index 9d34ff4f..869a561a 100644 --- a/src-tauri/src/audio/effects/offload.rs +++ b/src-tauri/src/audio/effects/offload.rs @@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle, Thread}; +use std::time::{Duration, Instant}; use rtrb::{Consumer, Producer, RingBuffer}; use tracing::warn; @@ -22,6 +23,12 @@ use crate::audio::health; /// Floor for a worker's turnaround: waking it, scheduling slack, and the /// processing itself. const MIN_TURNAROUND_MS: f64 = 5.0; +/// Longest the worker runs without blocking. Linux charges real-time CPU time +/// between blocking calls against `RLIMIT_RTTIME` and kills the process past +/// it; a worker whose effect cannot keep up never runs out of input to park +/// on, so it sleeps briefly instead. It is starving the output either way. +const MAX_BUSY: Duration = Duration::from_millis(20); +const BREATHER: Duration = Duration::from_millis(1); /// Smallest pad that never starves the RT side, for an effect stepping in /// `working_frames` blocks driven by the engine in `block_frames` blocks. @@ -74,6 +81,9 @@ pub struct Offload { /// Samples the worker has taken in and, for whole effect blocks, answered. #[cfg(test)] taken: Arc, + /// Times an overloaded worker stopped to block. + #[cfg(test)] + breathers: Arc, join: Option>, /// Woken after every push, so it sleeps until there is work instead of /// polling for it. @@ -157,6 +167,10 @@ impl Offload { let taken = Arc::new(std::sync::atomic::AtomicUsize::new(0)); #[cfg(test)] let taken_thread = taken.clone(); + #[cfg(test)] + let breathers = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + #[cfg(test)] + let breathers_thread = breathers.clone(); // Handoff cell rather than moving `processor` straight into the closure: // a failed `Builder::spawn` drops its closure internally with no way to // recover a moved value, so the cell is how the caller gets it back. @@ -181,6 +195,7 @@ impl Offload { let mut gathered = vec![0.0f32; working * width]; let mut filled = 0; let mut out = Vec::with_capacity(working * width); + let mut busy_since = Instant::now(); while !stop_thread.load(Ordering::Relaxed) { let avail = worker_in.slots(); let avail = avail - avail % width; // whole frames only @@ -188,6 +203,7 @@ impl Offload { // A wake that lands before this parks leaves the // token set, so the park returns at once. thread::park(); + busy_since = Instant::now(); #[cfg(test)] idle_wakes_thread.fetch_add(1, Ordering::Relaxed); continue; @@ -209,6 +225,12 @@ impl Offload { processor.process(&gathered, &mut out); push_aligned(&mut worker_out, &out, width); filled = 0; + if busy_since.elapsed() >= MAX_BUSY { + thread::sleep(BREATHER); + busy_since = Instant::now(); + #[cfg(test)] + breathers_thread.fetch_add(1, Ordering::Relaxed); + } } #[cfg(test)] taken_thread.fetch_add(n, Ordering::Relaxed); @@ -235,6 +257,8 @@ impl Offload { idle_wakes, #[cfg(test)] taken, + #[cfg(test)] + breathers, worker: join.thread().clone(), join: Some(join), width, @@ -307,7 +331,6 @@ impl Drop for Offload { #[cfg(test)] mod tests { use super::*; - use std::time::{Duration, Instant}; /// Hands its input straight back, checking it only ever gets whole blocks /// of `frames` when it has a block size of its own. @@ -470,6 +493,44 @@ mod tests { ); } + /// Takes `cost` of CPU per block, never blocking: an effect slower than + /// real time. + struct Slow { + cost: Duration, + } + + impl BlockProcessor for Slow { + fn process(&mut self, input: &[f32], output: &mut Vec) { + let started = Instant::now(); + while started.elapsed() < self.cost { + std::hint::spin_loop(); + } + output.extend_from_slice(input); + } + } + + #[test] + fn an_overloaded_worker_still_blocks_now_and_then() { + // A backlog it cannot clear keeps it from ever finding its input + // empty; it must still block within the busy limit. + let slow = Slow { + cost: Duration::from_millis(2), + }; + let Ok(mut offload) = Offload::spawn("test", slow, 2, None, 64, 48_000) else { + panic!("spawn offload") + }; + let mut data = vec![0.0f32; 64 * 2]; + for _ in 0..60 { + offload.process(&mut data); + } + thread::sleep(Duration::from_millis(100)); + let breathers = offload.breathers.load(Ordering::Relaxed); + assert!( + breathers >= 2, + "ran {breathers} breathers in 100 ms of backlog" + ); + } + #[test] fn an_idle_worker_sleeps_until_woken() { // A 1 ms poll wakes ~100 times in 100 ms; a parked worker only when a diff --git a/src-tauri/src/audio/pipeline/output/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs index 4c7219c1..c3d4c373 100644 --- a/src-tauri/src/audio/pipeline/output/linux.rs +++ b/src-tauri/src/audio/pipeline/output/linux.rs @@ -48,6 +48,11 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( _app: &AppHandle, ) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc, SpeakerIo)> { info!(node = %spec.node_id, sample_rate = spec.sample_rate, "opening speaker stream (PipeWire)"); + // Built before playback starts: a renderer that cannot be built must not + // leave a PipeWire stream running. + let block_frames = graph.block_frames(); + let (renderer, ctrl, io) = + speaker_renderer(graph, spec.sample_rate, spec.out_channels, None, meter)?; let dead = Arc::new(AtomicBool::new(false)); let (mut link, mut fill) = speaker_callback(); @@ -60,12 +65,9 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( &spec.node_id, spec.sample_rate, spec.out_channels, - graph.block_frames(), + block_frames, fill_pw, )?; - - let (renderer, ctrl, io) = - speaker_renderer(graph, spec.sample_rate, spec.out_channels, None, meter)?; link.attach(renderer); Ok(( SpeakerHandle { diff --git a/src-tauri/src/audio/pipeline/output/render.rs b/src-tauri/src/audio/pipeline/output/render.rs index 6cd593ea..4ec7dd78 100644 --- a/src-tauri/src/audio/pipeline/output/render.rs +++ b/src-tauri/src/audio/pipeline/output/render.rs @@ -8,15 +8,14 @@ //! //! The renderer owns the output graph, so it must never be freed on the audio //! thread or leaked with a callback closure (cpal's CoreAudio backend leaks the -//! closure of a non-default device). It therefore reaches the callback through -//! an SPSC handoff and is handed back to the control thread on retire. +//! closure of a non-default device). It therefore sits in a slot the callback +//! only try-locks, and the control thread takes it back out on retire, from a +//! dead device too. use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use rtrb::{Consumer, Producer, RingBuffer}; - use crate::audio::effects::{update_meter, MeterHandle}; use crate::audio::health; use crate::audio::resample::FixedRateResampler; @@ -25,8 +24,7 @@ use crate::error::AppResult; use super::super::dag::OutputGraph; use super::super::worker::{dsp_worker, DspWorker, WorkerCtrl}; -/// How long a retire waits for the callback to hand the renderer back. A live -/// device calls back within one buffer; a dead one never will. +/// Longest a retire waits for the stop fade to play out. const RETIRE_TIMEOUT: Duration = Duration::from_millis(500); /// A starting speaker fades in, and a retiring one fades out, over this, so @@ -224,13 +222,25 @@ impl SpeakerRenderer { } } +/// A speaker's renderer, shared by its device callback and the control +/// thread. The callback only ever try-locks it, and a miss plays silence: the +/// control thread holds it only to put the renderer in or take it back out, +/// and the renderer can be taken back even from a device that stopped calling. +type Slot = Arc>>; + +/// How long a retire waits without a single callback before calling the +/// device dead and taking the renderer back anyway. +const DEAD_DEVICE_AFTER: Duration = Duration::from_millis(100); + /// The audio-thread end of a speaker: renders once the renderer has arrived, /// silence before that and after a retire. pub(in crate::audio::pipeline) struct SpeakerCallback { - incoming: Consumer, - outgoing: Producer, + slot: Slot, retire: Arc, - renderer: Option, + /// Set once the stop fade has played out. + faded: Arc, + /// Callbacks so far, for telling a slow retire from a dead device. + calls: Arc, /// Frames of the stop fade still to play, once a retire is asked for. fade_left: Option, /// Frames played since the renderer arrived, while the start fade runs. @@ -239,44 +249,28 @@ pub(in crate::audio::pipeline) struct SpeakerCallback { impl SpeakerCallback { pub(in crate::audio::pipeline) fn fill(&mut self, out: &mut [f32], callback_frames: usize) { - if self.retire.load(Ordering::Acquire) { - self.fade_out(out, callback_frames); - return; - } - if self.renderer.is_none() { - self.renderer = self.incoming.pop().ok(); - } - let Some(r) = self.renderer.as_mut() else { + self.calls.fetch_add(1, Ordering::Relaxed); + let retiring = self.retire.load(Ordering::Acquire); + let Ok(mut slot) = self.slot.try_lock() else { out.fill(0.0); return; }; - r.render(out, callback_frames); - let total = (r.io.sample_rate * STOP_FADE_MS / 1000).max(1) as usize; - if self.faded_in < total { - for frame in out.chunks_exact_mut(r.channels) { - let gain = (self.faded_in as f32 / total as f32).min(1.0); - for s in frame.iter_mut() { - *s *= gain; - } - self.faded_in += 1; - } - } - } - - /// Plays the stop fade, then hands the renderer back and goes silent. - fn fade_out(&mut self, out: &mut [f32], callback_frames: usize) { - let Some(r) = self.renderer.as_mut() else { - // Retired before it ever played: hand it straight back. - if let Ok(r) = self.incoming.pop() { - let _ = self.outgoing.push(r); - } + let Some(r) = slot.as_mut() else { out.fill(0.0); + if retiring { + self.faded.store(true, Ordering::Release); + } return; }; let total = (r.io.sample_rate * STOP_FADE_MS / 1000).max(1) as usize; - // A stop during the start fade goes down from where that fade got to. - let left = self.fade_left.get_or_insert(self.faded_in.min(total)); - if *left > 0 { + if retiring { + // A stop during the start fade goes down from where that fade got to. + let left = self.fade_left.get_or_insert(self.faded_in.min(total)); + if *left == 0 { + out.fill(0.0); + self.faded.store(true, Ordering::Release); + return; + } r.render(out, callback_frames); for frame in out.chunks_exact_mut(r.channels) { let gain = *left as f32 / total as f32; @@ -285,40 +279,49 @@ impl SpeakerCallback { } *left = left.saturating_sub(1); } - if *left > 0 { - return; + if *left == 0 { + self.faded.store(true, Ordering::Release); } - } else { - out.fill(0.0); + return; } - if let Some(r) = self.renderer.take() { - let _ = self.outgoing.push(r); + r.render(out, callback_frames); + if self.faded_in < total { + for frame in out.chunks_exact_mut(r.channels) { + let gain = (self.faded_in as f32 / total as f32).min(1.0); + for s in frame.iter_mut() { + *s *= gain; + } + self.faded_in += 1; + } } } } /// The control-thread end of a speaker's handoff. pub(in crate::audio::pipeline) struct SpeakerLink { - incoming: Producer, - outgoing: Consumer, + slot: Slot, retire: Arc, + faded: Arc, + calls: Arc, } pub(in crate::audio::pipeline) fn speaker_link() -> (SpeakerLink, SpeakerCallback) { - let (in_tx, in_rx) = RingBuffer::new(1); - let (out_tx, out_rx) = RingBuffer::new(1); + let slot: Slot = Arc::new(Mutex::new(None)); let retire = Arc::new(AtomicBool::new(false)); + let faded = Arc::new(AtomicBool::new(false)); + let calls = Arc::new(AtomicU64::new(0)); ( SpeakerLink { - incoming: in_tx, - outgoing: out_rx, + slot: slot.clone(), retire: retire.clone(), + faded: faded.clone(), + calls: calls.clone(), }, SpeakerCallback { - incoming: in_rx, - outgoing: out_tx, + slot, retire, - renderer: None, + faded, + calls, fade_left: None, faded_in: 0, }, @@ -329,29 +332,35 @@ impl SpeakerLink { /// Hand the renderer to the callback. Call once the stream is open, so a /// failed open never takes the graph down with its closure. pub(in crate::audio::pipeline) fn attach(&mut self, renderer: SpeakerRenderer) { - if self.incoming.push(renderer).is_err() { + let mut slot = self.slot.lock().unwrap_or_else(|e| e.into_inner()); + if slot.replace(renderer).is_some() { tracing::error!("speaker renderer attached twice"); } } - /// Take the renderer back from the callback so it drops here, not on the - /// audio thread. `None` when the device stopped calling back first. + /// Fade the speaker out and take the renderer back, so it drops here and + /// not on the audio thread or with a callback closure cpal never frees. + /// Waits for the fade while the device keeps calling back; a device that + /// has stopped calling gets no fade, but still gives the renderer back. pub(in crate::audio::pipeline) fn retire(&mut self) -> Option { self.retire.store(true, Ordering::Release); - let deadline = Instant::now() + RETIRE_TIMEOUT; - loop { - if let Ok(r) = self.outgoing.pop() { - return Some(r); - } - if self.outgoing.is_abandoned() { - return None; + let started = Instant::now(); + let mut calls = self.calls.load(Ordering::Relaxed); + let mut last_call = started; + while !self.faded.load(Ordering::Acquire) { + let now = Instant::now(); + let seen = self.calls.load(Ordering::Relaxed); + if seen != calls { + calls = seen; + last_call = now; } - if Instant::now() >= deadline { - tracing::warn!("speaker callback never handed its renderer back"); - return None; + if now - last_call >= DEAD_DEVICE_AFTER || now - started >= RETIRE_TIMEOUT { + tracing::warn!("speaker stopped calling back; taking its renderer without a fade"); + break; } std::thread::sleep(Duration::from_millis(1)); } + self.slot.lock().unwrap_or_else(|e| e.into_inner()).take() } } @@ -651,11 +660,32 @@ mod tests { } #[test] - fn retire_gives_up_on_a_dead_device() { + fn a_dead_device_still_gives_its_renderer_back_quickly() { + // The device stopped calling: no fade will ever play, but the graph + // must still come back to drop here, and without the full timeout. let (mut link, cb) = speaker_link(); let t = rig(64, SR); link.attach(t.r); + let started = Instant::now(); + assert!(link.retire().is_some(), "the renderer came back"); + assert!( + started.elapsed() < RETIRE_TIMEOUT, + "waited {:?}", + started.elapsed() + ); drop(cb); - assert!(link.retire().is_none()); + } + + #[test] + fn a_callback_that_misses_the_lock_plays_silence() { + let (mut link, mut cb) = speaker_link(); + let mut t = rig(64, SR); + feed(&mut t.input, &vec![0.5; 4_096]); + link.attach(t.r); + let held = link.slot.lock().unwrap(); + let mut out = vec![1.0; 64 * 2]; + cb.fill(&mut out, 64); + assert!(out.iter().all(|&s| s == 0.0)); + drop(held); } } diff --git a/src-tauri/src/audio/pipeline/output/windows.rs b/src-tauri/src/audio/pipeline/output/windows.rs index 91ca0a52..34f3a7b8 100644 --- a/src-tauri/src/audio/pipeline/output/windows.rs +++ b/src-tauri/src/audio/pipeline/output/windows.rs @@ -73,6 +73,11 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( "opening speaker stream (WASAPI)", ); + // Built before the stream opens: a renderer that cannot be built must not + // leave behind a stream nothing pauses. + let (renderer, ctrl, io) = + speaker_renderer(graph, spec.sample_rate, spec.out_channels, None, meter)?; + let dead = Arc::new(AtomicBool::new(false)); let (mut link, fill) = speaker_callback(); @@ -99,9 +104,6 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( fill, err_cb, )?; - - let (renderer, ctrl, io) = - speaker_renderer(graph, spec.sample_rate, spec.out_channels, None, meter)?; link.attach(renderer); Ok(( SpeakerHandle { diff --git a/src-tauri/src/audio/plugins/host_api.rs b/src-tauri/src/audio/plugins/host_api.rs index 6a19b43e..ff3b35af 100644 --- a/src-tauri/src/audio/plugins/host_api.rs +++ b/src-tauri/src/audio/plugins/host_api.rs @@ -146,8 +146,6 @@ pub struct HostedEffect { backend: HostedBackend, channels: usize, latency: usize, - /// The offload pad when it exceeds the engine block. - working_block: Option, } enum HostedBackend { @@ -165,7 +163,6 @@ impl HostedEffect { backend: HostedBackend::Inline { node }, channels: width, latency, - working_block: None, }; } let processor = HostedProcessor { @@ -173,10 +170,11 @@ impl HostedEffect { width, scratch: Vec::with_capacity(MAX_BUFFER_FRAMES * width), }; + // A plugin takes the engine's blocks as they come; the offload's pad is + // latency it adds, not a block it works in. match Offload::spawn("plugin", processor, width, None, block_frames, sample_rate) { Ok(o) => Self { latency: latency + o.latency_frames(), - working_block: (o.latency_frames() > block_frames).then_some(o.latency_frames()), backend: HostedBackend::Offloaded(o), channels: width, }, @@ -184,14 +182,9 @@ impl HostedEffect { backend: HostedBackend::Inline { node: p.node }, channels: width, latency, - working_block: None, }, } } - - pub fn working_block(&self) -> Option { - self.working_block - } } impl Effect for HostedEffect { diff --git a/src/stories/nodes/plugin.stories.svelte b/src/stories/nodes/plugin.stories.svelte index 4f5fa1be..267f8c68 100644 --- a/src/stories/nodes/plugin.stories.svelte +++ b/src/stories/nodes/plugin.stories.svelte @@ -28,7 +28,7 @@ { plugin: 1, showParams: false, bypassed: false } ), wiringArgs(2), - envArgs(['running', 'plugins', 'pluginParams', 'pluginEditor', 'bufferFrames', 'workingBlock']) + envArgs(['running', 'plugins', 'pluginParams', 'pluginEditor']) ); const { Story } = defineMeta({ title: 'Nodes/Effects/Plugin', argTypes, args }); @@ -44,7 +44,6 @@ - From 722c3a85b35090b728a4ebf2e0fd67095732c27f Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:26:55 +0300 Subject: [PATCH 15/51] fix(latency): count network receive buffers and fan-out paths, apply engine format changes at once --- src-tauri/src/audio/pipeline/dag.rs | 81 +++++++++++++++--- src-tauri/src/audio/pipeline/mod.rs | 114 +++++++++++++++++++++---- src-tauri/src/audio/stream_recv.rs | 17 ++++ src/lib/modules/audio/stores.svelte.ts | 7 ++ src/routes/settings/+page.svelte | 25 ++++-- 5 files changed, 213 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 1b42e89d..8e76eeb0 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -224,7 +224,7 @@ pub(super) struct SourceStats { } impl SourceStats { - fn new() -> Self { + pub(super) fn new() -> Self { Self { xrun: Arc::new(AtomicU64::new(0)), stalled: Arc::new(AtomicU64::new(0)), @@ -262,6 +262,8 @@ pub(super) struct SourceMeta { /// Capture-side fed/dropped counters, filled in by `pipeline/mod.rs` /// after `BroadcastTx::add` returns them for this source's ring. pub capture: Option, + /// For a ring-source reading a fan-out node, the path before the ring. + pub upstream: Option, } /// Identifies one output for the tick thread: its per-block counter plus the @@ -1482,7 +1484,10 @@ pub(super) struct BuiltOutputGraph { pub output: OutputMeta, /// Effect node id -> (node index, channel width). Used to attach publish /// taps to nodes that fan out to other outputs. - pub node_meta: HashMap, + pub node_meta: HashMap, + /// Fill gauges of the network receive buffers this graph reads, in + /// 48 kHz samples. + pub receive_buffers: Vec>, /// Latency and working block of every effect built here. pub node_timings: Vec, /// What the next build of this output needs to carry these nodes over. @@ -1605,7 +1610,8 @@ pub(super) fn build_output_graph( let mut id_to_index: HashMap = HashMap::new(); // Effect node id -> (index in `nodes`, channel width). Lets the caller wire // publish taps onto a node that fans out to other outputs' ring-sources. - let mut node_meta: HashMap = HashMap::new(); + let mut node_meta: HashMap = HashMap::new(); + let mut receive_buffers: Vec> = Vec::new(); let mut controls: Vec<(String, EffectControl)> = Vec::new(); let mut bypasses: Vec<(String, Arc)> = Vec::new(); let mut meters: Vec = Vec::new(); @@ -1623,7 +1629,14 @@ pub(super) fn build_output_graph( for id in &topo { // A fan-out node owned by an earlier output: read its published block // from the ring instead of rebuilding the whole upstream chain. - if let Some((consumer, owner_sr, width, clock)) = cut_leaves.remove(id) { + if let Some(CutLeaf { + consumer, + owner_sr, + width, + clock, + upstream, + }) = cut_leaves.remove(id) + { let source = ring_source( id, consumer, @@ -1644,6 +1657,7 @@ pub(super) fn build_output_graph( input_id: None, output_id: output_id.unwrap_or(MONITOR_KEY).to_string(), capture: None, + upstream: Some(upstream), }); id_to_index.insert(id.clone(), nodes.len()); nodes.push(DagNode::Source(source)); @@ -1684,6 +1698,7 @@ pub(super) fn build_output_graph( _ => None, }; if let Some(receiver) = network { + receive_buffers.push(receiver.fill_gauge()); let mut handles: Vec = valid .edges .iter() @@ -1793,6 +1808,7 @@ pub(super) fn build_output_graph( input_id: Some(id.clone()), output_id: output_id.unwrap_or(MONITOR_KEY).to_string(), capture: None, + upstream: None, }); let source = SourceState { label, @@ -2045,7 +2061,10 @@ pub(super) fn build_output_graph( handle_bufs, taps: Vec::new(), })); - node_meta.insert(id.clone(), (nodes.len() - 1, eff_channels)); + node_meta.insert( + id.clone(), + (nodes.len() - 1, eff_channels, max_upstream + own), + ); node_latencies.push(max_upstream + own); node_channels.push(eff_channels); } @@ -2196,6 +2215,7 @@ pub(super) fn build_output_graph( io: None, }, node_meta, + receive_buffers, node_timings, carried, carried_inputs, @@ -2278,6 +2298,7 @@ pub(super) fn build_output_graph( io: None, }, node_meta, + receive_buffers, node_timings, carried, carried_inputs, @@ -2331,8 +2352,22 @@ fn node_identities( } /// A fan-out node's published ring as another output reads it: the ring, the -/// owner's rate and width, and when the owner wrote to it. -pub(super) type CutLeaf = (Consumer, u32, usize, Arc); +/// owner's rate and width, when the owner wrote to it, and what came before. +pub(super) struct CutLeaf { + pub consumer: Consumer, + pub owner_sr: u32, + pub width: usize, + pub clock: Arc, + pub upstream: Upstream, +} + +/// Where a fan-out node's audio was before its ring: the output that owns +/// it, and that graph's delay compensation up to the node, in its frames. +#[derive(Clone)] +pub(super) struct Upstream { + pub owner: String, + pub frames: usize, +} /// Builds a `SourceState` that reads a fan-out node's published block from a /// ring (written at `owner_sr`) and resamples it to this graph's `output_sr`. @@ -4405,11 +4440,23 @@ pub(super) mod graph_tests { // Building B with the cut leaf: A's published block feeds B. let (mut built_a, mut producers_a) = build(Some("a"), SR, &valid, SR, false); - let (node_idx, width) = built_a.node_meta["g"]; + let (node_idx, width, _) = built_a.node_meta["g"]; assert_eq!(width, 2); let (prod, cons) = RingBuffer::::new(SR as usize * 2); let mut cut_leaves = HashMap::new(); - cut_leaves.insert("g".to_string(), (cons, SR, 2, Arc::default())); + cut_leaves.insert( + "g".to_string(), + CutLeaf { + consumer: cons, + owner_sr: SR, + width: 2, + clock: Arc::default(), + upstream: Upstream { + owner: "a".into(), + frames: 0, + }, + }, + ); let mut producer_pairs = Vec::new(); let native = valid.inputs.iter().map(|i| (i.id.clone(), SR)).collect(); @@ -5033,10 +5080,22 @@ pub(super) mod graph_tests { }; let valid = g.validate().expect("valid"); let (mut built_a, mut producers_a) = build(Some("a"), 44_100, &valid, 44_100, false); - let (node_idx, _) = built_a.node_meta["g"]; + let (node_idx, _, _) = built_a.node_meta["g"]; let (prod, cons) = RingBuffer::::new(SR as usize * 2); let mut cut_leaves = HashMap::new(); - cut_leaves.insert("g".to_string(), (cons, 44_100, 2, Arc::default())); + cut_leaves.insert( + "g".to_string(), + CutLeaf { + consumer: cons, + owner_sr: 44_100, + width: 2, + clock: Arc::default(), + upstream: Upstream { + owner: "a".into(), + frames: 0, + }, + }, + ); let mut producer_pairs = Vec::new(); let native = valid diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index a251b6a0..3bc89960 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -102,6 +102,8 @@ pub struct ActivePipeline { /// Bridges of outputs being swapped in place, by (output, input): a source /// carried over keeps the bridge that feeds its ring. swapping_slots: HashMap<(String, String), Vec>, + /// Fill gauges of the network receive buffers each output reads. + receive_buffers: HashMap>>, /// Each speaker's overload count at the last report. reported_overloads: HashMap, } @@ -187,6 +189,7 @@ impl ActivePipeline { node_timings: HashMap::new(), carried_nodes: HashMap::new(), swapping_slots: HashMap::new(), + receive_buffers: HashMap::new(), reported_overloads: HashMap::new(), } } @@ -309,20 +312,21 @@ impl ActivePipeline { report.nodes.sort_by(|a, b| a.node_id.cmp(&b.node_id)); for (id, s) in &self.speakers { let io = &s.io; - let inputs: Vec = self + let mut inputs: Vec = self .source_stats .iter() .filter(|m| m.output_id == *id) - .map(|m| { - let input = m.input_id.as_ref().and_then(|i| self.inputs.get(i)); - PathInput { - device: input.and_then(|i| i.io), - queue_frames: m.stats.queue_frames.load(Ordering::Relaxed), - normalizer_frames: input.map_or(0, |i| i.normalizer_frames), - rate: m.native_sr, - } - }) + .map(|m| self.path_input(m)) .collect(); + // A network input's latency is the receive buffer it plays from. + for fill in self.receive_buffers.get(id).into_iter().flatten() { + inputs.push(PathInput { + device: None, + queue_frames: fill.load(Ordering::Relaxed) as u64, + normalizer_frames: 0, + rate: crate::audio::stream_recv::SR, + }); + } let out = PathOutput { pipeline_rate, graph_latency_frames: io.graph_latency_frames.load(Ordering::Relaxed) as u64, @@ -353,6 +357,38 @@ impl ActivePipeline { report } + /// One source's share of a path. A ring-source reading a fan-out node + /// carries on from the slowest source of the graph that owns the node, + /// through that graph's delay compensation up to it, all counted in the + /// ring's own rate. + fn path_input(&self, m: &SourceMeta) -> PathInput { + let input = m.input_id.as_ref().and_then(|i| self.inputs.get(i)); + let own = PathInput { + device: input.and_then(|i| i.io), + queue_frames: m.stats.queue_frames.load(Ordering::Relaxed), + normalizer_frames: input.map_or(0, |i| i.normalizer_frames), + rate: m.native_sr, + }; + let Some(up) = &m.upstream else { + return own; + }; + let in_rate = |frames: u64, rate: u32| frames * m.native_sr as u64 / rate.max(1) as u64; + let before = self + .source_stats + .iter() + .filter(|s| s.output_id == up.owner) + .map(|s| self.path_input(s)) + .max_by_key(|p| in_rate(p.queue_frames + p.normalizer_frames, p.rate)); + PathInput { + device: before.and_then(|b| b.device), + queue_frames: own.queue_frames + + up.frames as u64 + + before.map_or(0, |b| in_rate(b.queue_frames, b.rate)), + normalizer_frames: before.map_or(0, |b| in_rate(b.normalizer_frames, b.rate)), + rate: m.native_sr, + } + } + fn teardown(&mut self) { if let Some(current) = &self.current { for inp in ¤t.inputs { @@ -370,6 +406,7 @@ impl ActivePipeline { self.stale_bridges.clear(); self.carried_nodes.clear(); self.swapping_slots.clear(); + self.receive_buffers.clear(); self.inputs.clear(); self.meters.clear(); self.gr_handles.clear(); @@ -967,6 +1004,8 @@ impl ActivePipeline { self.keep_carried_bridges(&out.id, &built.carried_inputs, &mut carried_captures); self.carried_nodes .insert(out.id.clone(), std::mem::take(&mut built.carried)); + self.receive_buffers + .insert(out.id.clone(), std::mem::take(&mut built.receive_buffers)); // Wire publish taps for nodes this output owns and other outputs read. for (node, cons) in &cut_plan.consumers { if cons.is_empty() @@ -974,7 +1013,7 @@ impl ActivePipeline { { continue; } - let Some(&(idx, width)) = built.node_meta.get(node) else { + let Some(&(idx, width, latency)) = built.node_meta.get(node) else { continue; }; for o2 in cons { @@ -982,10 +1021,19 @@ impl ActivePipeline { rtrb::RingBuffer::::new(ring_capacity_frames(output_sr) * width); let clock = Arc::new(WriteClock::default()); built.graph.attach_tap(idx, prod, clock.clone()); - pending_cuts - .entry(o2.clone()) - .or_default() - .insert(node.clone(), (consumer, output_sr, width, clock)); + pending_cuts.entry(o2.clone()).or_default().insert( + node.clone(), + dag::CutLeaf { + consumer, + owner_sr: output_sr, + width, + clock, + upstream: dag::Upstream { + owner: out.id.clone(), + frames: latency, + }, + }, + ); } } for (inp_id, prod) in my_pairs { @@ -1647,6 +1695,42 @@ mod tests { assert!(p.speakers.is_empty()); } + #[test] + fn a_fan_out_path_counts_the_owner_graph_before_its_ring() { + // Output b reads node g from a ring that output a publishes: its path + // is a's slowest source, a's compensation up to g, then b's ring. + let meta = |output: &str, queue: u64, upstream: Option| { + let stats = dag::SourceStats::new(); + stats.queue_frames.store(queue, Ordering::Relaxed); + SourceMeta { + label: String::new(), + stats, + channels: 2, + native_sr: 48_000, + frames_per_block: 64, + input_id: None, + output_id: output.into(), + capture: None, + upstream, + } + }; + let mut p = ActivePipeline::new(); + p.source_stats = vec![ + meta("a", 480, None), + meta("a", 200, None), + meta( + "b", + 100, + Some(dag::Upstream { + owner: "a".into(), + frames: 96, + }), + ), + ]; + let path = p.path_input(&p.source_stats[2]); + assert_eq!(path.queue_frames, 480 + 96 + 100); + } + #[test] fn a_carried_source_keeps_the_bridge_that_feeds_its_ring() { use crate::audio::pipeline::file_reader::file_reader_test_emitter::TestEmitter; diff --git a/src-tauri/src/audio/stream_recv.rs b/src-tauri/src/audio/stream_recv.rs index 039879b5..df4fd2ad 100644 --- a/src-tauri/src/audio/stream_recv.rs +++ b/src-tauri/src/audio/stream_recv.rs @@ -675,6 +675,10 @@ pub struct ChannelReceiver { /// That source and its rate. Following another, or the same one after it /// refilled, starts the loop settling afresh. steered: Cell>, + /// Smoothed fill of the emptiest playing source, in 48 kHz samples: the + /// latency the receive buffer adds. A gauge for the latency report. + fill: Arc, + fill_avg: Cell, // Last emitted mix, held when the tap map is briefly locked for registration // so a lock miss is an inaudible repeat rather than a silent click. last_mix: std::cell::RefCell>, @@ -732,6 +736,8 @@ impl ChannelReceiver { drift_loop: std::cell::RefCell::new(DriftLoop::new(SR, need.max(1))), arrival: std::cell::RefCell::new(ArrivalClock::new(SR)), steered: Cell::new(None), + fill: Arc::new(AtomicU32::new(0)), + fill_avg: Cell::new(0.0), last_mix: std::cell::RefCell::new(vec![0.0; handle.block_frames * 2]), plans: std::cell::RefCell::new(Vec::with_capacity(8)), } @@ -858,6 +864,12 @@ impl ChannelReceiver { .min_by_key(|p| p.min_backlog) .map_or(0, |p| p.arrived); self.account(starving, live.map(|p| (p.min_backlog, p.need)), arrived); + if let Some(p) = live { + let avg = self.fill_avg.get(); + let avg = avg + (p.min_backlog as f64 - avg) * 0.02; + self.fill_avg.set(avg); + self.fill.store(avg.round() as u32, Ordering::Relaxed); + } let live = live.map(|p| p.id); match plans.iter_mut().find(|p| Some(p.id) == live) { Some(p) if !p.conceal => p.splice = self.steer(p, now), @@ -993,6 +1005,11 @@ impl ChannelReceiver { 0 } + /// Smoothed receive-buffer fill in 48 kHz samples, for the latency report. + pub fn fill_gauge(&self) -> Arc { + self.fill.clone() + } + /// Copy one channel's already-resampled scratch into `out`. pub fn channel(&self, key: &str, out: &mut [f32]) { out.fill(0.0); diff --git a/src/lib/modules/audio/stores.svelte.ts b/src/lib/modules/audio/stores.svelte.ts index 2c53fbbf..ed2ef8fc 100644 --- a/src/lib/modules/audio/stores.svelte.ts +++ b/src/lib/modules/audio/stores.svelte.ts @@ -416,6 +416,13 @@ class AudioStore { } } + /** Reopens every device of the running pipeline at the current engine + * sample rate and buffer size. No-op when nothing is running. */ + async applyEngineFormat(): Promise { + if (!this.isRunning || !this.fullGraph) return; + await this.restartPipeline(this.fullGraph); + } + /** Apply a new graph to the running pipeline. Uses `reconcile_pipeline`, * which diffs the new graph and only touches what changed — input * streams stay alive across edits when their spec is unchanged. diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 6a70544c..81da5beb 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -20,6 +20,7 @@ import NumberStepper from '$lib/components/number_stepper.svelte'; import { formatHz, formatLatencyMs } from '$lib/components/format'; import PresetsSection from './_presets_section.svelte'; + import { audioStore } from '$lib/modules/audio/stores.svelte'; const SHAPES: { value: EdgeShape; label: string; hint: string }[] = [ { value: 'bezier', label: 'Bezier', hint: 'Smooth curve, the default' }, @@ -45,9 +46,23 @@ ]; function resetAll() { + const rate = appSettings.pipelineSampleRate; + const frames = appSettings.bufferFrames; edgeSettings.reset(); appSettings.reset(); void disableAutostart(); + if (appSettings.pipelineSampleRate !== rate || appSettings.bufferFrames !== frames) applyEngineFormat(); + } + + /** Every device reopens at the new format, so a running pipeline takes it now. */ + function applyEngineFormat() { + audioStore.applyEngineFormat().catch((e) => audioStore.reportError(e)); + } + + function setEngine(key: K, value: (typeof appSettings)[K]) { + if (appSettings[key] === value) return; + setApp(key, value); + applyEngineFormat(); } let customRateSelected = $state(false); @@ -275,7 +290,7 @@ type="button" onclick={() => { customRateSelected = false; - setApp('pipelineSampleRate', rate); + setEngine('pipelineSampleRate', rate); }} class={[ 'rounded-lg border px-3 py-1 font-mono text-[11px] tabular-nums transition-colors', @@ -308,7 +323,7 @@ max={384000} step={1} label="Custom sample rate" - onchange={(v) => setApp('pipelineSampleRate', Math.min(Math.max(Math.round(v) || 48000, 8000), 384000))} /> + onchange={(v) => setEngine('pipelineSampleRate', Math.min(Math.max(Math.round(v) || 48000, 8000), 384000))} /> Hz (step: 1 Hz)
{/if} @@ -319,8 +334,8 @@

Buffer size

Samples each device callback carries. Smaller buffers cut latency but leave the CPU less time per block; raise it if the - latency readout reports dropped audio. Nodes that need larger blocks run at their own size and show an hourglass. Applies the next - time the pipeline starts. + latency readout reports dropped audio. Nodes that need larger blocks run at their own size and show an hourglass. A running + pipeline reopens its devices at the new size right away.

@@ -328,7 +343,7 @@ {#each BUFFER_FRAME_PRESETS as frames (frames)}