diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70fcb53..1615ba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,10 +96,24 @@ jobs: fi if [ "$RUNNER_OS" = "Windows" ]; then cargo test --no-run + elif [ "$RUNNER_OS" = "macOS" ]; then + # The real-time scenarios run in their own advisory step below. + cargo test --lib --bins --test updater + cargo test --doc else cargo test fi + # The macOS runner is a virtual machine that pauses threads for + # milliseconds at a time, longer than a small engine buffer lasts, so the + # real-time scenarios report there without failing the build. Linux runs + # them as a gate. + - name: Real-time scenarios (advisory on macOS) + if: runner.os == 'macOS' + continue-on-error: true + working-directory: src-tauri + run: cargo test --test pipelines --test transitions + - name: TS-RS bindings up to date run: git diff --exit-code -- src/lib/modules/pipeline/generated diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 3f67ab5..c1b2196 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, a network send thread after a block went into its rings; `audio/wake.rs`). 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`. Ring buffers: `rtrb` SPSC. Use `bulk_pop` / `bulk_push`, never per-sample loops. @@ -40,8 +42,13 @@ 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 and recording at `TIMER_BLOCK_FRAMES`, a wire sender at the engine block, since its latency is heard at the far end. 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. 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. ## Effects @@ -89,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/Cargo.lock b/src-tauri/Cargo.lock index 1bbe2a0..da98861 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" @@ -4078,10 +4061,13 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ + "block2", "dispatch2", + "libc", "objc2", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -4293,29 +4279,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 +6181,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk 0.9.0", + "ndk", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -6296,6 +6259,7 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-audio-toolbox", + "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation", @@ -6783,8 +6747,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 +8801,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 +8834,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 +8852,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 +8876,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 +8935,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 +8953,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 +8971,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 +9106,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 +9467,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 6dc588d..202a1a3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,44 @@ opt-level = 3 opt-level = 3 [profile.dev.package.rustfft] opt-level = 3 +# EBU R128 with true-peak oversampling runs every block of a LUFS meter; +# unoptimized it cannot keep a 64-frame speaker in real time. +[profile.dev.package.ebur128] +opt-level = 3 + +# A file input decodes ahead of playback on its own thread; unoptimized, the +# FLAC and MP3 decoders barely keep real time on a slow machine and the +# source runs dry. +[profile.dev.package.symphonia-core] +opt-level = 3 +[profile.dev.package.symphonia-common] +opt-level = 3 +[profile.dev.package.symphonia-metadata] +opt-level = 3 +[profile.dev.package.symphonia-bundle-flac] +opt-level = 3 +[profile.dev.package.symphonia-bundle-mp3] +opt-level = 3 +[profile.dev.package.symphonia-codec-pcm] +opt-level = 3 +[profile.dev.package.symphonia-codec-aac] +opt-level = 3 +[profile.dev.package.symphonia-codec-alac] +opt-level = 3 +[profile.dev.package.symphonia-codec-adpcm] +opt-level = 3 +[profile.dev.package.symphonia-codec-vorbis] +opt-level = 3 +[profile.dev.package.symphonia-format-riff] +opt-level = 3 +[profile.dev.package.symphonia-format-isomp4] +opt-level = 3 +[profile.dev.package.symphonia-format-ogg] +opt-level = 3 +[profile.dev.package.symphonia-format-mkv] +opt-level = 3 +[profile.dev.package.symphonia-format-caf] +opt-level = 3 # audiopus_sys uses CMake. At opt-level 0 CMake selects the debug MSVC runtime, # while Rust uses the release runtime even in dev/test profiles on Windows. @@ -76,7 +114,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" @@ -171,6 +209,7 @@ objc2 = "0.6" # coreaudio-sys is deliberately not used (it is in maintenance mode and # coreaudio-rs itself moved to the objc2 crates). objc2-audio-toolbox = "0.3.2" +objc2-core-audio = "0.3.2" objc2-core-audio-types = "0.3.2" objc2-core-foundation = { version = "0.3.2", features = [ "CFData", "CFError", "CFPropertyList", diff --git a/src-tauri/native/CATapCapture.swift b/src-tauri/native/CATapCapture.swift index 8337fe5..db8d113 100644 --- a/src-tauri/native/CATapCapture.swift +++ b/src-tauri/native/CATapCapture.swift @@ -92,6 +92,9 @@ private func inputBufferCount(_ deviceID: AudioDeviceID) -> Int { return Int(raw.assumingMemoryBound(to: AudioBufferList.self).pointee.mNumberBuffers) } +/// How often a tap re-reads which of its app's processes render output. +private let processPollInterval: DispatchTimeInterval = .milliseconds(200) + private func defaultOutputDevice() -> AudioDeviceID? { guard let id = readValue(systemObject, kAudioHardwarePropertyDefaultOutputDevice, AudioDeviceID(0)), id != AudioDeviceID(kAudioObjectUnknown) else { return nil } @@ -190,15 +193,24 @@ private final class Tap { /// aggregate is created without error but its IOProc never fires. private var active: Set = [] private var pollTimer: DispatchSourceTimer? + /// The output device whose start is observed, and its listener. + private var runningDevice = AudioDeviceID(0) + private var runningBlock: AudioObjectPropertyListenerBlock? + /// Moves the running listener along when the default output changes. + private var defaultOutputBlock: AudioObjectPropertyListenerBlock? 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 +218,7 @@ private final class Tap { defer { lock.unlock() } self.mode = mode + self.ioFrames = ioFrames self.callback = callback self.userData = userData @@ -329,6 +342,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 +365,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() { @@ -375,10 +402,14 @@ private final class Tap { /// when the app opens a new audio process (extra browser tab) and when one /// of its processes starts or stops rendering output. /// - /// The process list posts change notifications, but `IsRunningOutput` was - /// observed never to post one, so a long-lived process going from paused to - /// playing would be missed. Both are therefore re-evaluated on a timer; - /// these are cheap property reads off the IO queue. + /// The process list posts change notifications, but `IsRunningOutput` + /// never posts one (its listener registers and stays silent), so a + /// long-lived process going from paused to playing would be missed. The + /// output device says when it starts running, but not for whom, and says + /// nothing while it already runs for someone else. What is left is read + /// on a timer: cheap property reads off the IO queue, short enough that a + /// process joining the HAL (100-200 ms before it sounds) is picked up + /// about as it starts. private func observeProcesses() { guard case .application = mode else { return } var addr = address(kAudioHardwarePropertyProcessObjectList) @@ -388,8 +419,17 @@ private final class Tap { listenerBlock = block AudioObjectAddPropertyListenerBlock(systemObject, &addr, controlQueue, block) + observeRunning(on: defaultOutputDevice()) + var defaultAddr = address(kAudioHardwarePropertyDefaultOutputDevice) + let moved: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.defaultOutputChanged() + } + if AudioObjectAddPropertyListenerBlock(systemObject, &defaultAddr, controlQueue, moved) == noErr { + defaultOutputBlock = moved + } + let timer = DispatchSource.makeTimerSource(queue: controlQueue) - timer.schedule(deadline: .now() + 1.0, repeating: 1.0) + timer.schedule(deadline: .now() + processPollInterval, repeating: processPollInterval) timer.setEventHandler { [weak self] in self?.rebuildIfTargetChanged() } timer.resume() pollTimer = timer @@ -398,12 +438,55 @@ private final class Tap { private func removeProcessObservers() { pollTimer?.cancel() pollTimer = nil + if let moved = defaultOutputBlock { + var defaultAddr = address(kAudioHardwarePropertyDefaultOutputDevice) + AudioObjectRemovePropertyListenerBlock(systemObject, &defaultAddr, controlQueue, moved) + defaultOutputBlock = nil + } + unobserveRunning() guard let block = listenerBlock else { return } var addr = address(kAudioHardwarePropertyProcessObjectList) AudioObjectRemovePropertyListenerBlock(systemObject, &addr, controlQueue, block) listenerBlock = nil } + /// Watches `device` start running for someone, in place of the device + /// watched so far. + private func observeRunning(on device: AudioDeviceID?) { + unobserveRunning() + guard let device else { return } + var runningAddr = address(kAudioDevicePropertyDeviceIsRunningSomewhere) + let running: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.rebuildIfTargetChanged() + } + if AudioObjectAddPropertyListenerBlock(device, &runningAddr, controlQueue, running) == noErr { + runningDevice = device + runningBlock = running + } + } + + private func unobserveRunning() { + guard let running = runningBlock else { return } + var runningAddr = address(kAudioDevicePropertyDeviceIsRunningSomewhere) + AudioObjectRemovePropertyListenerBlock(runningDevice, &runningAddr, controlQueue, running) + runningBlock = nil + runningDevice = AudioDeviceID(0) + } + + /// The app's processes play to the default output, so the running + /// listener follows it there. + private func defaultOutputChanged() { + lock.lock() + let observing = callback != nil + if observing { + observeRunning(on: defaultOutputDevice()) + } + lock.unlock() + if observing { + rebuildIfTargetChanged() + } + } + private func rebuildIfTargetChanged() { lock.lock() defer { lock.unlock() } @@ -495,6 +578,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 +586,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 +598,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 +606,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/native/virtual_driver/Info.plist b/src-tauri/native/virtual_driver/Info.plist index fdea67d..4e5130a 100644 --- a/src-tauri/native/virtual_driver/Info.plist +++ b/src-tauri/native/virtual_driver/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 2.0 CFBundleVersion - 5 + 6 CFPlugInFactories 8C69103F-A4D0-44EA-97DC-A928A89637BF diff --git a/src-tauri/native/virtual_driver/SplitAudioDriver.cpp b/src-tauri/native/virtual_driver/SplitAudioDriver.cpp index 29356bd..0af76fa 100644 --- a/src-tauri/native/virtual_driver/SplitAudioDriver.cpp +++ b/src-tauri/native/virtual_driver/SplitAudioDriver.cpp @@ -20,15 +20,27 @@ static const char* kConfigDir = "/Library/Application Support/Splitwave"; static const char* kConfigPath = "/Library/Application Support/Splitwave/devices.plist"; +// The ring holds this much audio at any rate, rounded up to a power of two of +// frames so a position maps to a slot with a mask. +static constexpr double kRingSeconds = 0.25; + +static uint64_t RingFrames(uint32_t sampleRate) { + const uint64_t want = (uint64_t)std::ceil(sampleRate * kRingSeconds); + uint64_t frames = 1; + while (frames < want) frames <<= 1; + return frames; +} + struct DeviceRing { - static constexpr uint32_t kFrames = 16384; uint32_t channels; - uint32_t nSamples; + uint64_t frames; + uint64_t mask; std::vector buf; std::atomic lastOutFrame{0}; - explicit DeviceRing(uint32_t ch) - : channels(ch), nSamples(kFrames * ch), buf(kFrames * ch, 0.0f) {} + DeviceRing(uint32_t ch, uint32_t sampleRate) + : channels(ch), frames(RingFrames(sampleRate)), mask(frames - 1), + buf(frames * ch, 0.0f) {} }; class SplitIOHandler : public aspl::IORequestHandler, @@ -46,13 +58,14 @@ class SplitIOHandler : public aspl::IORequestHandler, const void* buff, UInt32 bytes) override { const float* src = static_cast(buff); - const uint32_t n = bytes / sizeof(float); + const uint32_t ch = ring_.channels; + const int64_t frames = bytes / sizeof(float) / ch; const int64_t frame = llround(timestamp); - const uint64_t base = (uint64_t)frame * ring_.channels; - for (uint32_t i = 0; i < n; ++i) { - ring_.buf[(base + i) % ring_.nSamples] = src[i]; + for (int64_t f = 0; f < frames; ++f) { + float* slot = &ring_.buf[((uint64_t)(frame + f) & ring_.mask) * ch]; + memcpy(slot, src + f * ch, ch * sizeof(float)); } - ring_.lastOutFrame.store(frame + n / ring_.channels, std::memory_order_release); + ring_.lastOutFrame.store(frame + frames, std::memory_order_release); } void OnReadClientInput( @@ -62,16 +75,19 @@ class SplitIOHandler : public aspl::IORequestHandler, void* buff, UInt32 bytes) override { float* dst = static_cast(buff); - const uint32_t n = bytes / sizeof(float); + const uint32_t ch = ring_.channels; const int64_t frame = llround(timestamp); - const int64_t frames = n / ring_.channels; - if (ring_.lastOutFrame.load(std::memory_order_acquire) - frames < frame) { + const int64_t frames = bytes / sizeof(float) / ch; + const int64_t last = ring_.lastOutFrame.load(std::memory_order_acquire); + // Not written yet, or already written over by newer audio: a reader + // this far behind gets silence rather than audio from another moment. + if (last - frames < frame || frame < last - (int64_t)ring_.frames) { memset(dst, 0, bytes); return; } - const uint64_t base = (uint64_t)frame * ring_.channels; - for (uint32_t i = 0; i < n; ++i) { - dst[i] = ring_.buf[(base + i) % ring_.nSamples]; + for (int64_t f = 0; f < frames; ++f) { + const float* slot = &ring_.buf[((uint64_t)(frame + f) & ring_.mask) * ch]; + memcpy(dst + f * ch, slot, ch * sizeof(float)); } } }; @@ -167,7 +183,7 @@ static AudioStreamBasicDescription FloatFormat(UInt32 channels, Float64 sampleRa } static DeviceEntry BuildDevice(const DeviceConfig& cfg) { - auto ring = std::make_shared(cfg.channels); + auto ring = std::make_shared(cfg.channels, cfg.sampleRate); auto handler = std::make_shared(ring); aspl::DeviceParameters params; diff --git a/src-tauri/src/audio/adaptive_depth.rs b/src-tauri/src/audio/adaptive_depth.rs new file mode 100644 index 0000000..818d068 --- /dev/null +++ b/src-tauri/src/audio/adaptive_depth.rs @@ -0,0 +1,604 @@ +//! 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. +//! +//! 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. + +use std::sync::atomic::{AtomicU32, Ordering}; + +/// What a source measured about its own delivery, kept for the source that +/// replaces it: one rebuilt by an edit, or reopened at another buffer, starts +/// at the depth already measured instead of guessing and correcting the +/// guess a second later. In microseconds, so a new rate or block reads it. +#[derive(Default)] +pub struct DepthMemo { + dip_us: AtomicU32, + quantum_us: AtomicU32, +} + +impl DepthMemo { + /// The measured dip and delivery size, in frames at `rate`. + pub fn recall(&self, rate: u32) -> Option<(usize, usize)> { + let dip = self.dip_us.load(Ordering::Relaxed); + if dip == 0 { + return None; + } + let quantum = self.quantum_us.load(Ordering::Relaxed); + let frames = |us: u32| (us as u64 * rate as u64 / 1_000_000) as usize; + Some((frames(dip), frames(quantum))) + } + + /// RT-safe: two relaxed stores. + pub fn keep(&self, dip: usize, quantum: usize, rate: u32) { + let us = |frames: usize| (frames as u64 * 1_000_000 / rate.max(1) as u64) as u32; + self.dip_us.store(us(dip).max(1), Ordering::Relaxed); + self.quantum_us.store(us(quantum), Ordering::Relaxed); + } +} + +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; + } + + /// Replaces the depth assumed until enough has been measured. + pub fn set_prior(&mut self, prior_frames: usize) { + self.prior = prior_frames; + } + + /// 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. + 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(); + 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; + } +} + +/// 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, + } + } + + /// 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 { + 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::*; + + 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/capture/linux.rs b/src-tauri/src/audio/capture/linux.rs index f309b54..fac5875 100644 --- a/src-tauri/src/audio/capture/linux.rs +++ b/src-tauri/src/audio/capture/linux.rs @@ -60,9 +60,17 @@ impl Capture { pub fn start_system( sample_rate: u32, channels: u32, + io_frames: u32, callback: impl FnMut(&[f32]) + Send + 'static, ) -> AppResult { - spawn(None, true, sample_rate, channels, Box::new(callback)) + spawn( + None, + true, + sample_rate, + channels, + io_frames, + Box::new(callback), + ) } // Tap a specific app's output stream, found by binary/name. @@ -70,6 +78,7 @@ impl Capture { binary: &str, sample_rate: u32, channels: u32, + io_frames: u32, callback: impl FnMut(&[f32]) + Send + 'static, ) -> AppResult { let serial = resolve_serial(binary)? @@ -79,6 +88,7 @@ impl Capture { false, sample_rate, channels, + io_frames, Box::new(callback), ) } @@ -88,6 +98,7 @@ impl Capture { node_name: &str, sample_rate: u32, channels: u32, + io_frames: u32, callback: impl FnMut(&[f32]) + Send + 'static, ) -> AppResult { spawn( @@ -95,6 +106,7 @@ impl Capture { false, sample_rate, channels, + io_frames, Box::new(callback), ) } @@ -104,6 +116,7 @@ impl Capture { sink_node_name: &str, sample_rate: u32, channels: u32, + io_frames: u32, callback: impl FnMut(&[f32]) + Send + 'static, ) -> AppResult { spawn( @@ -111,16 +124,20 @@ impl Capture { true, sample_rate, channels, + io_frames, Box::new(callback), ) } } +/// `io_frames` is the engine block at `sample_rate`: the capture asks the +/// graph for one per cycle, so a delivery is never burstier than a read. fn spawn( target: Option, capture_sink: bool, sample_rate: u32, channels: u32, + io_frames: u32, callback: Box, ) -> AppResult { let (sender, receiver) = pw::channel::channel::(); @@ -128,13 +145,14 @@ fn spawn( let thread_rate = negotiated_rate.clone(); let thread = std::thread::spawn(move || { // Drain the PipeWire stream on a real-time thread so delivery keeps up. - let _rt = RtThread::promote("capture", 0, sample_rate); + let _rt = RtThread::promote("capture", io_frames, sample_rate); if let Err(e) = run( receiver, target, capture_sink, sample_rate, channels, + io_frames, callback, thread_rate, ) { @@ -154,6 +172,7 @@ fn run( capture_sink: bool, sample_rate: u32, channels: u32, + io_frames: u32, callback: Box, negotiated_rate: Arc, ) -> Result<(), pw::Error> { @@ -177,6 +196,10 @@ fn run( if let Some(target) = target { props.insert(*pw::keys::TARGET_OBJECT, target); } + props.insert( + *pw::keys::NODE_LATENCY, + format!("{}/{sample_rate}", io_frames.max(1)), + ); let stream = pw::stream::StreamRc::new(core.clone(), "splitwave-capture", props)?; let user_data = UserData { diff --git a/src-tauri/src/audio/capture/macos_backend.rs b/src-tauri/src/audio/capture/macos_backend.rs index 6fd6877..b65accf 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 35bb541..c9a0fbe 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/capture/windows.rs b/src-tauri/src/audio/capture/windows.rs index 8ae1f91..8572280 100644 --- a/src-tauri/src/audio/capture/windows.rs +++ b/src-tauri/src/audio/capture/windows.rs @@ -28,6 +28,10 @@ const TARGET_RATE: u32 = 48_000; const TARGET_CHANNELS: u16 = 2; // 200 ms shared-mode buffer; we drain it by polling. const BUFFER_HNS: i64 = 2_000_000; +/// Loopback hands over one shared-mode engine period at a time (10 ms by +/// default). Falling further behind than that is an idle endpoint, not the +/// wait for the next packet. +const IDLE_BEHIND_MS: u64 = 10; pub struct Capture { stop: Arc, @@ -44,26 +48,30 @@ impl Drop for Capture { } impl Capture { + /// `io_frames` is the engine block at the capture's rate: how often the + /// capture is drained. pub fn start_system( _exclude_current_app: bool, _sample_rate: u32, - _channels: u32, + io_frames: u32, bridge: BroadcastRx, ) -> AppResult { - Ok(spawn(bridge, run_loopback)) + Ok(spawn(bridge, move |stop, bridge| { + run_loopback(io_frames, stop, bridge) + })) } pub fn start_app( bundle_id: &str, sample_rate: u32, - _channels: u32, + io_frames: u32, bridge: BroadcastRx, ) -> AppResult { let pid = crate::audio::system_audio::pid_for_exe(bundle_id).ok_or_else(|| { AppError::Stream(format!("no active audio session found for {bundle_id:?}")) })?; Ok(spawn(bridge, move |stop, bridge| { - run_process_loopback(pid, sample_rate, stop, bridge) + run_process_loopback(pid, sample_rate, io_frames, stop, bridge) })) } } @@ -113,7 +121,7 @@ fn com_err(e: windows::core::Error) -> AppError { AppError::Host(format!("wasapi: {e}")) } -fn run_loopback(stop: Arc, bridge: BroadcastRx) -> AppResult<()> { +fn run_loopback(io_frames: u32, stop: Arc, bridge: BroadcastRx) -> AppResult<()> { unsafe { ensure_com(); let enumerator: IMMDeviceEnumerator = @@ -147,7 +155,7 @@ fn run_loopback(stop: Arc, bridge: BroadcastRx) -> AppResult<()> { let capture: IAudioCaptureClient = client.GetService().map_err(com_err)?; client.Start().map_err(com_err)?; - let r = pump(&capture, channels, rate, &stop, bridge); + let r = pump(&capture, channels, rate, io_frames, &stop, bridge); let _ = client.Stop(); r } @@ -159,6 +167,7 @@ fn run_loopback(stop: Arc, bridge: BroadcastRx) -> AppResult<()> { fn run_process_loopback( pid: u32, sample_rate: u32, + io_frames: u32, stop: Arc, bridge: BroadcastRx, ) -> AppResult<()> { @@ -233,25 +242,35 @@ fn run_process_loopback( let capture: IAudioCaptureClient = client.GetService().map_err(com_err)?; client.Start().map_err(com_err)?; - let r = pump(&capture, TARGET_CHANNELS as usize, rate, &stop, bridge); + let r = pump( + &capture, + TARGET_CHANNELS as usize, + rate, + io_frames, + &stop, + bridge, + ); let _ = client.Stop(); r } } -// Drain capture packets, downmix to stereo f32, and broadcast. Loopback delivers +// Drain capture packets, downmix to stereo f32, and broadcast. Polled once per +// engine block, so a packet waits at most one block for us. Loopback delivers // nothing while the endpoint is idle, so pace to the wall clock and emit -// real-time silence on idle ticks (only when genuinely behind, not on jitter) -// so the source never looks stalled. +// real-time silence when genuinely behind (not on jitter) so the source never +// looks stalled. unsafe fn pump( capture: &IAudioCaptureClient, channels: usize, rate: u32, + io_frames: u32, stop: &AtomicBool, mut bridge: BroadcastRx, ) -> AppResult<()> { - let frames_per_tick = (rate / 100).max(1) as u64; - let tick = Duration::from_millis(10); + let idle_behind = (rate as u64 * IDLE_BEHIND_MS / 1_000).max(1); + let tick = Duration::from_secs_f64(io_frames.max(1) as f64 / rate.max(1) as f64) + .max(Duration::from_millis(1)); let start = Instant::now(); let mut delivered: u64 = 0; let mut next = start; @@ -291,7 +310,7 @@ unsafe fn pump( bridge.broadcast(&stereo); } let expected = (start.elapsed().as_secs_f64() * rate as f64) as u64; - if expected > delivered + frames_per_tick { + if expected > delivered + idle_behind { let deficit = (expected - delivered) as usize; stereo.clear(); stereo.resize(deficit * 2, 0.0); diff --git a/src-tauri/src/audio/clock.rs b/src-tauri/src/audio/clock.rs index 0ad4619..1ed51b6 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/device/macos.rs b/src-tauri/src/audio/device/macos.rs index 52aa702..5e1440d 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 06b6fe0..38dfaff 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 3e6645d..84b7ff5 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/drift_loop.rs b/src-tauri/src/audio/drift_loop.rs new file mode 100644 index 0000000..6581703 --- /dev/null +++ b/src-tauri/src/audio/drift_loop.rs @@ -0,0 +1,353 @@ +//! 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: 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 +/// 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, 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).min(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 + } + + #[cfg(test)] + 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. + // 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 < 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 + ); + } + + #[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/effects/biquad.rs b/src-tauri/src/audio/effects/biquad.rs index 5206086..9916a2a 100644 --- a/src-tauri/src/audio/effects/biquad.rs +++ b/src-tauri/src/audio/effects/biquad.rs @@ -38,10 +38,15 @@ pub enum BandShape { Hpf, } +/// Highest corner a filter is designed at, as a share of the sample rate. At +/// Nyquist the RBJ design degenerates; a corner set for a higher rate (a +/// 16 kHz de-esser in a 32 kHz pipeline) stays just under it instead. +const MAX_CORNER: f32 = 0.45; + /// RBJ cookbook coefficients. pub fn biquad_for(shape: BandShape, freq_hz: f32, q: f32, sample_rate: u32) -> Biquad { let fs = sample_rate as f32; - let w0 = 2.0 * std::f32::consts::PI * (freq_hz.max(1.0) / fs); + let w0 = 2.0 * std::f32::consts::PI * (freq_hz.clamp(1.0, MAX_CORNER * fs) / fs); let (sinw, cosw) = (w0.sin(), w0.cos()); let q = q.max(0.05); let alpha = sinw / (2.0 * q); @@ -95,6 +100,19 @@ mod tests { peak } + #[test] + fn a_corner_past_nyquist_stays_stable() { + for shape in [BandShape::Lpf, BandShape::Hpf] { + let mut f = biquad_for(shape, 16_000.0, 0.707, 16_000); + let mut peak = 0.0f32; + for n in 0..4_800 { + let x = if n % 2 == 0 { 0.5 } else { -0.5 }; + peak = peak.max(f.process(x).abs()); + } + assert!(peak.is_finite() && peak < 2.0, "{peak}"); + } + } + #[test] fn lowpass_passes_lows_and_cuts_highs() { let f = biquad_for(BandShape::Lpf, 1_000.0, 0.707, SR); diff --git a/src-tauri/src/audio/effects/channel_balance.rs b/src-tauri/src/audio/effects/channel_balance.rs index a9a17ef..6bb19f3 100644 --- a/src-tauri/src/audio/effects/channel_balance.rs +++ b/src-tauri/src/audio/effects/channel_balance.rs @@ -3,39 +3,46 @@ use std::sync::Arc; use crate::audio::graph::ChannelBalanceData; -use super::util::{db_to_linear, load_f32}; +use super::util::{db_to_linear, load_f32, Ramp}; use super::{Effect, EffectControl}; pub struct ChannelBalanceEffect { left: Arc, right: Arc, + ramp_left: Ramp, + ramp_right: Ramp, } impl ChannelBalanceEffect { - pub fn new(d: ChannelBalanceData) -> (Self, EffectControl) { + pub fn new(d: ChannelBalanceData, sample_rate: u32) -> (Self, EffectControl) { let left = Arc::new(AtomicU32::new(db_to_linear(d.left_gain_db).to_bits())); let right = Arc::new(AtomicU32::new(db_to_linear(d.right_gain_db).to_bits())); let control = EffectControl::ChannelBalance { left: left.clone(), right: right.clone(), }; - (Self { left, right }, control) + (Self::from_state(left, right, sample_rate), control) } - pub fn from_state(left: Arc, right: Arc) -> Self { - Self { left, right } + pub fn from_state(left: Arc, right: Arc, sample_rate: u32) -> Self { + Self { + ramp_left: Ramp::new(load_f32(&left), sample_rate), + ramp_right: Ramp::new(load_f32(&right), sample_rate), + left, + right, + } } } impl Effect for ChannelBalanceEffect { #[inline] fn process(&mut self, samples: &mut [f32], frames: usize) { - let gl = load_f32(&self.left); - let gr = load_f32(&self.right); + self.ramp_left.set(load_f32(&self.left)); + self.ramp_right.set(load_f32(&self.right)); let stereo = &mut samples[..frames * 2]; for frame in stereo.chunks_exact_mut(2) { - frame[0] *= gl; - frame[1] *= gr; + frame[0] *= self.ramp_left.next(); + frame[1] *= self.ramp_right.next(); } } } @@ -46,11 +53,14 @@ mod tests { #[test] fn balance_applies_per_channel() { - let (mut e, _) = ChannelBalanceEffect::new(ChannelBalanceData { - left_gain_db: -6.0, - right_gain_db: 0.0, - bypassed: false, - }); + let (mut e, _) = ChannelBalanceEffect::new( + ChannelBalanceData { + left_gain_db: -6.0, + right_gain_db: 0.0, + bypassed: false, + }, + 48_000, + ); let mut buf = [1.0, 1.0]; e.process(&mut buf, 1); assert!((buf[0] - 0.501).abs() < 0.01); diff --git a/src-tauri/src/audio/effects/compressor.rs b/src-tauri/src/audio/effects/compressor.rs index 9028a89..4b60666 100644 --- a/src-tauri/src/audio/effects/compressor.rs +++ b/src-tauri/src/audio/effects/compressor.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::audio::graph::CompressorData; -use super::util::{load_f32, store_f32}; +use super::util::{load_f32, store_f32, Ramp}; use super::{Effect, EffectControl}; pub struct CompressorEffect { @@ -15,10 +15,28 @@ pub struct CompressorEffect { makeup_db: Arc, sample_rate: u32, envelope: f32, + /// Threshold, ratio, knee and makeup glide to a new setting, so moving + /// one never steps the gain. + ramps: [Ramp; 4], /// Min gain (0-1 linear, no makeup) across the last block. 1.0 = no GR. pub gr_lin: Arc, } +fn curve_ramps( + threshold_db: &AtomicU32, + ratio: &AtomicU32, + knee_db: &AtomicU32, + makeup_db: &AtomicU32, + sample_rate: u32, +) -> [Ramp; 4] { + [ + Ramp::new(load_f32(threshold_db), sample_rate), + Ramp::new(load_f32(ratio).max(1.0), sample_rate), + Ramp::new(load_f32(knee_db).max(0.0), sample_rate), + Ramp::new(load_f32(makeup_db), sample_rate), + ] +} + impl CompressorEffect { pub fn new(d: CompressorData, sample_rate: u32) -> (Self, EffectControl, Arc) { let threshold_db = Arc::new(AtomicU32::new(d.threshold_db.to_bits())); @@ -38,6 +56,7 @@ impl CompressorEffect { }; ( Self { + ramps: curve_ramps(&threshold_db, &ratio, &knee_db, &makeup_db, sample_rate), threshold_db, ratio, attack_ms, @@ -65,6 +84,7 @@ impl CompressorEffect { gr_lin: Arc, ) -> Self { Self { + ramps: curve_ramps(&threshold_db, &ratio, &knee_db, &makeup_db, sample_rate), threshold_db, ratio, attack_ms, @@ -87,24 +107,26 @@ impl CompressorEffect { } fn process_inner(&mut self, main: &mut [f32], sidechain: Option<&[f32]>, frames: usize) { - let threshold_db = load_f32(&self.threshold_db); - let ratio = load_f32(&self.ratio).max(1.0); + self.ramps[0].set(load_f32(&self.threshold_db)); + self.ramps[1].set(load_f32(&self.ratio).max(1.0)); + self.ramps[2].set(load_f32(&self.knee_db).max(0.0)); + self.ramps[3].set(load_f32(&self.makeup_db)); let attack_ms = load_f32(&self.attack_ms).max(0.01); let release_ms = load_f32(&self.release_ms).max(0.1); - let knee_db = load_f32(&self.knee_db).max(0.0); - let makeup_db = load_f32(&self.makeup_db); let sr = self.sample_rate as f32; let attack_coeff = 1.0 - (-1.0 / (attack_ms * 0.001 * sr)).exp(); let release_coeff = 1.0 - (-1.0 / (release_ms * 0.001 * sr)).exp(); - let inv_ratio = 1.0 / ratio; - let makeup_lin = 10f32.powf(makeup_db / 20.0); - let half_knee = knee_db * 0.5; let main_buf = &mut main[..frames * 2]; let side = sidechain.filter(|s| s.len() >= frames * 2); let mut block_min_gr = 1.0f32; for (f, frame) in main_buf.chunks_exact_mut(2).enumerate() { + let threshold_db = self.ramps[0].next(); + let inv_ratio = 1.0 / self.ramps[1].next(); + let knee_db = self.ramps[2].next(); + let makeup_lin = 10f32.powf(self.ramps[3].next() / 20.0); + let half_knee = knee_db * 0.5; let detected = match side { Some(s) => s[f * 2].abs().max(s[f * 2 + 1].abs()), None => frame[0].abs().max(frame[1].abs()), diff --git a/src-tauri/src/audio/effects/declick.rs b/src-tauri/src/audio/effects/declick.rs index bfa812a..1a2dd7b 100644 --- a/src-tauri/src/audio/effects/declick.rs +++ b/src-tauri/src/audio/effects/declick.rs @@ -6,11 +6,14 @@ use crate::audio::graph::DeclickData; use super::util::load_f32; use super::{Effect, EffectControl}; -/// Longest click the node repairs; also fixes the added latency. 5 ms covers -/// mouse pops and mic taps; wider bursts are dropouts, not clicks. +/// Longest click the node can be set to repair. 5 ms covers mouse pops and +/// mic taps; wider bursts are dropouts, not clicks. The node's own setting +/// sizes its delay, so a narrower one costs less latency. const MAX_MS: f32 = 5.0; -/// Consecutive quiet samples that confirm a click has ended. -const CLOSE_HOLD: usize = 3; +/// How long the detector must stay quiet to confirm a click has ended: +/// three samples at 48 kHz, the same time at any rate. +const CLOSE_HOLD_MS: f32 = 0.0625; +const CLOSE_HOLD_MIN: usize = 3; /// Detector high-pass corner — clicks are broadband, their edge lives up here. const HP_CUTOFF_HZ: f32 = 1500.0; /// Time constant of the high-frequency floor the threshold scales against. @@ -27,6 +30,8 @@ pub struct DeclickEffect { cap: usize, delay: usize, max_w_cap: usize, + /// `CLOSE_HOLD_MS` in samples. + close_hold: usize, a_lp: f32, a_env: f32, a_fast: f32, @@ -65,21 +70,32 @@ impl DeclickEffect { sensitivity: sensitivity.clone(), max_width_ms: max_width_ms.clone(), }; - (Self::build(sensitivity, max_width_ms, sample_rate), control) + ( + Self::build(sensitivity, max_width_ms, sample_rate, d.max_width_ms), + control, + ) } + /// `width_ms` is the node's configured width, which sizes the delay. pub fn from_state( sensitivity: Arc, max_width_ms: Arc, sample_rate: u32, + width_ms: f32, ) -> Self { - Self::build(sensitivity, max_width_ms, sample_rate) + Self::build(sensitivity, max_width_ms, sample_rate, width_ms) } - fn build(sensitivity: Arc, max_width_ms: Arc, sample_rate: u32) -> Self { + fn build( + sensitivity: Arc, + max_width_ms: Arc, + sample_rate: u32, + width_ms: f32, + ) -> Self { let sr = sample_rate as f32; - let max_w_cap = ((sr * MAX_MS * 0.001) as usize).max(2); - let delay = max_w_cap + CLOSE_HOLD + 4; + let max_w_cap = ((sr * width_ms.clamp(0.3, MAX_MS) * 0.001) as usize).max(2); + let close_hold = ((sr * CLOSE_HOLD_MS * 0.001).round() as usize).max(CLOSE_HOLD_MIN); + let delay = max_w_cap + close_hold + 4; let cap = delay + 8; let a_lp = 1.0 - (-2.0 * std::f32::consts::PI * HP_CUTOFF_HZ / sr).exp(); let a_env = 1.0 - (-1.0 / (FLOOR_MS * 0.001 * sr)).exp(); @@ -94,6 +110,7 @@ impl DeclickEffect { cap, delay, max_w_cap, + close_hold, a_lp, a_env, a_fast, @@ -147,7 +164,7 @@ impl Effect for DeclickEffect { st.below = 0; } let width = (self.n - st.click_start + 1) as usize; - if st.below >= CLOSE_HOLD { + if st.below >= self.close_hold { let e = self.n - st.below as u64; // last loud sample repair(&mut st.buf, self.cap, st.click_start, e); st.in_click = false; @@ -204,6 +221,24 @@ fn repair(buf: &mut [f32], cap: usize, s: u64, e: u64) { mod tests { use super::*; + #[test] + fn the_delay_follows_the_configured_width() { + let latency = |width: f32| { + DeclickEffect::new( + DeclickData { + sensitivity: 0.5, + max_width_ms: width, + bypassed: false, + }, + 48_000, + ) + .0 + .latency_frames() + }; + assert!(latency(2.0) < latency(5.0)); + assert!(latency(2.0) <= 96 + CLOSE_HOLD_MIN + 4, "{}", latency(2.0)); + } + fn declick(sensitivity: f32) -> DeclickEffect { DeclickEffect::new( DeclickData { diff --git a/src-tauri/src/audio/effects/delay.rs b/src-tauri/src/audio/effects/delay.rs index 3736d15..c5b4207 100644 --- a/src-tauri/src/audio/effects/delay.rs +++ b/src-tauri/src/audio/effects/delay.rs @@ -3,13 +3,22 @@ use std::sync::Arc; use crate::audio::graph::DelayData; -use super::util::load_f32; +use super::util::{load_f32, Ramp}; use super::{Effect, EffectControl}; -const MAX_DELAY_MS: f32 = 2000.0; +pub(crate) const MAX_DELAY_MS: f32 = 2000.0; + +/// How quickly the delay follows a new time: the gap closes with this time +/// constant... +const GLIDE_MS: f64 = 50.0; +/// ...but never faster than this many frames of delay per frame played, so a +/// change bends the pitch by at most this share, as a tape delay's motor +/// would, rather than jumping (a click) or outrunning the audio (backwards). +const MAX_GLIDE_RATE: f64 = 0.25; /// Stereo delay line with feedback and dry/wet mix. Ring sized to 2 s @ build -/// SR — live `time_ms` changes just shift the read offset, no realloc. +/// SR; a live `time_ms` change moves the read position sample by sample, read +/// between samples, so it glides instead of stepping. pub struct DelayEffect { time_ms: Arc, feedback: Arc, @@ -17,10 +26,14 @@ pub struct DelayEffect { sample_rate: u32, /// Stereo-interleaved ring; capacity = MAX_DELAY_MS @ sample_rate. buf: Box<[f32]>, + /// Next frame written. write: usize, - /// Per-block lerp target for `time_ms` — prevents discontinuity clicks - /// when the slider moves. - current_delay_frames: f32, + /// Delay heard now, in frames, gliding toward the set time. + current_delay_frames: f64, + /// Share of the gap to the set time closed per frame. + glide: f64, + ramp_feedback: Ramp, + ramp_mix: Ramp, } impl DelayEffect { @@ -46,16 +59,29 @@ impl DelayEffect { sample_rate: u32, ) -> Self { let cap = (MAX_DELAY_MS * 0.001 * sample_rate as f32) as usize * 2; - let initial_delay_frames = load_f32(&time_ms).max(1.0) * 0.001 * sample_rate as f32; - Self { + let ramp_feedback = Ramp::new(load_f32(&feedback).clamp(0.0, 0.95), sample_rate); + let ramp_mix = Ramp::new(load_f32(&mix).clamp(0.0, 1.0), sample_rate); + let mut effect = Self { time_ms, feedback, mix, sample_rate, buf: vec![0.0; cap].into_boxed_slice(), write: 0, - current_delay_frames: initial_delay_frames, - } + current_delay_frames: 0.0, + glide: 1.0 - (-1000.0 / (GLIDE_MS * sample_rate as f64)).exp(), + ramp_feedback, + ramp_mix, + }; + effect.current_delay_frames = effect.target_delay_frames(); + effect + } + + /// The set time in frames, within what the ring can read between. + fn target_delay_frames(&self) -> f64 { + let cap_frames = (self.buf.len() / 2) as f64; + (load_f32(&self.time_ms).max(1.0) as f64 * 0.001 * self.sample_rate as f64) + .clamp(1.0, (cap_frames - 2.0).max(1.0)) } } @@ -64,28 +90,40 @@ impl Effect for DelayEffect { if frames == 0 { return; } - let target_delay = (load_f32(&self.time_ms).max(1.0) * 0.001 * self.sample_rate as f32) - .min(MAX_DELAY_MS * 0.001 * self.sample_rate as f32); - // Single-pole smoothing over one block: critically damped, ≈10 ms - // settle so slider sweeps don't click. - let smooth = 1.0 - (-1.0 / (0.01 * self.sample_rate as f32 / frames as f32)).exp(); - self.current_delay_frames += (target_delay - self.current_delay_frames) * smooth; - let delay_samples = (self.current_delay_frames as usize).max(1) * 2; - let feedback = load_f32(&self.feedback).clamp(0.0, 0.95); - let mix = load_f32(&self.mix).clamp(0.0, 1.0); - let dry = 1.0 - mix; + let target = self.target_delay_frames(); + self.ramp_feedback + .set(load_f32(&self.feedback).clamp(0.0, 0.95)); + self.ramp_mix.set(load_f32(&self.mix).clamp(0.0, 1.0)); - let cap = self.buf.len(); + let cap = self.buf.len() / 2; let stereo = &mut samples[..frames * 2]; for frame in stereo.chunks_exact_mut(2) { - let read = (self.write + cap - delay_samples) % cap; - let dl = self.buf[read]; - let dr = self.buf[read + 1]; + let feedback = self.ramp_feedback.next(); + let mix = self.ramp_mix.next(); + let dry = 1.0 - mix; + let gap = target - self.current_delay_frames; + if gap != 0.0 { + let step = (gap * self.glide).clamp(-MAX_GLIDE_RATE, MAX_GLIDE_RATE); + self.current_delay_frames = if gap.abs() <= step.abs() || gap.abs() < 1e-6 { + target + } else { + self.current_delay_frames + step + }; + } + let mut pos = self.write as f64 - self.current_delay_frames; + if pos < 0.0 { + pos += cap as f64; + } + let i0 = pos as usize % cap; + let i1 = (i0 + 1) % cap; + let frac = (pos - pos.floor()) as f32; + let dl = self.buf[i0 * 2] * (1.0 - frac) + self.buf[i1 * 2] * frac; + let dr = self.buf[i0 * 2 + 1] * (1.0 - frac) + self.buf[i1 * 2 + 1] * frac; let il = frame[0]; let ir = frame[1]; - self.buf[self.write] = il + dl * feedback; - self.buf[self.write + 1] = ir + dr * feedback; - self.write = (self.write + 2) % cap; + self.buf[self.write * 2] = il + dl * feedback; + self.buf[self.write * 2 + 1] = ir + dr * feedback; + self.write = (self.write + 1) % cap; frame[0] = il * dry + dl * mix; frame[1] = ir * dry + dr * mix; } @@ -176,6 +214,46 @@ mod tests { assert_eq!(buf[21], -0.2); } + #[test] + fn a_time_change_glides_without_a_step_at_any_block() { + let rate = 48_000; + for block in [32, 2048] { + let (mut e, c) = DelayEffect::new( + DelayData { + time_ms: 100.0, + feedback: 0.0, + mix: 1.0, + bypassed: false, + }, + rate, + ); + let EffectControl::Delay { time_ms, .. } = &c else { + panic!("wrong control variant"); + }; + let tone = + |n: usize| 0.5 * (std::f32::consts::TAU * 440.0 * n as f32 / rate as f32).sin(); + let mut out = Vec::new(); + let mut n = 0; + for b in 0..(rate as usize * 2) / block { + if b * block >= rate as usize / 2 { + time_ms.store(400.0f32.to_bits(), Ordering::Relaxed); + } + let mut buf: Vec = (0..block).flat_map(|f| [tone(n + f); 2]).collect(); + n += block; + e.process(&mut buf, block); + out.extend(buf.iter().step_by(2).copied()); + } + // Past the first echo: the tone's own slope, bent by at most the + // glide's pitch change, and nothing like a jump. + let worst = out[rate as usize / 5..] + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0f32, f32::max); + let slope = 0.5 * std::f32::consts::TAU * 440.0 / rate as f32; + assert!(worst < slope * 1.3, "{block}: step of {worst}"); + } + } + #[test] fn time_change_moves_without_panic_or_click() { let d = DelayData { diff --git a/src-tauri/src/audio/effects/eq.rs b/src-tauri/src/audio/effects/eq.rs index 3750253..e66c043 100644 --- a/src-tauri/src/audio/effects/eq.rs +++ b/src-tauri/src/audio/effects/eq.rs @@ -4,11 +4,11 @@ use std::sync::Arc; use crate::audio::graph::EqData; use super::biquad::{biquad_for, BandShape, Biquad}; -use super::util::{db_to_linear, load_f32}; +use super::util::{db_to_linear, load_f32, Ramp}; use super::{Effect, EffectControl}; /// Fourth-order crossover points at geometric means between adjacent bands. -const EQ_CROSSOVER_FREQS: [f32; 9] = [ +pub(crate) const EQ_CROSSOVER_FREQS: [f32; 9] = [ 45.2548, 89.4427, 176.7767, 353.5534, 707.1068, 1414.2136, 2828.4271, 5656.8542, 11313.7085, ]; @@ -64,6 +64,8 @@ impl ChannelChain { pub struct EqEffect { channels: [ChannelChain; 2], gains: [Arc; 10], + /// Each band's linear gain, gliding to a new setting. + ramps: [Ramp; 10], } impl EqEffect { @@ -73,16 +75,7 @@ impl EqEffect { let control = EffectControl::Eq { gains: gains.clone(), }; - ( - Self { - channels: [ - ChannelChain::new(sample_rate), - ChannelChain::new(sample_rate), - ], - gains, - }, - control, - ) + (Self::from_state(gains, sample_rate), control) } pub fn from_state(gains: [Arc; 10], sample_rate: u32) -> Self { @@ -91,6 +84,9 @@ impl EqEffect { ChannelChain::new(sample_rate), ChannelChain::new(sample_rate), ], + ramps: std::array::from_fn(|i| { + Ramp::new(db_to_linear(load_f32(&gains[i])), sample_rate) + }), gains, } } @@ -98,10 +94,18 @@ impl EqEffect { impl Effect for EqEffect { fn process(&mut self, samples: &mut [f32], frames: usize) { - let gains_linear: [f32; 10] = - std::array::from_fn(|i| db_to_linear(load_f32(&self.gains[i]))); + for (ramp, gain) in self.ramps.iter_mut().zip(&self.gains) { + ramp.set(db_to_linear(load_f32(gain))); + } + let mut gains_linear: [f32; 10] = std::array::from_fn(|i| self.ramps[i].value()); + let settled = self.ramps.iter().all(|r| r.is_settled()); let stereo = &mut samples[..frames * 2]; for frame in stereo.chunks_exact_mut(2) { + if !settled { + for (g, ramp) in gains_linear.iter_mut().zip(self.ramps.iter_mut()) { + *g = ramp.next(); + } + } frame[0] = self.channels[0].process(frame[0], &gains_linear); frame[1] = self.channels[1].process(frame[1], &gains_linear); } diff --git a/src-tauri/src/audio/effects/gain.rs b/src-tauri/src/audio/effects/gain.rs index 3ff4489..bff9aca 100644 --- a/src-tauri/src/audio/effects/gain.rs +++ b/src-tauri/src/audio/effects/gain.rs @@ -3,16 +3,16 @@ use std::sync::Arc; use crate::audio::graph::GainData; -use super::util::{db_to_linear, load_f32}; +use super::util::{db_to_linear, load_f32, Ramp}; use super::{Effect, EffectControl}; pub struct GainEffect { linear: Arc, - current: f32, + ramp: Ramp, } impl GainEffect { - pub fn new(d: GainData) -> (Self, EffectControl) { + pub fn new(d: GainData, sample_rate: u32) -> (Self, EffectControl) { let initial = db_to_linear(d.gain_db); let linear = Arc::new(AtomicU32::new(initial.to_bits())); let control = EffectControl::Gain { @@ -21,15 +21,15 @@ impl GainEffect { ( Self { linear, - current: initial, + ramp: Ramp::new(initial, sample_rate), }, control, ) } - pub fn from_state(linear: Arc) -> Self { + pub fn from_state(linear: Arc, sample_rate: u32) -> Self { Self { - current: load_f32(&linear), + ramp: Ramp::new(load_f32(&linear), sample_rate), linear, } } @@ -38,23 +38,20 @@ impl GainEffect { impl Effect for GainEffect { #[inline] fn process(&mut self, samples: &mut [f32], frames: usize) { - let target = load_f32(&self.linear); + self.ramp.set(load_f32(&self.linear)); let stereo = &mut samples[..frames * 2]; - if (self.current - target).abs() < 1e-7 { + if self.ramp.is_settled() { + let g = self.ramp.value(); for s in stereo { - *s *= target; + *s *= g; } - self.current = target; return; } - let step = (target - self.current) / frames as f32; - let mut g = self.current; for frame in stereo.chunks_exact_mut(2) { - g += step; + let g = self.ramp.next(); frame[0] *= g; frame[1] *= g; } - self.current = target; } } @@ -64,10 +61,13 @@ mod tests { #[test] fn gain_applies_db() { - let (mut e, _) = GainEffect::new(GainData { - gain_db: 6.0, - bypassed: false, - }); + let (mut e, _) = GainEffect::new( + GainData { + gain_db: 6.0, + bypassed: false, + }, + 48_000, + ); let mut buf = [1.0_f32, 1.0]; e.process(&mut buf, 1); assert!((buf[0] - 1.995).abs() < 0.01); @@ -75,13 +75,39 @@ mod tests { #[test] fn gain_control_changes_live() { - let (mut e, c) = GainEffect::new(GainData { - gain_db: 0.0, - bypassed: false, - }); + let (mut e, c) = GainEffect::new( + GainData { + gain_db: 0.0, + bypassed: false, + }, + 48_000, + ); c.apply_update(&serde_json::json!({ "gainDb": 6.0 })); - let mut buf = [1.0_f32, 1.0]; - e.process(&mut buf, 1); - assert!((buf[0] - 1.995).abs() < 0.01); + let mut buf = vec![1.0_f32; 2 * 240]; + e.process(&mut buf, 240); + assert!((buf[478] - 1.995).abs() < 0.01); + } + + #[test] + fn a_change_glides_over_the_same_time_at_any_block() { + for block in [32, 2048] { + let (mut e, c) = GainEffect::new( + GainData { + gain_db: -60.0, + bypassed: false, + }, + 48_000, + ); + c.apply_update(&serde_json::json!({ "gainDb": 0.0 })); + let mut out = Vec::new(); + for _ in 0..4096 / block { + let mut buf = vec![1.0_f32; 2 * block]; + e.process(&mut buf, block); + out.extend(buf.iter().step_by(2).copied()); + } + // Halfway through 5 ms (240 frames at 48 kHz). + assert!((out[119] - 0.5).abs() < 0.01, "{block}: {}", out[119]); + assert_eq!(out[239], 1.0, "{block}"); + } } } diff --git a/src-tauri/src/audio/effects/level_meter.rs b/src-tauri/src/audio/effects/level_meter.rs index 0e8ef79..8e59199 100644 --- a/src-tauri/src/audio/effects/level_meter.rs +++ b/src-tauri/src/audio/effects/level_meter.rs @@ -1,4 +1,4 @@ -use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use crate::audio::graph::LevelMeterData; @@ -20,6 +20,21 @@ pub struct MeterHandle { channels: Arc, peaks: Arc>, rms: Arc>, + /// Per channel, the squared sum and the frames metered since the last + /// tick, packed so the tick takes both at once: RMS covers the time + /// between ticks, whatever the block size. + window: Arc>, +} + +/// Frames a window may hold before it starts over, should no tick come. +const MAX_WINDOW_FRAMES: u32 = 1 << 30; + +fn pack(sum_sq: f32, frames: u32) -> u64 { + (sum_sq.to_bits() as u64) << 32 | frames as u64 +} + +fn unpack(window: u64) -> (f32, u32) { + (f32::from_bits((window >> 32) as u32), window as u32) } #[derive(Debug, Clone)] @@ -38,6 +53,7 @@ impl MeterHandle { channels: Arc::new(AtomicUsize::new(0)), peaks: Arc::new((0..MAX_METER_CHANNELS).map(|_| AtomicU32::new(0)).collect()), rms: Arc::new((0..MAX_METER_CHANNELS).map(|_| AtomicU32::new(0)).collect()), + window: Arc::new((0..MAX_METER_CHANNELS).map(|_| AtomicU64::new(0)).collect()), } } @@ -51,9 +67,16 @@ impl MeterHandle { let mut peaks = Vec::with_capacity(n); let mut rms = Vec::with_capacity(n); for c in 0..n { - let p = load_f32(&self.peaks[c]); - store_f32(&self.peaks[c], p * METER_PEAK_DECAY); + let decayed = self.peaks[c].fetch_update(Ordering::Relaxed, Ordering::Relaxed, |b| { + Some((f32::from_bits(b) * METER_PEAK_DECAY).to_bits()) + }); + let p = f32::from_bits(decayed.unwrap_or_else(|b| b)); peaks.push(p); + let (sum, frames) = unpack(self.window[c].swap(0, Ordering::Relaxed)); + // Nothing metered since the last tick keeps the last reading. + if frames > 0 { + store_f32(&self.rms[c], (sum / frames as f32).sqrt()); + } rms.push(load_f32(&self.rms[c])); } MeterSnapshot { peaks, rms } @@ -88,8 +111,8 @@ impl Effect for LevelMeterEffect { } } -/// Meter `channels`-wide interleaved f32. Peaks accumulate (max) since the last -/// tick; RMS is per-block. RT-safe: fixed stack scratch, no allocation. +/// Meter `channels`-wide interleaved f32. Peaks (max) and squared sums +/// accumulate since the last tick. RT-safe: fixed stack scratch, no allocation. pub fn update_meter(handle: &MeterHandle, interleaved: &[f32], channels: usize) { let channels = channels.min(MAX_METER_CHANNELS); if channels == 0 { @@ -113,10 +136,21 @@ pub fn update_meter(handle: &MeterHandle, interleaved: &[f32], channels: usize) } } handle.channels.store(channels, Ordering::Relaxed); + // The tick decays and resets these at the same time, so each update is + // one atomic read-modify-write: a load and a later store would undo it. for c in 0..channels { - let existing = load_f32(&handle.peaks[c]); - store_f32(&handle.peaks[c], existing.max(peak[c])); - store_f32(&handle.rms[c], (sum_sq[c] / frames as f64).sqrt() as f32); + // Non-negative floats order the same as their bits. + handle.peaks[c].fetch_max(peak[c].to_bits(), Ordering::Relaxed); + let _ = handle.window[c].fetch_update(Ordering::Relaxed, Ordering::Relaxed, |w| { + let (sum, n) = unpack(w); + let block = frames as u32; + let add = sum_sq[c] as f32; + Some(if n > MAX_WINDOW_FRAMES { + pack(add, block) + } else { + pack(sum + add, n + block) + }) + }); } } @@ -124,6 +158,30 @@ pub fn update_meter(handle: &MeterHandle, interleaved: &[f32], channels: usize) mod tests { use super::*; + #[test] + fn a_tick_racing_the_audio_thread_never_reads_high() { + let h = MeterHandle::new("n".to_string()); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let audio = { + let (h, stop) = (h.clone(), stop.clone()); + std::thread::spawn(move || { + let block = vec![0.5f32; 64]; + while !stop.load(Ordering::Relaxed) { + update_meter(&h, &block, 2); + } + }) + }; + for _ in 0..200_000 { + let s = h.snapshot_and_decay(); + for (rms, peak) in s.rms.iter().zip(&s.peaks) { + assert!(*rms <= 0.5 + 1e-4, "RMS {rms} of a 0.5 signal"); + assert!(*peak <= 0.5, "peak {peak} of a 0.5 signal"); + } + } + stop.store(true, Ordering::Relaxed); + audio.join().unwrap(); + } + fn interleaved(ch: usize, frames: usize, f: impl Fn(usize, usize) -> f32) -> Vec { (0..frames * ch).map(|i| f(i / ch, i % ch)).collect() } @@ -155,20 +213,26 @@ mod tests { let s = h.snapshot_and_decay(); assert_eq!(s.peaks[0], 0.9); assert_eq!(s.peaks[1], 0.2); - // RMS is per-block: reflects only the last block's content. - assert_eq!(s.rms[0], 0.9); - assert_eq!(s.rms[1], 0.2); } #[test] - fn rms_is_per_block_not_accumulated() { + fn rms_covers_the_time_since_the_last_tick() { let h = MeterHandle::new("n".to_string()); - update_meter(&h, &interleaved(2, 240, |_, _| 0.8), 2); - update_meter(&h, &vec![0.0; 480], 2); - let s = h.snapshot_and_decay(); - for r in &s.rms { - assert_eq!(*r, 0.0); + // Half the tick at 0.8, half silent: whatever the block size. + for block in [32, 2048] { + for _ in 0..4096 / block { + update_meter(&h, &interleaved(2, block, |_, _| 0.8), 2); + } + for _ in 0..4096 / block { + update_meter(&h, &vec![0.0; 2 * block], 2); + } + let s = h.snapshot_and_decay(); + let want = (0.8f32 * 0.8 / 2.0).sqrt(); + assert!((s.rms[0] - want).abs() < 1e-5, "{block}: {}", s.rms[0]); } + // The next tick starts afresh. + update_meter(&h, &vec![0.0; 480], 2); + assert_eq!(h.snapshot_and_decay().rms[0], 0.0); } #[test] @@ -209,10 +273,11 @@ mod tests { let h = MeterHandle::new("n".to_string()); update_meter(&h, &interleaved(2, 240, |_, _| 0.6), 2); let mut e = LevelMeterEffect::from_handle(h.clone()); - // Per-block RMS follows the newest block; peaks keep the max. + // Both feed one reading: peaks keep the max, RMS spans both blocks. e.process(&mut interleaved(2, 240, |_, _| 0.4), 240); let s = h.snapshot_and_decay(); assert_eq!(s.peaks[0], 0.6); - assert_eq!(s.rms[0], 0.4); + let want = ((0.36f32 + 0.16) / 2.0).sqrt(); + assert!((s.rms[0] - want).abs() < 1e-6, "{}", s.rms[0]); } } diff --git a/src-tauri/src/audio/effects/limiter.rs b/src-tauri/src/audio/effects/limiter.rs index ced2ad9..3c0f953 100644 --- a/src-tauri/src/audio/effects/limiter.rs +++ b/src-tauri/src/audio/effects/limiter.rs @@ -6,9 +6,10 @@ use crate::audio::graph::LimiterData; use super::util::{db_to_linear, load_f32, store_f32}; use super::{Effect, EffectControl}; -/// Brick-wall limiter: input is delayed by `lookahead_frames`; gain envelope -/// reacts to the upcoming peak so reduction lands before the peak emerges. -/// Instant attack, exponential release. +/// Brick-wall limiter: input is delayed by `lookahead_frames`. The gain each +/// frame needs is held over the lookahead window and averaged over it again, +/// so the gain falls in a smooth ramp that reaches what a peak needs exactly +/// as the peak emerges, never a step. Exponential release. pub struct LimiterEffect { ceiling: Arc, release_ms: Arc, @@ -17,14 +18,58 @@ pub struct LimiterEffect { /// Stereo-interleaved look-ahead delay; both channels share `current_gain`. delay_buf: Box<[f32]>, delay_pos: usize, - /// Per-frame max(|L|, |R|) over the same window as `delay_buf`. Peak in - /// the window = `peak_buf.iter().max()`. - peak_buf: Box<[f32]>, + /// Peak of the last `window` frames, the one emerging now included. + peaks: SlidingMax, + /// The held gain of the last `window` frames, and their sum. + held: Box<[f32]>, + held_pos: usize, + held_sum: f64, current_gain: f32, /// Min gain during the last block (1.0 = no limiting). pub gr_lin: Arc, } +/// Largest of the last `capacity` values pushed, in constant time per push: +/// a queue of values each larger than all pushed after it. +struct SlidingMax { + values: Box<[f32]>, + times: Box<[u64]>, + head: usize, + len: usize, + now: u64, +} + +impl SlidingMax { + fn new(capacity: usize) -> Self { + Self { + values: vec![0.0; capacity].into_boxed_slice(), + times: vec![0; capacity].into_boxed_slice(), + head: 0, + len: 0, + now: 0, + } + } + + #[inline] + fn push(&mut self, v: f32) -> f32 { + let cap = self.values.len(); + let at = |i: usize| (self.head + i) % cap; + while self.len > 0 && self.values[at(self.len - 1)] <= v { + self.len -= 1; + } + let tail = at(self.len); + self.values[tail] = v; + self.times[tail] = self.now; + self.len += 1; + if self.now >= cap as u64 && self.times[self.head] <= self.now - cap as u64 { + self.head = (self.head + 1) % cap; + self.len -= 1; + } + self.now += 1; + self.values[self.head] + } +} + impl LimiterEffect { pub fn new(d: LimiterData, sample_rate: u32) -> (Self, EffectControl, Arc) { let lookahead_frames = @@ -38,17 +83,13 @@ impl LimiterEffect { release_ms: release_ms.clone(), }; ( - Self { + Self::from_state( ceiling, release_ms, - sample_rate, lookahead_frames, - delay_buf: vec![0.0; lookahead_frames * 2].into_boxed_slice(), - delay_pos: 0, - peak_buf: vec![0.0; lookahead_frames].into_boxed_slice(), - current_gain: 1.0, - gr_lin: gr_lin.clone(), - }, + sample_rate, + gr_lin.clone(), + ), control, gr_lin, ) @@ -61,6 +102,8 @@ impl LimiterEffect { sample_rate: u32, gr_lin: Arc, ) -> Self { + // The frame emerging now and every frame still in the delay. + let window = lookahead_frames + 1; Self { ceiling, release_ms, @@ -68,7 +111,10 @@ impl LimiterEffect { lookahead_frames, delay_buf: vec![0.0; lookahead_frames * 2].into_boxed_slice(), delay_pos: 0, - peak_buf: vec![0.0; lookahead_frames].into_boxed_slice(), + peaks: SlidingMax::new(window), + held: vec![1.0; window].into_boxed_slice(), + held_pos: 0, + held_sum: window as f64, current_gain: 1.0, gr_lin, } @@ -82,6 +128,7 @@ impl Effect for LimiterEffect { let release_coeff = 1.0 - (-1.0 / (release_ms * 0.001 * self.sample_rate as f32)).exp(); let lookahead = self.lookahead_frames; + let window = self.held.len(); let stereo = &mut samples[..frames * 2]; let mut block_min_gr = 1.0f32; for f in 0..frames { @@ -93,24 +140,29 @@ impl Effect for LimiterEffect { let r_out = self.delay_buf[self.delay_pos * 2 + 1]; self.delay_buf[self.delay_pos * 2] = l_in; self.delay_buf[self.delay_pos * 2 + 1] = r_in; - self.peak_buf[self.delay_pos] = l_in.abs().max(r_in.abs()); self.delay_pos = if self.delay_pos + 1 == lookahead { 0 } else { self.delay_pos + 1 }; - let mut peak = 0.0_f32; - for &p in self.peak_buf.iter() { - if p > peak { - peak = p; - } - } - let target = if peak > ceiling { ceiling / peak } else { 1.0 }; - if target < self.current_gain { - self.current_gain = target; + // The gain the loudest frame in the window needs, held over the + // window, then averaged over it: every frame averaged in saw the + // emerging one, so the average never asks for more than it needs. + let peak = self.peaks.push(l_in.abs().max(r_in.abs())); + let need = if peak > ceiling { ceiling / peak } else { 1.0 }; + self.held_sum += need as f64 - self.held[self.held_pos] as f64; + self.held[self.held_pos] = need; + self.held_pos = if self.held_pos + 1 == window { + 0 + } else { + self.held_pos + 1 + }; + let smooth = ((self.held_sum / window as f64) as f32).min(1.0); + if smooth < self.current_gain { + self.current_gain = smooth; } else { - self.current_gain += (target - self.current_gain) * release_coeff; + self.current_gain += (smooth - self.current_gain) * release_coeff; } if self.current_gain < block_min_gr { @@ -186,6 +238,32 @@ mod tests { } } + #[test] + fn the_gain_ramps_down_ahead_of_a_peak_instead_of_stepping() { + // A quiet passage, then a burst 6 dB over the ceiling: the quiet + // audio still emerging must fade, not drop at once when the burst + // enters the lookahead. + let d = LimiterData { + ceiling_db: -6.0, + lookahead_ms: 5.0, + release_ms: 50.0, + bypassed: false, + }; + let (mut e, _, _) = LimiterEffect::new(d, SR); + let mut buf = [dc(2_000, 0.25), dc(2_000, 1.0)].concat(); + e.process(&mut buf, 4_000); + let ceiling = db_to_linear(-6.0); + let left: Vec = buf.iter().step_by(2).copied().collect(); + // From the first emerging frame to the last of the quiet passage. + let worst = left[240..2_000 + 240] + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0f32, f32::max); + // The ramp halves 0.25 over 241 frames: about 0.0005 a frame. + assert!(worst < 0.002, "the quiet audio stepped by {worst}"); + assert!(left.iter().all(|s| s.abs() <= ceiling + 1e-5)); + } + #[test] fn latency_equals_lookahead_frames() { let d = LimiterData { diff --git a/src-tauri/src/audio/effects/lufs_meter.rs b/src-tauri/src/audio/effects/lufs_meter.rs index c6114fa..cadbf5b 100644 --- a/src-tauri/src/audio/effects/lufs_meter.rs +++ b/src-tauri/src/audio/effects/lufs_meter.rs @@ -16,8 +16,9 @@ pub const LUFS_SILENT: f32 = -120.0; /// passages, so a window has to be long enough to sit inside one gap. const NOISE_WINDOW_MS: usize = 300; -/// Sized for the DSP block; the analyser only ever sees two channels of it. -const SCRATCH_FRAMES: usize = 1024; +/// Sized for the largest engine block; the analyser only ever sees two +/// channels of it. +const SCRATCH_FRAMES: usize = crate::audio::graph::MAX_BUFFER_FRAMES; /// A sample at or past this counts as clipped; f32 can exceed 1.0, so the /// test is on magnitude rather than equality. @@ -36,8 +37,8 @@ pub struct LufsMeterEffect { /// First two channels of a wider block, deinterleaved for the analyser. scratch: Vec, handle: LufsHandle, - /// `loudness_global` iterates the entire stored block history (O(N)), - /// so we throttle it to ~once per second. + /// Global loudness and LRA are read about once a second: nobody reads + /// them faster. frames_since_global: usize, sample_rate: u32, /// Unweighted, ungated running RMS. ACX predates BS.1770 and specifies @@ -119,11 +120,16 @@ impl LufsHandle { } } +/// Histogram gating keeps integrated loudness and LRA in fixed memory and +/// fixed cost. Without it every 100 ms block is stored for the whole session: +/// the history grows on the audio thread, and the once-a-second global +/// readings walk all of it. const LUFS_MODE: Mode = Mode::I .union(Mode::M) .union(Mode::S) .union(Mode::LRA) - .union(Mode::TRUE_PEAK); + .union(Mode::TRUE_PEAK) + .union(Mode::HISTOGRAM); impl LufsMeterEffect { pub fn new(_d: LufsMeterData, node_id: String, sample_rate: u32) -> (Self, LufsHandle) { @@ -156,7 +162,7 @@ impl LufsMeterEffect { total_frames: 0, win_sum_sq: 0.0, win_frames: 0, - window_frames: (sample_rate as usize / 1000) * NOISE_WINDOW_MS, + window_frames: sample_rate as usize * NOISE_WINDOW_MS / 1000, noise_floor: f32::INFINITY, sum_dc: 0.0, sample_peak: 0.0, @@ -183,7 +189,7 @@ impl LufsMeterEffect { total_frames: 0, win_sum_sq: 0.0, win_frames: 0, - window_frames: (sample_rate as usize / 1000) * NOISE_WINDOW_MS, + window_frames: sample_rate as usize * NOISE_WINDOW_MS / 1000, noise_floor: f32::INFINITY, sum_dc: 0.0, sample_peak: 0.0, diff --git a/src-tauri/src/audio/effects/mod.rs b/src-tauri/src/audio/effects/mod.rs index 6e0181b..75b9455 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; @@ -54,6 +54,7 @@ use noise_gate::NoiseGateEffect; use noise_suppressor::{NoiseSuppressorControls, NoiseSuppressorEffect}; use reverb::ReverbEffect; use saturator::SaturatorEffect; +pub(crate) use util::Ramp; pub use waveform::{WaveformEffect, WaveformHandle}; /// Shared atom for a dynamic-gain effect (compressor / noise gate / limiter). @@ -95,9 +96,23 @@ pub enum RuntimeEffect { Declick(DeclickEffect), DeEsser(DeEsserEffect), HostedPlugin(HostedEffect), + /// A node run on its own thread with no work of its own, standing in + /// for a light plugin where no real one can be loaded. + #[cfg(test)] + Offloaded(offload::Offload), } 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(), + _ => None, + } + } + #[inline] pub fn latency_frames(&self) -> usize { match self { @@ -118,6 +133,8 @@ impl RuntimeEffect { RuntimeEffect::Declick(e) => e.latency_frames(), RuntimeEffect::DeEsser(e) => e.latency_frames(), RuntimeEffect::HostedPlugin(e) => e.latency_frames(), + #[cfg(test)] + RuntimeEffect::Offloaded(o) => o.latency_frames(), } } @@ -128,7 +145,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; @@ -152,6 +173,8 @@ impl RuntimeEffect { RuntimeEffect::Declick(e) => e.process(active, frames), RuntimeEffect::DeEsser(e) => e.process(active, frames), RuntimeEffect::HostedPlugin(e) => e.process(active, frames), + #[cfg(test)] + RuntimeEffect::Offloaded(o) => o.process(active), } for sample in active { if !sample.is_finite() { @@ -489,6 +512,10 @@ 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, + // Most blocks the graph renders back to back (see `Offload::spawn`). + burst_blocks: 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, @@ -527,7 +554,7 @@ pub fn instantiate_effect( match *spec { EffectSpec::Gain(d) => match registry.controls.get(node_id) { Some(EffectControl::Gain { linear }) => mk( - RuntimeEffect::Gain(GainEffect::from_state(linear.clone())), + RuntimeEffect::Gain(GainEffect::from_state(linear.clone(), sample_rate)), None, None, None, @@ -535,14 +562,14 @@ pub fn instantiate_effect( None, ), _ => { - let (e, c) = GainEffect::new(d); + let (e, c) = GainEffect::new(d, sample_rate); registry.controls.insert(node_id.to_string(), c.clone()); mk(RuntimeEffect::Gain(e), Some(c), None, None, None, None) } }, EffectSpec::Mute(d) => match registry.controls.get(node_id) { Some(EffectControl::Mute { muted }) => mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted.clone())), + RuntimeEffect::Mute(MuteEffect::from_state(muted.clone(), sample_rate)), None, None, None, @@ -550,7 +577,7 @@ pub fn instantiate_effect( None, ), _ => { - let (e, c) = MuteEffect::new(d); + let (e, c) = MuteEffect::new(d, sample_rate); registry.controls.insert(node_id.to_string(), c.clone()); mk(RuntimeEffect::Mute(e), Some(c), None, None, None, None) } @@ -560,6 +587,7 @@ pub fn instantiate_effect( RuntimeEffect::ChannelBalance(ChannelBalanceEffect::from_state( left.clone(), right.clone(), + sample_rate, )), None, None, @@ -568,7 +596,7 @@ pub fn instantiate_effect( None, ), _ => { - let (e, c) = ChannelBalanceEffect::new(d); + let (e, c) = ChannelBalanceEffect::new(d, sample_rate); registry.controls.insert(node_id.to_string(), c.clone()); mk( RuntimeEffect::ChannelBalance(e), @@ -585,6 +613,7 @@ pub fn instantiate_effect( RuntimeEffect::Saturator(SaturatorEffect::from_state( ceiling.clone(), drive.clone(), + sample_rate, )), None, None, @@ -593,7 +622,7 @@ pub fn instantiate_effect( None, ), _ => { - let (e, c) = SaturatorEffect::new(d); + let (e, c) = SaturatorEffect::new(d, sample_rate); registry.controls.insert(node_id.to_string(), c.clone()); mk(RuntimeEffect::Saturator(e), Some(c), None, None, None, None) } @@ -657,8 +686,11 @@ pub fn instantiate_effect( ) } }, + // A handle carries the rate its samples run at; a node rebuilt in a + // graph at another rate starts a new one, or the UI reads its time + // and frequency axes at the old rate. EffectSpec::Waveform(d) => match registry.scopes.get(node_id) { - Some(handle) => mk( + Some(handle) if handle.sample_rate == sample_rate => mk( RuntimeEffect::Waveform(WaveformEffect::from_handle(handle.clone())), None, None, @@ -666,7 +698,7 @@ pub fn instantiate_effect( None, None, ), - None => { + _ => { let (e, handle) = WaveformEffect::new(d, node_id.to_string(), sample_rate); registry.scopes.insert(node_id.to_string(), handle.clone()); mk( @@ -682,7 +714,7 @@ pub fn instantiate_effect( // Spectrum reuses the scope's time-domain capture and SCOPE_EVENT // transport; the UI runs the FFT. No distinct runtime effect is needed. EffectSpec::Spectrum(_) => match registry.scopes.get(node_id) { - Some(handle) => mk( + Some(handle) if handle.sample_rate == sample_rate => mk( RuntimeEffect::Waveform(WaveformEffect::from_handle(handle.clone())), None, None, @@ -690,7 +722,7 @@ pub fn instantiate_effect( None, None, ), - None => { + _ => { let (e, handle) = WaveformEffect::new_for(node_id.to_string(), sample_rate); registry.scopes.insert(node_id.to_string(), handle.clone()); mk( @@ -937,6 +969,8 @@ pub fn instantiate_effect( RuntimeEffect::NoiseSuppressor(NoiseSuppressorEffect::from_state( controls.clone(), sample_rate, + block_frames, + burst_blocks, realtime, )), None, @@ -946,7 +980,13 @@ pub fn instantiate_effect( None, ), _ => { - let (e, c) = NoiseSuppressorEffect::new(d, sample_rate, realtime); + let (e, c) = NoiseSuppressorEffect::new( + d, + sample_rate, + block_frames, + burst_blocks, + realtime, + ); registry.controls.insert(node_id.to_string(), c.clone()); mk( RuntimeEffect::NoiseSuppressor(e), @@ -967,6 +1007,7 @@ pub fn instantiate_effect( sensitivity.clone(), max_width_ms.clone(), sample_rate, + d.max_width_ms, )), None, None, @@ -1017,7 +1058,7 @@ pub fn instantiate_effect( crate::audio::plugins::registry::forget(node_id); let muted = Arc::new(AtomicBool::new(false)); return mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted)), + RuntimeEffect::Mute(MuteEffect::from_state(muted, sample_rate)), None, None, None, @@ -1031,7 +1072,7 @@ pub fn instantiate_effect( tracing::error!(node_id, path, "plugin node has no format"); let muted = Arc::new(AtomicBool::new(true)); return mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted)), + RuntimeEffect::Mute(MuteEffect::from_state(muted, sample_rate)), None, None, None, @@ -1074,7 +1115,13 @@ 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, + burst_blocks, + )), control, None, None, @@ -1090,7 +1137,7 @@ pub fn instantiate_effect( crate::audio::plugins::registry::forget(node_id); let muted = Arc::new(AtomicBool::new(true)); mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted)), + RuntimeEffect::Mute(MuteEffect::from_state(muted, sample_rate)), None, None, None, @@ -1401,6 +1448,99 @@ mod tests { assert_eq!(seen, vec![(12, 0.5)], "non-numeric ids must be skipped"); } + #[test] + fn a_setting_that_changes_the_level_glides_instead_of_stepping() { + // On a steady input only the setting moves the output: a change must + // spread over the glide, never land in one sample. + use crate::audio::graph::{ + ChannelBalanceData, CompressorData, EqData, GainData, SaturatorData, + }; + let cases: Vec<(EffectSpec, serde_json::Value)> = vec![ + ( + EffectSpec::Gain(GainData { + gain_db: 0.0, + bypassed: false, + }), + serde_json::json!({ "gainDb": -12.0 }), + ), + ( + EffectSpec::ChannelBalance(ChannelBalanceData { + left_gain_db: 0.0, + right_gain_db: 0.0, + bypassed: false, + }), + serde_json::json!({ "leftGainDb": -12.0, "rightGainDb": -12.0 }), + ), + ( + EffectSpec::Saturator(SaturatorData { + threshold_db: 0.0, + drive_db: 0.0, + bypassed: false, + }), + serde_json::json!({ "driveDb": 12.0 }), + ), + ( + EffectSpec::Compressor(CompressorData { + threshold_db: -30.0, + ratio: 4.0, + attack_ms: 1.0, + release_ms: 50.0, + knee_db: 0.0, + makeup_db: 0.0, + bypassed: false, + }), + serde_json::json!({ "makeupDb": 12.0, "thresholdDb": -10.0 }), + ), + ( + EffectSpec::Eq(EqData { + gains_db: [0.0; 10], + bypassed: false, + }), + serde_json::json!({ "gainsDb": [-12.0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }), + ), + ]; + for (spec, update) in cases { + let mut reg = EffectRegistry::new(); + let mut build = + instantiate_effect(&spec, "n", 48_000, 1024, 1, true, true, 2, &mut reg); + let mut warm = vec![0.25f32; 48_000 * 2]; + build.effect.process_with_sidechain(&mut warm, None, 48_000); + let before = warm[warm.len() - 1]; + build + .control + .as_ref() + .expect("control") + .apply_update(&update); + let mut buf = vec![0.25f32; 4_800 * 2]; + build.effect.process_with_sidechain(&mut buf, None, 4_800); + let mut prev = before; + let mut worst = 0.0f32; + for frame in buf.chunks_exact(2) { + worst = worst.max((frame[0] - prev).abs()); + prev = frame[0]; + } + let moved = (buf[buf.len() - 1] - before).abs(); + assert!(moved > 0.01, "{spec:?}: the setting changed nothing"); + assert!( + worst < moved / 50.0, + "{spec:?}: stepped by {worst} of {moved}" + ); + } + } + + #[test] + fn a_scope_rebuilt_at_another_rate_starts_a_new_handle() { + let mut reg = EffectRegistry::new(); + let spec = EffectSpec::Spectrum(Default::default()); + let first = instantiate_effect(&spec, "sp", 48_000, 1024, 1, true, true, 2, &mut reg); + let same = instantiate_effect(&spec, "sp", 48_000, 1024, 1, true, true, 2, &mut reg); + assert!(same.scope.is_none(), "same rate reuses the handle"); + let other = instantiate_effect(&spec, "sp", 44_100, 1024, 1, true, true, 2, &mut reg); + let handle = other.scope.expect("a new handle at the new rate"); + assert_eq!(handle.sample_rate, 44_100); + assert_ne!(handle.session, first.scope.unwrap().session); + } + #[test] fn instantiate_reuses_control_and_bypass_for_same_node() { let mut reg = EffectRegistry::new(); @@ -1408,17 +1548,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, 1, 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, 1, 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, 1, true, true, 2, &mut reg); assert!(other.bypass_is_new); assert!(other.control.is_some()); } @@ -1432,9 +1572,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, 1, 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, 1, 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 +1596,8 @@ mod tests { }), "c", 48_000, + 1024, + 1, true, true, 2, @@ -1473,6 +1615,8 @@ mod tests { }), "g", 48_000, + 1024, + 1, true, true, 2, @@ -1488,6 +1632,8 @@ mod tests { &EffectSpec::LevelMeter(LevelMeterData {}), "m", 48_000, + 1024, + 1, true, true, 2, @@ -1498,6 +1644,8 @@ mod tests { &EffectSpec::LevelMeter(LevelMeterData {}), "m", 48_000, + 1024, + 1, true, true, 2, @@ -1509,6 +1657,8 @@ mod tests { &EffectSpec::LufsMeter(LufsMeterData {}), "l", 48_000, + 1024, + 1, true, true, 2, @@ -1519,6 +1669,8 @@ mod tests { &EffectSpec::Waveform(WaveformData {}), "w", 48_000, + 1024, + 1, true, true, 2, @@ -1529,6 +1681,8 @@ mod tests { &EffectSpec::Spectrum(SpectrumData {}), "s", 48_000, + 1024, + 1, true, true, 2, @@ -1539,6 +1693,8 @@ mod tests { &EffectSpec::Spectrum(SpectrumData {}), "s", 48_000, + 1024, + 1, true, true, 2, @@ -1558,7 +1714,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, 1, true, true, 2, &mut reg); assert!(build.control.is_none()); assert!(!build.full_width); let mut buf = vec![0.3f32, -0.4]; @@ -1577,7 +1733,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, 1, 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 +1750,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, 1, 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 +1765,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 +1841,123 @@ 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, 1, 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, 1, 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, 1, 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, 1, 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 +2012,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, 1, true, true, 2, &mut reg); let mut poisoned = vec![0.25; 96]; poisoned[10] = f32::NAN; poisoned[11] = f32::INFINITY; @@ -1851,7 +2120,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, 1, false, true, 2, &mut reg); assert!( first.control.is_some() || first.meter.is_some() @@ -1860,7 +2129,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, 1, 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/mute.rs b/src-tauri/src/audio/effects/mute.rs index 3ca1718..796ef22 100644 --- a/src-tauri/src/audio/effects/mute.rs +++ b/src-tauri/src/audio/effects/mute.rs @@ -3,35 +3,40 @@ use std::sync::Arc; use crate::audio::graph::MuteData; +use super::util::Ramp; use super::{Effect, EffectControl}; pub struct MuteEffect { muted: Arc, - current: f32, + ramp: Ramp, +} + +fn level(muted: bool) -> f32 { + if muted { + 0.0 + } else { + 1.0 + } } impl MuteEffect { - pub fn new(d: MuteData) -> (Self, EffectControl) { + pub fn new(d: MuteData, sample_rate: u32) -> (Self, EffectControl) { let muted = Arc::new(AtomicBool::new(d.muted)); let control = EffectControl::Mute { muted: muted.clone(), }; ( Self { - current: if d.muted { 0.0 } else { 1.0 }, + ramp: Ramp::new(level(d.muted), sample_rate), muted, }, control, ) } - pub fn from_state(muted: Arc) -> Self { + pub fn from_state(muted: Arc, sample_rate: u32) -> Self { Self { - current: if muted.load(Ordering::Relaxed) { - 0.0 - } else { - 1.0 - }, + ramp: Ramp::new(level(muted.load(Ordering::Relaxed)), sample_rate), muted, } } @@ -40,29 +45,20 @@ impl MuteEffect { impl Effect for MuteEffect { #[inline] fn process(&mut self, samples: &mut [f32], frames: usize) { - let target = if self.muted.load(Ordering::Relaxed) { - 0.0 - } else { - 1.0 - }; - if self.current >= 1.0 && target >= 1.0 { + self.ramp.set(level(self.muted.load(Ordering::Relaxed))); + if self.ramp.at(1.0) { return; } let stereo = &mut samples[..frames * 2]; - if self.current <= 0.0 && target <= 0.0 { - for s in stereo { - *s = 0.0; - } + if self.ramp.at(0.0) { + stereo.fill(0.0); return; } - let step = (target - self.current) / frames as f32; - let mut g = self.current; for frame in stereo.chunks_exact_mut(2) { - g += step; + let g = self.ramp.next(); frame[0] *= g; frame[1] *= g; } - self.current = target; } } @@ -72,10 +68,13 @@ mod tests { #[test] fn mute_zeros() { - let (mut e, _) = MuteEffect::new(MuteData { - muted: true, - bypassed: false, - }); + let (mut e, _) = MuteEffect::new( + MuteData { + muted: true, + bypassed: false, + }, + 48_000, + ); let mut buf = [0.5, -0.5, 0.3, -0.3]; e.process(&mut buf, 2); assert_eq!(buf, [0.0, 0.0, 0.0, 0.0]); @@ -83,13 +82,17 @@ mod tests { #[test] fn mute_control_unmutes_live() { - let (mut e, c) = MuteEffect::new(MuteData { - muted: true, - bypassed: false, - }); + let (mut e, c) = MuteEffect::new( + MuteData { + muted: true, + bypassed: false, + }, + 48_000, + ); c.apply_update(&serde_json::json!({ "muted": false })); - let mut buf = [0.5_f32, -0.5]; - e.process(&mut buf, 1); - assert_eq!(buf, [0.5, -0.5]); + let mut buf = vec![0.5_f32; 2 * 240]; + e.process(&mut buf, 240); + assert_eq!(buf[478], 0.5); + assert!(buf[0] < 0.01, "fades in, not a step"); } } diff --git a/src-tauri/src/audio/effects/noise_suppressor.rs b/src-tauri/src/audio/effects/noise_suppressor.rs index 194b17e..c655c1e 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, @@ -72,8 +73,8 @@ use model::{DfParams, DfTract, RuntimeParams}; use ndarray::Array2; use crate::audio::graph::NoiseSuppressorData; -use crate::audio::pipeline::dag::DSP_BLOCK_FRAMES; -use crate::audio::resample::MultiResampler; +use crate::audio::graph::MAX_BUFFER_FRAMES; +use crate::audio::resample::{MultiResampler, MultiResamplerOut}; use super::offload::{BlockProcessor, Offload}; use super::util::load_f32; @@ -83,8 +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; -const CAP: usize = 8192; +/// 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; #[derive(Clone)] pub struct NoiseSuppressorControls { @@ -98,6 +107,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 { @@ -138,58 +149,47 @@ 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 { - pub fn new(d: NoiseSuppressorData, sample_rate: u32, realtime: bool) -> (Self, EffectControl) { + pub fn new( + d: NoiseSuppressorData, + sample_rate: u32, + block_frames: usize, + burst_blocks: 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,53 +200,83 @@ 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, burst_blocks, 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, + burst_blocks: usize, + 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; + let hop_out = state.hop_out; + let working_block = (hop_out > block_frames).then_some(hop_out); 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 { + latency: worker.state.latency, 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, - } - } - Err(worker) => { - let out = Vec::with_capacity(DSP_BLOCK_FRAMES * 2); + 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, + burst_blocks, + sample_rate, + ) { + Ok(offload) => Self { + latency: model_latency + offload.latency_frames(), + backend: Some(Backend::Offloaded(offload)), + working_block, + }, + 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, } } } } -} + /// 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 { - 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; @@ -260,62 +290,150 @@ 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 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)); + // Either queue holds its prefill, a hop being gathered or answered, + // and a whole engine block on top: sized so the audio thread never + // grows them. + let cap = (preroll + queued + 2 * hop_out + MAX_BUFFER_FRAMES) * 2; + 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); + 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); @@ -325,37 +443,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(); + 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; } - 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); - } - } - - 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)); } } } @@ -399,7 +495,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, 1, 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 +516,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, 1, false); assert!(e.latency_frames() > 0); for _k in 0..4 { let mut buf = vec![0.2f32; 512 * 2]; @@ -431,7 +527,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, 1, false); let EffectControl::NoiseSuppressor { controls } = &c else { panic!("variant") }; @@ -450,9 +546,161 @@ 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, + 1, + 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, 1, 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, false); + let (mut e, _) = NoiseSuppressorEffect::new(data(), 48_000, 1024, 1, 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 24b910f..22e89b1 100644 --- a/src-tauri/src/audio/effects/offload.rs +++ b/src-tauri/src/audio/effects/offload.rs @@ -1,24 +1,99 @@ //! 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. +//! +//! 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 std::time::{Duration, Instant}; 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); +/// Waking a parked real-time thread and its scheduling slack, on top of the +/// processing the worker measures for itself. +const WAKE: Duration = Duration::from_millis(1); +/// Processing is timed over this many of the effect's blocks, after the +/// first few (which warm caches and lazy state) are left out. +const CALIBRATION_RUNS: usize = 8; +const CALIBRATION_WARMUP: usize = 2; +/// Headroom over the slowest timed block, for the effect sharing the CPU +/// with the rest of the graph later on. +const PROCESSING_MARGIN: f64 = 2.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. +/// +/// 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(block_frames: usize, working_frames: usize, turnaround_frames: usize) -> usize { + let b = block_frames.max(1); + let w = working_frames.max(1); + let turnaround_blocks = turnaround_frames.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) +} + +/// The worker's turnaround in frames: its wake-up plus the slowest of a few +/// timed runs of `processor` on silence, with headroom. Run on the worker +/// itself, after its promotion, so the timing is the thread's own. +fn turnaround_frames( + processor: &mut P, + working: usize, + width: usize, + sample_rate: u32, +) -> usize { + let input = vec![0.0f32; working * width]; + let mut out = Vec::with_capacity(working * width); + let mut slowest = Duration::ZERO; + for run in 0..CALIBRATION_RUNS { + out.clear(); + let started = Instant::now(); + processor.process(&input, &mut out); + if run >= CALIBRATION_WARMUP { + slowest = slowest.max(started.elapsed()); + } + } + let secs = WAKE.as_secs_f64() + slowest.as_secs_f64() * PROCESSING_MARGIN; + (sample_rate as f64 * secs).ceil() as usize +} + +fn gcd(a: usize, b: usize) -> usize { + if b == 0 { + a + } else { + gcd(b, a % b) + } +} /// Interleaved block processing, run on the offload thread. pub trait BlockProcessor: Send { @@ -26,12 +101,37 @@ pub trait BlockProcessor: Send { fn process(&mut self, input: &[f32], output: &mut Vec); } +/// Hands every block straight back: an offloaded node whose own work costs +/// nothing, so only the hand-off is measured. +#[cfg(test)] +pub(crate) struct Echo; + +#[cfg(test)] +impl BlockProcessor for Echo { + fn process(&mut self, input: &[f32], output: &mut Vec) { + output.extend_from_slice(input); + } +} + 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, + /// 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. + worker: Thread, width: usize, + pad_frames: usize, } // A partial write must not split a frame: a short tail would shift every later @@ -64,34 +164,43 @@ fn push_aligned(prod: &mut Producer, samples: &[f32], width: usize) -> usiz } impl Offload { + /// `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. + /// `burst_blocks` is the most engine blocks the graph renders back to + /// back: a burst is pushed and read at once, so the pad covers it whole. pub fn spawn( name: &'static str, processor: P, width: usize, + working_frames: Option, + block_frames: usize, + burst_blocks: 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); - - match worker_out.write_chunk(PAD_FRAMES * width) { - Ok(mut chunk) => { - let (first, second) = chunk.as_mut_slices(); - first.fill(0.0); - second.fill(0.0); - chunk.commit_all(); - } - Err(e) => { - tracing::error!(name, error = %e, "offload: failed to prefill return ring pad"); - return Err(processor); - } - } + let working = working_frames.unwrap_or(block_frames).max(1); + // The worker times the effect, sizes the pad from it, and hands back + // the RT side of its rings. + let (ready_tx, ready_rx) = + std::sync::mpsc::sync_channel::<(usize, Producer, Consumer)>(1); 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(); + #[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. @@ -100,34 +209,83 @@ impl Offload { let join = match thread::Builder::new() .name(format!("offload:{name}")) .spawn(move || { + // 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", + working as u32, + sample_rate, + ); + // Timed in place: a processor that panics stays in the cell + // for the caller to take back. + let turnaround = { + let mut cell = handoff_thread.lock().unwrap(); + let processor = cell.as_mut().expect("processor handed off"); + turnaround_frames(processor, working, width, sample_rate) + }; let mut processor = handoff_thread .lock() .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); + let pad_frames = + pad_frames(block_frames * burst_blocks.max(1), working, turnaround); + // Room for the pad, a block in flight each way, and a stall's worth. + 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); + if let Ok(mut chunk) = worker_out.write_chunk(pad_frames * width) { + let (first, second) = chunk.as_mut_slices(); + first.fill(0.0); + second.fill(0.0); + chunk.commit_all(); + } + if ready_tx.send((pad_frames, to_worker, from_worker)).is_err() { + return; + } + // 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); + 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 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(); + busy_since = Instant::now(); + #[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; + 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); } }) { Ok(j) => j, @@ -142,27 +300,47 @@ impl Offload { return Err(processor); } }; + let Ok((pad_frames, to_worker, from_worker)) = ready_rx.recv() else { + let _ = join.join(); + warn!(name, "offload: worker died while timing the effect"); + let processor = handoff + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .expect("processor stays in the cell until timed"); + return Err(processor); + }; Ok(Self { to_worker, from_worker, stop, + #[cfg(test)] + idle_wakes, + #[cfg(test)] + taken, + #[cfg(test)] + breathers, + 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. - 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,13 +374,14 @@ impl Offload { } pub fn latency_frames(&self) -> usize { - PAD_FRAMES + self.pad_frames } } 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"); @@ -215,10 +394,25 @@ impl Drop for Offload { mod tests { use super::*; - struct Passthrough; + /// 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, + } + + 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); } } @@ -226,79 +420,278 @@ 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)); } } - #[test] - fn offload_roundtrip_delays_by_pad() { - let Ok(mut offload) = Offload::spawn("test", Passthrough, 2) 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, 1, sample_rate) + else { panic!("spawn offload") }; + let pad = offload.latency_frames(); + assert!(pad >= pad_frames(block, working.unwrap_or(block), 1)); 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 + 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) { + 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_until_taken(&offload, fed.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; + 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], "{case}: 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, 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() { - const WIDTH: usize = 6; - let Ok(mut offload) = Offload::spawn("test-wide", Passthrough, WIDTH) else { - panic!("spawn offload") - }; + roundtrip(6, 1024, None, 48_000); + roundtrip(6, 64, Some(480), 48_000); + } - let mut fed = Vec::new(); - let mut got = Vec::new(); - let mut counter = 0.0f32; + /// 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 + }) + }) + } - 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; + #[test] + fn the_pad_covers_a_burst_of_blocks_rendered_back_to_back() { + // A speaker resampled to its device renders `burst` blocks at one + // instant every `burst` periods. The pad sized for the burst never + // starves; the one sized for a steady block does. + let starves_in_bursts = + |pad: usize, block: usize, working: usize, turnaround: usize, burst: usize| { + let at = |k: usize| k / burst * burst * block; + let blocks = + (pad + working) / block + working / gcd(working, block) * 2 + 4 * burst + 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; + at(done) + turnaround > at(k) + }) + }) + }; + for block in [32, 64, 128, 256] { + for working in [block, 441, 480] { + for turnaround in [1, block / 2, block, 3 * block] { + for burst in [2, 3, 4] { + let pad = pad_frames(block * burst, working, turnaround); + assert!( + !starves_in_bursts(pad, block, working, turnaround, burst), + "{block}/{working}/{turnaround} in bursts of {burst}: pad {pad} starves" + ); + } + // A plugin turning a block round within the block: its + // steady pad is one or two blocks, short of a burst. + if working == block && turnaround <= block { + let steady = pad_frames(block, working, turnaround); + assert!( + starves_in_bursts(steady, block, working, turnaround, 3), + "{block}/{turnaround}: a steady pad {steady} rides out bursts" + ); + } } - counter += 1.0; } - fed.extend_from_slice(&block); - offload.process(&mut block); - got.extend_from_slice(&block); - wait_for_return(&offload, block.len()); } + } + + #[test] + fn the_pad_is_the_least_that_never_starves() { + for turnaround in [1, 48, 221, 240, 480] { + 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(block, working, turnaround); + let case = format!("{turnaround}/{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(64, 441, 221); + assert!(pad <= 441 + 64 + 256, "{pad}"); + // An effect that takes any block pays the turnaround alone. + assert_eq!(pad_frames(32, 32, 240), 256, "5 ms turnaround at 48k"); + assert_eq!(pad_frames(2048, 2048, 1), 2048, "never below the block"); + } + + #[test] + fn the_pad_follows_what_the_effect_costs() { + let Ok(cheap) = Offload::spawn("test", passthrough(), 2, None, 32, 1, 48_000) else { + panic!("spawn offload") + }; + let slow = Slow { + cost: Duration::from_millis(3), + }; + let Ok(dear) = Offload::spawn("test", slow, 2, None, 32, 1, 48_000) else { + panic!("spawn offload") + }; + // Old fixed floor: 5 ms, 256 frames at a 32-frame block. + assert!(cheap.latency_frames() < 256, "{}", cheap.latency_frames()); + assert!( + dear.latency_frames() >= 2 * 144, + "3 ms of work, twice over: {}", + dear.latency_frames() + ); + } - 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}"); + /// 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); } - for i in pad..got.len() { - assert_eq!(got[i], fed[i - pad], "mismatch at index {i}"); + } + + #[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, 1, 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 + // push wakes it (or, rarely, spuriously). + let Ok(mut offload) = Offload::spawn("test", passthrough(), 2, None, 64, 1, 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, 1, 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, 1, 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/effects/reverb.rs b/src-tauri/src/audio/effects/reverb.rs index cb278b1..8cdba28 100644 --- a/src-tauri/src/audio/effects/reverb.rs +++ b/src-tauri/src/audio/effects/reverb.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::audio::graph::ReverbData; -use super::util::load_f32; +use super::util::{load_f32, Ramp}; use super::{Effect, EffectControl}; /// Freeverb. Tuning constants are 44.1 kHz; we scale buffer lengths to the @@ -18,6 +18,8 @@ pub struct ReverbEffect { comb_r: [Comb; 8], allpass_l: [Allpass; 4], allpass_r: [Allpass; 4], + ramp_width: Ramp, + ramp_mix: Ramp, } struct Comb { @@ -112,6 +114,8 @@ impl ReverbEffect { let scale = sample_rate as f32 / 44100.0; let comb_len = |n: usize| (n as f32 * scale) as usize; Self { + ramp_width: Ramp::new(load_f32(&width).clamp(0.0, 1.0), sample_rate), + ramp_mix: Ramp::new(load_f32(&mix).clamp(0.0, 1.0), sample_rate), room_size, damping, width, @@ -133,17 +137,19 @@ impl Effect for ReverbEffect { } let room = load_f32(&self.room_size).clamp(0.0, 1.0); let damping = load_f32(&self.damping).clamp(0.0, 1.0); - let width = load_f32(&self.width).clamp(0.0, 1.0); - let mix = load_f32(&self.mix).clamp(0.0, 1.0); + self.ramp_width.set(load_f32(&self.width).clamp(0.0, 1.0)); + self.ramp_mix.set(load_f32(&self.mix).clamp(0.0, 1.0)); let feedback = room * REVERB_SCALE_ROOM + REVERB_OFFSET_ROOM; let damp = damping * REVERB_SCALE_DAMP; - let dry = 1.0 - mix; - // width=1: strict L/R wet; width=0: mono wet image (cross-channel mix) - let wet1 = mix * (width * 0.5 + 0.5); - let wet2 = mix * (1.0 - width) * 0.5; let stereo = &mut samples[..frames * 2]; for frame in stereo.chunks_exact_mut(2) { + let width = self.ramp_width.next(); + let mix = self.ramp_mix.next(); + let dry = 1.0 - mix; + // width=1: strict L/R wet; width=0: mono wet image (cross-channel mix) + let wet1 = mix * (width * 0.5 + 0.5); + let wet2 = mix * (1.0 - width) * 0.5; let il = frame[0]; let ir = frame[1]; let input = (il + ir) * REVERB_INPUT_GAIN; @@ -367,6 +373,9 @@ mod tests { SR, ); mix.store(0.0f32.to_bits(), std::sync::atomic::Ordering::Relaxed); + // The mix glides to dry over a few milliseconds, then holds there. + let mut settle = vec![0.0; 480 * 2]; + e.process(&mut settle, 480); let input = vec![0.3, -0.7, 0.9, -0.1]; let mut buf = input.clone(); e.process(&mut buf, 2); diff --git a/src-tauri/src/audio/effects/saturator.rs b/src-tauri/src/audio/effects/saturator.rs index 76ced7f..fa59379 100644 --- a/src-tauri/src/audio/effects/saturator.rs +++ b/src-tauri/src/audio/effects/saturator.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::audio::graph::SaturatorData; -use super::util::{db_to_linear, load_f32}; +use super::util::{db_to_linear, load_f32, Ramp}; use super::{Effect, EffectControl}; /// Soft saturator: `y = ceiling * tanh(x * drive / ceiling)` — smooth tanh @@ -11,10 +11,12 @@ use super::{Effect, EffectControl}; pub struct SaturatorEffect { ceiling: Arc, drive: Arc, + ramp_ceiling: Ramp, + ramp_drive: Ramp, } impl SaturatorEffect { - pub fn new(d: SaturatorData) -> (Self, EffectControl) { + pub fn new(d: SaturatorData, sample_rate: u32) -> (Self, EffectControl) { let c = db_to_linear(d.threshold_db).max(1e-6); let ceiling = Arc::new(AtomicU32::new(c.to_bits())); let drive = Arc::new(AtomicU32::new(db_to_linear(d.drive_db).to_bits())); @@ -22,24 +24,39 @@ impl SaturatorEffect { ceiling: ceiling.clone(), drive: drive.clone(), }; - (Self { ceiling, drive }, control) + (Self::from_state(ceiling, drive, sample_rate), control) } - pub fn from_state(ceiling: Arc, drive: Arc) -> Self { - Self { ceiling, drive } + pub fn from_state(ceiling: Arc, drive: Arc, sample_rate: u32) -> Self { + Self { + ramp_ceiling: Ramp::new(load_f32(&ceiling).max(1e-6), sample_rate), + ramp_drive: Ramp::new(load_f32(&drive), sample_rate), + ceiling, + drive, + } } } impl Effect for SaturatorEffect { #[inline] fn process(&mut self, samples: &mut [f32], frames: usize) { - // No `inv_ceiling` cache — RT could read NEW_c with OLD_inv_c (torn pair). - let c = load_f32(&self.ceiling).max(1e-6); - let d = load_f32(&self.drive); - let inv_c = 1.0 / c; + self.ramp_ceiling.set(load_f32(&self.ceiling).max(1e-6)); + self.ramp_drive.set(load_f32(&self.drive)); let stereo = &mut samples[..frames * 2]; - for s in stereo { - *s = c * fast_tanh(*s * d * inv_c); + if self.ramp_ceiling.is_settled() && self.ramp_drive.is_settled() { + let c = self.ramp_ceiling.value(); + let k = self.ramp_drive.value() / c; + for s in stereo { + *s = c * fast_tanh(*s * k); + } + return; + } + for frame in stereo.chunks_exact_mut(2) { + let c = self.ramp_ceiling.next(); + let k = self.ramp_drive.next() / c; + for s in frame { + *s = c * fast_tanh(*s * k); + } } } } @@ -61,11 +78,14 @@ mod tests { #[test] fn saturator_clips_above_ceiling() { - let (mut e, _) = SaturatorEffect::new(SaturatorData { - threshold_db: 0.0, - drive_db: 0.0, - bypassed: false, - }); + let (mut e, _) = SaturatorEffect::new( + SaturatorData { + threshold_db: 0.0, + drive_db: 0.0, + bypassed: false, + }, + 48_000, + ); let mut buf = [10.0, -10.0]; e.process(&mut buf, 1); assert!(buf[0].abs() < 1.05); diff --git a/src-tauri/src/audio/effects/util.rs b/src-tauri/src/audio/effects/util.rs index 8252846..d781ddb 100644 --- a/src-tauri/src/audio/effects/util.rs +++ b/src-tauri/src/audio/effects/util.rs @@ -21,6 +21,70 @@ pub(super) fn load_f32(slot: &AtomicU32) -> f32 { f32::from_bits(slot.load(Ordering::Relaxed)) } +/// Gain changes glide over this long, whatever the block size: short enough +/// to feel instant, long enough not to click. +pub(crate) const GAIN_RAMP_MS: f32 = 5.0; + +/// A gain that moves to each new target in a straight line over a fixed +/// number of frames, carried across blocks. +pub(crate) struct Ramp { + current: f32, + target: f32, + step: f32, + remaining: usize, + frames: usize, +} + +impl Ramp { + pub(crate) fn new(value: f32, sample_rate: u32) -> Self { + Self { + current: value, + target: value, + step: 0.0, + remaining: 0, + frames: ((GAIN_RAMP_MS * sample_rate as f32 / 1000.0) as usize).max(1), + } + } + + #[inline] + pub(crate) fn set(&mut self, target: f32) { + if target != self.target { + self.target = target; + self.remaining = self.frames; + self.step = (target - self.current) / self.frames as f32; + } + } + + /// Settled at `value`: no glide in progress. + #[inline] + pub(crate) fn at(&self, value: f32) -> bool { + self.remaining == 0 && self.current == value + } + + #[inline] + pub(crate) fn is_settled(&self) -> bool { + self.remaining == 0 + } + + #[inline] + pub(crate) fn value(&self) -> f32 { + self.current + } + + #[inline] + pub(crate) fn next(&mut self) -> f32 { + if self.remaining > 0 { + self.remaining -= 1; + self.current = if self.remaining == 0 { + self.target + } else { + self.current + self.step + }; + } + self.current + } +} + pub(super) fn num(data: &Value, key: &str) -> Option { data.get(key).and_then(Value::as_f64).map(|v| v as f32) } diff --git a/src-tauri/src/audio/effects/waveform.rs b/src-tauri/src/audio/effects/waveform.rs index e2942df..4a0a675 100644 --- a/src-tauri/src/audio/effects/waveform.rs +++ b/src-tauri/src/audio/effects/waveform.rs @@ -14,12 +14,36 @@ static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); /// ~21 ms DSP block rate; a longer tick stall overwrites the oldest frames, /// which the UI renders as a skipped span. pub const SCOPE_RING_FRAMES: usize = 16384; +/// Least time the scope ring holds, at any rate: a 33 ms tick plus a late +/// one's worth. +const SCOPE_RING_MIN_MS: usize = 85; + +/// Scope ring at `sample_rate`: `SCOPE_RING_FRAMES`, or more where that +/// would hold less than `SCOPE_RING_MIN_MS`. +fn scope_ring_frames(sample_rate: u32) -> usize { + (sample_rate as usize * SCOPE_RING_MIN_MS) + .div_ceil(1000) + .next_power_of_two() + .max(SCOPE_RING_FRAMES) +} /// Spectrum nodes need a longer contiguous window than the scope: a single -/// 1024-frame block is ~47 Hz/bin and cannot separate low tones. 4096 frames -/// (~11.7 Hz/bin) do, and one snapshot stays gap-free (concatenating separate -/// snapshots would inject discontinuities that smear across the spectrum). +/// 1024-frame block is ~47 Hz/bin at 48 kHz and cannot separate low tones. +/// 4096 frames at 48 kHz (~11.7 Hz/bin) do, and one snapshot stays gap-free +/// (concatenating separate snapshots would inject discontinuities that smear +/// across the spectrum). Higher rates get a longer window for the same +/// resolution: see `spectrum_frames`. pub const SPECTRUM_FRAMES: usize = 4096; +const SPECTRUM_REFERENCE_RATE: usize = 48_000; + +/// Spectrum window at `sample_rate`: `SPECTRUM_FRAMES` worth of 48 kHz time, +/// rounded up to a power of two for the FFT, so a bin is ~11 Hz at any rate. +pub fn spectrum_frames(sample_rate: u32) -> usize { + (SPECTRUM_FRAMES * sample_rate as usize) + .div_ceil(SPECTRUM_REFERENCE_RATE) + .next_power_of_two() + .max(1024) +} /// Upper bound on scoped channels; sizes the fixed ring so display never /// allocates on the RT path. @@ -80,14 +104,14 @@ impl WaveformHandle { /// Scope-size handle; used by the Waveform effect and by non-effect /// consumers such as the File Recording node. pub fn new(node_id: String, sample_rate: u32) -> Self { - Self::with_frames(node_id, sample_rate, SCOPE_RING_FRAMES, false) + Self::with_frames(node_id, sample_rate, scope_ring_frames(sample_rate), false) } /// Scope-size handle for one recorder worker invocation. pub fn for_recorder(node_id: String, sample_rate: u32, base_frames: u64) -> Self { Self { base_frames, - ..Self::with_frames(node_id, sample_rate, SCOPE_RING_FRAMES, false) + ..Self::with_frames(node_id, sample_rate, scope_ring_frames(sample_rate), false) } } @@ -211,7 +235,8 @@ impl WaveformEffect { /// Spectrum nodes capture identically to the scope, just over a longer /// contiguous window; the FFT runs in the UI. pub fn new_for(node_id: String, sample_rate: u32) -> (Self, WaveformHandle) { - let handle = WaveformHandle::with_frames(node_id, sample_rate, SPECTRUM_FRAMES, true); + let handle = + WaveformHandle::with_frames(node_id, sample_rate, spectrum_frames(sample_rate), true); ( Self { handle: handle.clone(), @@ -389,6 +414,23 @@ mod tests { assert_eq!(out.len(), SPECTRUM_FRAMES * 2); } + #[test] + fn the_scope_holds_a_late_tick_at_any_rate() { + for rate in [44_100, 48_000, 96_000, 192_000, 384_000] { + let ms = scope_ring_frames(rate) as f64 * 1000.0 / rate as f64; + assert!(ms >= SCOPE_RING_MIN_MS as f64, "{rate}: {ms:.0} ms"); + } + assert_eq!(scope_ring_frames(48_000), SCOPE_RING_FRAMES); + } + + #[test] + fn the_spectrum_resolves_the_same_at_any_rate() { + for rate in [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 384_000] { + let bin_hz = rate as f64 / spectrum_frames(rate) as f64; + assert!((10.0..=12.0).contains(&bin_hz), "{rate}: {bin_hz} Hz/bin"); + } + } + proptest::proptest! { #[test] fn ring_roundtrip_preserves_last_written_frames( diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index bee1712..dd5df54 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, }, } @@ -86,7 +86,7 @@ pub fn run(rx: Receiver) { let _ = reply.send(Err(AppError::AlreadyRunning)); continue; } - match pipeline::build(&graph, app) { + match pipeline::build(&graph, pipeline::Host::app(app)) { Ok(p) => { info!("pipeline built and running"); active = Some(p); @@ -111,7 +111,7 @@ pub fn run(rx: Receiver) { let _ = reply.send(Err(AppError::NotRunning)); } Some(p) => { - let r = p.reconcile(&graph, app.clone()); + let r = p.reconcile(&graph, pipeline::Host::app(app.clone())); if let Err(e) = &r { error!(error = %e, "reconcile failed, clearing active pipeline"); active = None; @@ -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 ccb7005..bbc248f 100644 --- a/src-tauri/src/audio/graph.rs +++ b/src-tauri/src/audio/graph.rs @@ -7,13 +7,30 @@ 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; +/// Pipeline sample rates the engine accepts, and the one it runs without a +/// setting. The frontend reads these from `generated/engine.ts`. +pub const MIN_SAMPLE_RATE: u32 = 8_000; +pub const MAX_SAMPLE_RATE: u32 = 384_000; +pub const DEFAULT_SAMPLE_RATE: u32 = 48_000; + #[derive(Debug, Deserialize)] pub struct NodeSpec { pub id: String, @@ -295,7 +312,7 @@ pub struct FileRecordingData { } fn default_rec_sample_rate() -> Option { - Some(48_000) + Some(DEFAULT_SAMPLE_RATE) } fn default_two() -> u16 { @@ -714,6 +731,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 @@ -729,10 +747,17 @@ struct RoleNode<'a> { /// (alphanumeric), so this can never collide with one. const RECV_SUFFIX: &str = "#recv"; -fn is_analyzer_kind(kind: NodeKind) -> bool { +/// A node audio may end at besides an output: an analyzer renders telemetry, +/// and a plugin can be an end point of its own (one that sends its input over +/// the network, say). +fn is_end_point_kind(kind: NodeKind) -> bool { matches!( kind, - NodeKind::LevelMeter | NodeKind::LufsMeter | NodeKind::Waveform | NodeKind::Spectrum + NodeKind::LevelMeter + | NodeKind::LufsMeter + | NodeKind::Waveform + | NodeKind::Spectrum + | NodeKind::Plugin ) } @@ -869,7 +894,7 @@ impl GraphSpec { let has_destination = nodes .iter() - .any(|n| n.role == NodeCategory::Output || is_analyzer_kind(n.kind)) + .any(|n| n.role == NodeCategory::Output || is_end_point_kind(n.kind)) // A collaborator holds a live peer session from the moment it // exists, so an unwired one is a destination in waiting, not a // graph error. @@ -882,7 +907,7 @@ impl GraphSpec { let reachable_from_inputs = bfs_forward(&nodes, &outgoing, NodeCategory::Input); let reachable_from_terminals: HashSet<&str> = bfs_backward_pred(&nodes, &incoming, |n| { - n.role == NodeCategory::Output || is_analyzer_kind(n.kind) + n.role == NodeCategory::Output || is_end_point_kind(n.kind) }); let routed: HashSet<&str> = reachable_from_inputs .intersection(&reachable_from_terminals) @@ -918,13 +943,23 @@ impl GraphSpec { .collect(); let sample_rate = match self.sample_rate { - Some(sr) if !(8_000..=384_000).contains(&sr) => { + Some(sr) if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sr) => { return Err(AppError::Validation(format!( - "pipeline sample rate {sr} out of bounds (8000..=384000)" + "pipeline sample rate {sr} out of bounds ({MIN_SAMPLE_RATE}..={MAX_SAMPLE_RATE})" ))); } Some(sr) => sr, - None => 48_000, + None => DEFAULT_SAMPLE_RATE, + }; + + 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 { @@ -933,6 +968,7 @@ impl GraphSpec { effects, edges, sample_rate, + buffer_frames, }) } } @@ -1089,9 +1125,9 @@ fn resolve_outputs( let max = if matches!(data.format, RecordingFormat::Flac { .. }) { 655_350 } else { - 384_000 + MAX_SAMPLE_RATE }; - if !(8000..=max).contains(&sr) { + if !(MIN_SAMPLE_RATE..=max).contains(&sr) { return Err(AppError::Validation(format!( "recording node {} pins sample rate {sr}; expected 8000..{max}", n.id @@ -1314,6 +1350,36 @@ fn check_acyclic(nodes: &[RoleNode<'_>], outgoing: &HashMap<&str, Vec<&str>>) -> mod tests { use super::*; + /// Writes the engine limits the frontend offers and validates against, + /// next to the ts-rs types, so the two sides never keep copies. + #[test] + fn export_engine_constants() { + let Ok(dir) = std::env::var("TS_RS_EXPORT_DIR") else { + return; + }; + let options = BUFFER_FRAME_OPTIONS.map(|n| n.to_string()).join(", "); + let crossovers = crate::audio::effects::eq::EQ_CROSSOVER_FREQS + .map(|f| f.to_string()) + .join(", "); + let spectrum = crate::audio::effects::waveform::SPECTRUM_FRAMES; + let silent = crate::audio::effects::lufs_meter::LUFS_SILENT; + let max_delay = crate::audio::effects::delay::MAX_DELAY_MS; + let ts = format!( + "// Generated from src-tauri/src/audio/graph.rs by `bun run generate`. Do not edit this file manually.\n\n\ + export const BUFFER_FRAME_OPTIONS = [{options}] as const;\n\ + export const DEFAULT_BUFFER_FRAMES = {DEFAULT_BUFFER_FRAMES};\n\ + export const MAX_BUFFER_FRAMES = {MAX_BUFFER_FRAMES};\n\ + export const MIN_SAMPLE_RATE = {MIN_SAMPLE_RATE};\n\ + export const MAX_SAMPLE_RATE = {MAX_SAMPLE_RATE};\n\ + export const DEFAULT_SAMPLE_RATE = {DEFAULT_SAMPLE_RATE};\n\ + export const EQ_CROSSOVER_FREQS = [{crossovers}] as const;\n\ + export const SPECTRUM_FRAMES = {spectrum};\n\ + export const LUFS_SILENT = {silent};\n\ + export const MAX_DELAY_MS = {max_delay};\n" + ); + std::fs::write(std::path::Path::new(&dir).join("engine.ts"), ts).expect("write engine.ts"); + } + fn node(id: &str, kind: NodeKind, data: serde_json::Value) -> NodeSpec { NodeSpec { id: id.to_string(), @@ -1370,6 +1436,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 +1460,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 +1482,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 +1500,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 +1515,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 +1533,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 +1552,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 +1560,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 +1634,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 +1646,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 +1657,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 +1666,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 +1681,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 +1693,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 +1707,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 +1719,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 +1738,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 +1752,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 +1767,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 +1782,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 +1795,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 +1823,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 +1846,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 +1868,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 +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)], }; @@ -1765,6 +1911,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 +1934,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 +1957,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 +1978,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 +2006,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 +2025,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 +2047,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 e344476..352d8e7 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,19 +27,25 @@ 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, - /// Input samples discarded by a source's backlog trim (SourceState::fill_block). - SOURCE_TRIM_DROPPED_SAMPLES, + /// Speaker blocks the output resampler failed to convert; the device + /// played silence for them (pipeline/output/render.rs). + SPEAKER_RENDER_FAILED_BLOCKS, /// Samples dropped by a StagingRing overrun (producer outran the drain). STAGING_OVERRUN_SAMPLES, /// Worker blocks whose wall-clock deadline had already passed on wake. CLOCK_LATE_BLOCKS, /// Worst single deadline miss, microseconds (monotonic high-water mark). CLOCK_LATE_MAX_US, + /// Times a speaker stopped calling back for several of its periods (a + /// device overload) and its sources realigned under the dropout. + OUTPUT_STALLS, /// 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, @@ -50,6 +54,9 @@ counters! { OFFLOAD_RESYNC_DROPPED_SAMPLES, /// Samples dropped pushing into an offload ring (either direction). OFFLOAD_RING_OVERRUN_SAMPLES, + /// Rendered samples a recorder dropped because its file writer fell + /// further behind than its queue holds. + RECORDER_OVERRUN_SAMPLES, } /// Name of the high-water-mark counter, whose delta is meaningless. @@ -88,19 +95,20 @@ mod tests { assert_eq!( names, [ - "OUTPUT_UNDERRUN_SAMPLES", "CAPTURE_RING_OVERRUN_SAMPLES", "NET_RING_OVERRUN_SAMPLES", "TAP_RING_OVERRUN_SAMPLES", - "SPEAKER_RING_OVERRUN_SAMPLES", - "SOURCE_TRIM_DROPPED_SAMPLES", + "SPEAKER_RENDER_FAILED_BLOCKS", "STAGING_OVERRUN_SAMPLES", "CLOCK_LATE_BLOCKS", "CLOCK_LATE_MAX_US", + "OUTPUT_STALLS", "STREAM_ERRORS", + "DEVICE_XRUNS", "OFFLOAD_STARVED_SAMPLES", "OFFLOAD_RESYNC_DROPPED_SAMPLES", "OFFLOAD_RING_OVERRUN_SAMPLES", + "RECORDER_OVERRUN_SAMPLES", ] ); } diff --git a/src-tauri/src/audio/input_bridge.rs b/src-tauri/src/audio/input_bridge.rs index f382a3b..cfd1232 100644 --- a/src-tauri/src/audio/input_bridge.rs +++ b/src-tauri/src/audio/input_bridge.rs @@ -26,6 +26,7 @@ use rtrb::{Consumer, Producer, RingBuffer}; use crate::audio::health; use crate::audio::streams::bulk_push_counted; +use crate::audio::wake::Doorbell; use crate::error::{AppError, AppResult}; /// Maximum subscribers per input. 32 covers any plausible pipeline (each @@ -73,6 +74,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 +121,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 +130,13 @@ pub struct BroadcastRx { cmds: Consumer, slots: Vec>, discarded_tx: Producer, + clock: Arc, + /// Rung after every broadcast, for a thread that waits on this bridge. + bell: Option>, } 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 +150,24 @@ 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, + bell: None, }, ) } 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 +252,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); @@ -211,6 +263,36 @@ impl BroadcastRx { } } } + if let Some(bell) = &self.bell { + bell.ring(); + } + } + + /// Wakes whoever answers `bell` after every broadcast. + pub fn ring_after_broadcast(&mut self, bell: Arc) { + self.bell = Some(bell); + } + + /// Samples queued in the emptiest active slot: the reader furthest along, + /// or `None` when nothing is subscribed. + pub fn min_queued(&mut self) -> Option { + self.apply_commands(); + self.slots + .iter() + .filter_map(|s| s.as_ref()) + .filter(|(p, _)| !p.is_abandoned()) + .map(|(p, _)| p.buffer().capacity() - p.slots()) + .min() + } + + /// Subscribers still reading. + pub fn active_consumers(&mut self) -> usize { + self.apply_commands(); + self.slots + .iter() + .filter_map(|s| s.as_ref()) + .filter(|(p, _)| !p.is_abandoned()) + .count() } /// Samples queued in the fullest active slot, or `None` when nothing is @@ -284,6 +366,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 +394,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); @@ -319,12 +414,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!(crate::audio::streams::bulk_pop(&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] @@ -382,7 +504,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 +517,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 deddf48..268e188 100644 --- a/src-tauri/src/audio/macos_hal.rs +++ b/src-tauri/src/audio/macos_hal.rs @@ -792,3 +792,348 @@ 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, + ) + } +} + +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 { + 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), + }) + } +} + +/// A stream's format as the hardware runs it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PhysicalFormat { + pub sample_rate: f64, + pub bits: u32, + pub channels: u32, + pub float: bool, +} + +/// The physical format of each of the device's streams in `kind`'s direction. +pub fn physical_formats(kind: crate::audio::device::DeviceKind, name: &str) -> Vec { + use objc2_core_audio::{ + kAudioDevicePropertyStreams, kAudioObjectPropertyElementMain, + kAudioObjectPropertyScopeGlobal, kAudioStreamPropertyPhysicalFormat, + AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, AudioObjectPropertyAddress, + }; + use objc2_core_audio_types::{kAudioFormatFlagIsFloat, AudioStreamBasicDescription}; + use std::ptr::NonNull; + + let scope = scope_for(kind); + let Some(device) = find_device_id(name, scope) else { + return Vec::new(); + }; + let streams_addr = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyStreams, + mScope: scope, + mElement: kAudioObjectPropertyElementMain, + }; + let mut size = 0u32; + // SAFETY: plain property reads into buffers sized from the reported size. + unsafe { + if AudioObjectGetPropertyDataSize( + device, + NonNull::from(&streams_addr), + 0, + ptr::null(), + NonNull::from(&mut size), + ) != 0 + { + return Vec::new(); + } + let mut streams = vec![0u32; size as usize / mem::size_of::()]; + if streams.is_empty() + || AudioObjectGetPropertyData( + device, + NonNull::from(&streams_addr), + 0, + ptr::null(), + NonNull::from(&mut size), + NonNull::new_unchecked(streams.as_mut_ptr().cast()), + ) != 0 + { + return Vec::new(); + } + let format_addr = AudioObjectPropertyAddress { + mSelector: kAudioStreamPropertyPhysicalFormat, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + streams + .iter() + .filter_map(|&stream| { + let mut asbd: AudioStreamBasicDescription = mem::zeroed(); + let mut size = mem::size_of::() as u32; + let status = AudioObjectGetPropertyData( + stream, + NonNull::from(&format_addr), + 0, + ptr::null(), + NonNull::from(&mut size), + NonNull::from(&mut asbd).cast(), + ); + (status == 0).then_some(PhysicalFormat { + sample_rate: asbd.mSampleRate, + bits: asbd.mBitsPerChannel, + channels: asbd.mChannelsPerFrame, + float: asbd.mFormatFlags & kAudioFormatFlagIsFloat != 0, + }) + }) + .collect() + } +} + +/// cpal sets a device's physical format when it opens a stream on it, for +/// every app, and never sets it back. Call with what the device ran before +/// opening; says so when opening changed it. +pub fn warn_if_physical_format_changed( + kind: crate::audio::device::DeviceKind, + name: &str, + before: &[PhysicalFormat], +) { + let after = physical_formats(kind, name); + if after != before { + tracing::warn!( + device = %name, + ?before, + ?after, + "opening the stream changed the device's physical format; it stays changed for every app" + ); + } +} + +#[cfg(test)] +mod io_tests { + use super::*; + use crate::audio::device::DeviceKind; + + #[test] + #[ignore = "needs a real output device"] + fn physical_formats_are_read() { + let dev = list_output_devices() + .into_iter() + .next() + .expect("an output device"); + let formats = physical_formats(DeviceKind::Output, &dev.name); + println!("{}: {formats:?}", dev.name); + assert!(!formats.is_empty(), "no stream format read"); + assert!(formats + .iter() + .all(|f| f.sample_rate > 0.0 && f.channels > 0)); + } + + #[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/mod.rs b/src-tauri/src/audio/mod.rs index 5328ef5..59a8de7 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -1,6 +1,8 @@ +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; @@ -18,11 +20,14 @@ 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; pub mod system_audio; pub mod virtual_device; pub mod volume; +pub mod wake; pub mod webrtc; pub mod webrtc_codec; diff --git a/src-tauri/src/audio/netaudio/codec.rs b/src-tauri/src/audio/netaudio/codec.rs index d28e65a..9f5d513 100644 --- a/src-tauri/src/audio/netaudio/codec.rs +++ b/src-tauri/src/audio/netaudio/codec.rs @@ -3,31 +3,67 @@ use tracing::warn; +use crate::audio::graph::OpusApplication; + use super::packet::{ pcm_f32_decode, pcm_f32_encode, pcm_i16_decode, pcm_i16_encode, Format, MAX_PAYLOAD, }; -use super::{OPUS_FRAME_SAMPLES, SR}; +use super::{OPUS_FRAME_SIZES, OPUS_MAX_PACKET_SAMPLES, SR}; -/// Samples one packet of `format` carries. Fixed per format, which is what -/// makes a packet's `seq` a position on the source's timeline. -pub fn chunk_samples(format: Format) -> usize { +/// Samples one packet of `format` carries when the sender runs +/// `block_frames` blocks: one block per packet, so a block goes out as soon +/// as it exists. PCM is capped by the datagram size; Opus takes the largest +/// frame it can encode that fits in a block, 2.5 ms at the least. Fixed for +/// the stream, which is what makes a packet's `seq` a position on the +/// source's timeline. +pub fn packet_samples(format: Format, block_frames: usize) -> usize { let bytes = match format { Format::PcmF32 => 4, Format::PcmI16 => 2, - Format::Opus => return OPUS_FRAME_SAMPLES, + Format::Opus => { + return OPUS_FRAME_SIZES + .into_iter() + .rev() + .find(|&f| f <= block_frames) + .unwrap_or(OPUS_FRAME_SIZES[0]); + } }; - ((MAX_PAYLOAD / bytes) & !1).max(2) + block_frames.clamp(1, MAX_PAYLOAD / bytes) +} + +/// The codec's own name for an application setting. +pub fn opus_application(app: OpusApplication) -> opus::Application { + match app { + OpusApplication::Voip => opus::Application::Voip, + OpusApplication::Audio => opus::Application::Audio, + OpusApplication::LowDelay => opus::Application::LowDelay, + } +} + +/// How the packet header names an application setting. +pub fn opus_application_byte(app: OpusApplication) -> u8 { + match app { + OpusApplication::Voip => 1, + OpusApplication::Audio => 2, + OpusApplication::LowDelay => 3, + } } pub struct ChannelEncoder { format: Format, + chunk: usize, opus: Option, acc: Vec, scratch: Vec, } impl ChannelEncoder { - pub fn new(format: Format, bitrate: u32, application: opus::Application) -> Self { + pub fn new( + format: Format, + bitrate: u32, + application: opus::Application, + block_frames: usize, + ) -> Self { let opus = if format == Format::Opus { match opus::Encoder::new(SR, opus::Channels::Mono, application) { Ok(mut e) => { @@ -46,6 +82,7 @@ impl ChannelEncoder { }; Self { format, + chunk: packet_samples(format, block_frames), opus, acc: Vec::new(), scratch: vec![0u8; 4096], @@ -56,7 +93,7 @@ impl ChannelEncoder { /// payload (packet body, without header). pub fn push(&mut self, samples: &[f32], mut emit: impl FnMut(&[u8])) { self.acc.extend_from_slice(samples); - let chunk = chunk_samples(self.format); + let chunk = self.chunk; let mut off = 0; while self.acc.len() - off >= chunk { let frame = &self.acc[off..off + chunk]; @@ -87,9 +124,9 @@ impl ChannelEncoder { pub struct ChannelDecoder { opus: Option, pcm: Vec, - // Samples the last packet decoded to. The sender's MTU (and so its PCM - // chunk) need not match ours, so concealment follows what this stream - // actually carries rather than what we would have sent. + // Samples the last packet carried. The sender's block (and so its packet + // size) need not match ours, so concealment follows what this stream + // actually carries; nothing is owed before the first packet says. last_chunk: usize, } @@ -100,7 +137,7 @@ impl ChannelDecoder { .ok(); Self { opus, - pcm: vec![0.0; OPUS_FRAME_SAMPLES], + pcm: vec![0.0; OPUS_MAX_PACKET_SAMPLES], last_chunk: 0, } } @@ -110,11 +147,13 @@ impl ChannelDecoder { /// Opus extrapolates from decoder state; raw PCM has no codec PLC and gets /// silence (the playback side fades across the join). pub fn conceal_packets(&mut self, format: Format, packets: u16, out: &mut Vec) { - let want = out.len() + self.chunk(format) * packets as usize; - if format == Format::Opus { + let chunk = self.last_chunk; + let want = out.len() + chunk * packets as usize; + if format == Format::Opus && chunk > 0 { for _ in 0..packets { let Some(dec) = self.opus.as_mut() else { break }; - match dec.decode_float(&[], &mut self.pcm, false) { + // A lost packet's length is the buffer handed in. + match dec.decode_float(&[], &mut self.pcm[..chunk], false) { Ok(n) => out.extend_from_slice(&self.pcm[..n]), Err(e) => { warn!(error = %e, "opus conceal failed"); @@ -127,12 +166,9 @@ impl ChannelDecoder { out.resize(want.max(out.len()), 0.0); } - fn chunk(&self, format: Format) -> usize { - if self.last_chunk > 0 { - self.last_chunk - } else { - chunk_samples(format) - } + /// Samples each packet of this stream carries; 0 until one has said. + pub fn packet_samples(&self) -> usize { + self.last_chunk } /// Decodes one payload into 48 kHz interleaved samples appended to `out`. @@ -141,6 +177,11 @@ impl ChannelDecoder { match format { Format::Opus => { if let Some(dec) = self.opus.as_mut() { + // Read from the packet itself, so a packet that fails to + // decode still says what it owes the timeline. + if let Ok(n) = dec.get_nb_samples(payload) { + self.last_chunk = n; + } match dec.decode_float(payload, &mut self.pcm, false) { Ok(n) => out.extend_from_slice(&self.pcm[..n]), Err(e) => warn!(error = %e, "opus decode failed"), @@ -152,7 +193,7 @@ impl ChannelDecoder { } match out.len() - before { // A packet the codec rejected still owes the timeline its samples. - 0 => out.resize(before + self.chunk(format), 0.0), + 0 => out.resize(before + self.last_chunk, 0.0), n => self.last_chunk = n, } } @@ -173,19 +214,25 @@ mod tests { (sink, emit) } + const BLOCK: usize = 256; + const OPUS_FRAME: usize = 960; + #[test] - fn chunk_samples_are_even_and_positive() { - assert!(chunk_samples(Format::PcmF32) % 2 == 0); - assert!(chunk_samples(Format::PcmI16) % 2 == 0); - assert_eq!(chunk_samples(Format::Opus), OPUS_FRAME_SAMPLES); + fn a_packet_carries_one_engine_block() { + assert_eq!(packet_samples(Format::PcmF32, 32), 32); + assert_eq!(packet_samples(Format::PcmI16, 256), 256); + assert_eq!(packet_samples(Format::PcmF32, 2_048), MAX_PAYLOAD / 4); + assert_eq!(packet_samples(Format::Opus, 32), 120); + assert_eq!(packet_samples(Format::Opus, 256), 240); + assert_eq!(packet_samples(Format::Opus, 1_024), 960); } #[test] fn pcm_encoder_emits_fixed_size_payloads() { let (sink, emit) = collect(); - let mut enc = ChannelEncoder::new(Format::PcmF32, 96_000, opus::Application::Audio); + let mut enc = ChannelEncoder::new(Format::PcmF32, 96_000, opus::Application::Audio, BLOCK); // One chunk plus a little slack: exactly one full packet leaves. - let chunk = chunk_samples(Format::PcmF32); + let chunk = packet_samples(Format::PcmF32, BLOCK); enc.push(&vec![0.25f32; chunk + 3], emit); let packets = sink.borrow(); assert_eq!(packets.len(), 1, "one full chunk leaves the accumulator"); @@ -195,8 +242,8 @@ mod tests { #[test] fn pcm_i16_encoder_roundtrips_through_decoder() { let (sink, emit) = collect(); - let mut enc = ChannelEncoder::new(Format::PcmI16, 0, opus::Application::Audio); - let chunk = chunk_samples(Format::PcmI16); + let mut enc = ChannelEncoder::new(Format::PcmI16, 0, opus::Application::Audio, BLOCK); + let chunk = packet_samples(Format::PcmI16, BLOCK); let input: Vec = (0..chunk) .map(|i| if i % 2 == 0 { 0.5 } else { -0.25 }) .collect(); @@ -214,9 +261,10 @@ mod tests { #[test] fn opus_encoder_decoder_roundtrip() { let (sink, emit) = collect(); - let mut enc = ChannelEncoder::new(Format::Opus, 96_000, opus::Application::Audio); + let mut enc = + ChannelEncoder::new(Format::Opus, 96_000, opus::Application::Audio, OPUS_FRAME); // Feed 4 chunks of a sine; each becomes one opus packet. - let input: Vec = (0..OPUS_FRAME_SAMPLES * 3) + let input: Vec = (0..OPUS_FRAME * 3) .map(|i| 0.5 * (i as f32 * 440.0 * std::f32::consts::TAU / SR as f32).sin()) .collect(); enc.push(&input, emit); @@ -236,22 +284,43 @@ mod tests { #[test] fn conceal_owes_exactly_the_lost_timeline() { let mut dec = ChannelDecoder::new(); - let mut out = vec![1.0f32; 10]; + let mut out = Vec::new(); + dec.conceal_packets(Format::PcmF32, 3, &mut out); + assert!( + out.is_empty(), + "nothing is owed before a packet says its size" + ); + let mut payload = Vec::new(); + pcm_f32_encode(&[1.0; 100], &mut payload); + dec.decode(Format::PcmF32, &payload, &mut out); dec.conceal_packets(Format::PcmF32, 3, &mut out); - let chunk = chunk_samples(Format::PcmF32); // PCM concealment is silence, but the timeline keeps its place. - assert_eq!(out.len(), 10 + 3 * chunk); + assert_eq!(out.len(), 100 + 3 * 100); assert_eq!(out[0], 1.0, "existing content untouched"); assert_eq!(out[out.len() - 1], 0.0); } + #[test] + fn a_short_opus_frame_is_concealed_at_its_own_length() { + let (sink, emit) = collect(); + let mut enc = ChannelEncoder::new(Format::Opus, 96_000, opus::Application::Audio, 32); + enc.push(&[0.1; 120], emit); + let mut dec = ChannelDecoder::new(); + let mut out = Vec::new(); + dec.decode(Format::Opus, &sink.borrow()[0], &mut out); + assert_eq!(out.len(), 120); + dec.conceal_packets(Format::Opus, 2, &mut out); + assert_eq!(out.len(), 3 * 120); + } + #[test] fn conceal_opus_produces_extrapolation() { // Encode a tone, decode one packet, then conceal: opus PLC must // return something non-silent. let (sink, emit) = collect(); - let mut enc = ChannelEncoder::new(Format::Opus, 96_000, opus::Application::Audio); - let input: Vec = (0..OPUS_FRAME_SAMPLES * 2) + let mut enc = + ChannelEncoder::new(Format::Opus, 96_000, opus::Application::Audio, OPUS_FRAME); + let input: Vec = (0..OPUS_FRAME * 2) .map(|i| 0.5 * (i as f32 * 440.0 * std::f32::consts::TAU / SR as f32).sin()) .collect(); enc.push(&input, emit); @@ -260,7 +329,7 @@ mod tests { dec.decode(Format::Opus, &sink.borrow()[0], &mut out); let before = out.len(); dec.conceal_packets(Format::Opus, 2, &mut out); - assert_eq!(out.len(), before + 2 * OPUS_FRAME_SAMPLES); + assert_eq!(out.len(), before + 2 * OPUS_FRAME); assert!(out.iter().all(|s| s.is_finite())); assert!( out[before..].iter().any(|s| s.abs() > 1e-4), diff --git a/src-tauri/src/audio/netaudio/mod.rs b/src-tauri/src/audio/netaudio/mod.rs index ebfed95..acf07dd 100644 --- a/src-tauri/src/audio/netaudio/mod.rs +++ b/src-tauri/src/audio/netaudio/mod.rs @@ -10,8 +10,11 @@ pub mod sender; pub mod timeline; pub const SR: u32 = 48_000; -/// One 20 ms Opus frame; a net channel is mono, so frames == samples. -pub const OPUS_FRAME_SAMPLES: usize = 960; +/// Frame sizes Opus encodes at 48 kHz (2.5 to 60 ms); a net channel is mono, +/// so frames == samples. +pub const OPUS_FRAME_SIZES: [usize; 6] = [120, 240, 480, 960, 1_920, 2_880]; +/// Longest Opus packet (120 ms at 48 kHz): what one decode may produce. +pub const OPUS_MAX_PACKET_SAMPLES: usize = 5_760; /// Channel index rides in one packet header byte (`packet.rs`). pub const MAX_CHANNELS: usize = 255; /// Per-channel send ring feeding the UDP task (~2 s mono at 48 kHz). diff --git a/src-tauri/src/audio/netaudio/receiver.rs b/src-tauri/src/audio/netaudio/receiver.rs index 21272dc..af7e55f 100644 --- a/src-tauri/src/audio/netaudio/receiver.rs +++ b/src-tauri/src/audio/netaudio/receiver.rs @@ -3,17 +3,27 @@ //! decoded to 48 kHz and fanned out to every output subgraph via `FanoutRegistry`. use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::net::UdpSocket; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::JoinHandle; +use std::time::Duration; -use tokio::net::UdpSocket; use tracing::{info, warn}; use crate::audio::stream_recv::{ChannelBroadcast, ConsumerHandle, FanoutRegistry}; use super::codec::ChannelDecoder; use super::packet; -use super::timeline::{ChannelTimeline, SeqStep}; +use super::timeline::{max_gap_packets, ChannelTimeline, SeqStep}; + +/// Pause after a failed receive, so an error that repeats cannot spin the +/// loop; short enough that a one-off costs the next packet next to nothing. +const RECV_ERROR_PAUSE: Duration = Duration::from_millis(1); + +/// Longest the receive thread blocks before checking whether it was stopped; +/// a stop waits this long at most before the port is free again. +const RECV_POLL: Duration = Duration::from_millis(20); struct ChannelState { decoder: Mutex, @@ -26,7 +36,8 @@ pub struct NetReceiver { port: u16, fanout: FanoutRegistry, channels: Mutex>>, - task: Mutex>>, + stopped: AtomicBool, + thread: Mutex>>, bytes: AtomicU64, packets: AtomicU64, lost: AtomicU64, @@ -133,7 +144,8 @@ pub fn get_or_create(node_id: &str, port: u16) -> Arc { port, fanout: FanoutRegistry::default(), channels: Mutex::new(HashMap::new()), - task: Mutex::new(None), + stopped: AtomicBool::new(false), + thread: Mutex::new(None), bytes: AtomicU64::new(0), packets: AtomicU64::new(0), lost: AtomicU64::new(0), @@ -149,37 +161,74 @@ 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) } + /// Stops the receive thread and waits for it, so the port is free for + /// whatever binds it next. fn stop(&self) { - if let Some(t) = self.task.lock().unwrap().take() { - t.abort(); + self.stopped.store(true, Ordering::Relaxed); + if let Some(t) = self.thread.lock().unwrap().take() { + let _ = t.join(); } self.fanout.clear(); } fn spawn_recv(self: Arc) { - let handle = tauri::async_runtime::spawn(self.clone().recv_loop()); - *self.task.lock().unwrap() = Some(handle); + let receiver = self.clone(); + match std::thread::Builder::new() + .name("net-recv".into()) + .spawn(move || receiver.recv_loop()) + { + Ok(handle) => *self.thread.lock().unwrap() = Some(handle), + Err(e) => warn!(error = %e, "net receiver thread failed to start"), + } } - async fn recv_loop(self: Arc) { - let socket = match UdpSocket::bind(("0.0.0.0", self.port)).await { + fn recv_loop(&self) { + let socket = match UdpSocket::bind(("0.0.0.0", self.port)) { Ok(s) => s, Err(e) => { warn!(port = self.port, error = %e, "net receiver bind failed"); return; } }; + if let Err(e) = socket.set_read_timeout(Some(RECV_POLL)) { + warn!(port = self.port, error = %e, "net receiver read timeout failed"); + return; + } info!(port = self.port, "net receiver listening"); + // Every packet goes from here into the rings the speakers read: one + // that waits behind ordinary threads is a gap they play. Scheduled + // as for a 20 ms packet, the usual Opus frame. + let _rt = crate::audio::pipeline::RtThread::promote("net-recv", super::SR / 50, super::SR); let mut buf = vec![0u8; 2048]; let mut pcm: Vec = Vec::new(); - loop { - let n = match socket.recv_from(&mut buf).await { + while !self.stopped.load(Ordering::Relaxed) { + let n = match socket.recv_from(&mut buf) { Ok((n, _)) => n, - Err(_) => continue, + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue; + } + // A datagram-level error (an ICMP reset from a stale peer) + // passes; one that repeats would spin the loop, so it is paced. + Err(e) => { + tracing::debug!(port = self.port, error = %e, "net receiver recv failed"); + std::thread::sleep(RECV_ERROR_PAUSE); + continue; + } }; let Some(pkt) = packet::parse(&buf[..n]) else { continue; @@ -196,8 +245,12 @@ impl NetReceiver { if let Some(app) = pkt.opus_app { self.opus_app.store(app as u32, Ordering::Relaxed); } - let channel = self.channel(pkt.channel, pkt.seq); - let step = channel.timeline.lock().unwrap().step(pkt.seq); + let channel = self.channel(pkt.channel, pkt.seq, pkt.sample_rate); + let max_gap = max_gap_packets( + pkt.sample_rate, + channel.decoder.lock().unwrap().packet_samples(), + ); + let step = channel.timeline.lock().unwrap().step(pkt.seq, max_gap); match step { SeqStep::Drop => continue, // The break is longer than concealment covers, so this channel @@ -243,12 +296,14 @@ impl NetReceiver { /// Receive state for a channel index, created (and wired to consumers) on /// its first packet. - fn channel(&self, index: u8, first_seq: u16) -> Arc { + fn channel(&self, index: u8, first_seq: u16, sample_rate: u32) -> Arc { let mut channels = self.channels.lock().unwrap(); if let Some(c) = channels.get(&index) { return c.clone(); } - let broadcast = self.fanout.attach_channel(index.to_string(), first_seq); + let broadcast = self + .fanout + .attach_channel(index.to_string(), first_seq, sample_rate); let state = Arc::new(ChannelState { decoder: Mutex::new(ChannelDecoder::new()), broadcast, @@ -315,9 +370,9 @@ 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); + let recv = crate::audio::stream_recv::ChannelReceiver::new(consumer, Arc::default()); let sender = UdpSocket::bind("127.0.0.1:0").map_err(|e| format!("client bind: {e}"))?; let target = format!("127.0.0.1:{port}"); diff --git a/src-tauri/src/audio/netaudio/sender.rs b/src-tauri/src/audio/netaudio/sender.rs index 1755774..235c6cd 100644 --- a/src-tauri/src/audio/netaudio/sender.rs +++ b/src-tauri/src/audio/netaudio/sender.rs @@ -1,24 +1,34 @@ //! UDP audio sender. One instance per NetSender node (keyed by node id) owns a -//! send socket and a background task that drains per-channel send rings, encodes -//! each channel (Opus or raw PCM) and transmits it to the configured target as -//! self-describing packets. The DAG runs a NetSender output at 48 kHz, so the -//! task encodes the drained samples directly with no resample. +//! send socket and a thread that drains per-channel send rings, encodes each +//! channel (Opus or raw PCM) and transmits it to the configured target as +//! self-describing packets. The DAG rings the thread every block it leaves in +//! the rings, so audio goes out as soon as it exists rather than on a timer. +//! The DAG runs a NetSender output at 48 kHz, so the thread encodes the drained +//! samples directly with no resample. use std::collections::HashMap; use std::net::SocketAddr; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::net::UdpSocket; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use rtrb::Consumer; -use tokio::net::UdpSocket; use tracing::{info, warn}; use crate::audio::graph::OpusApplication; +use crate::audio::wake::Doorbell; -use super::codec::ChannelEncoder; +use super::codec::{opus_application, opus_application_byte, ChannelEncoder}; use super::packet::{self, Format}; +/// Longest the send thread sleeps without being rung. Out of reach under +/// test, so whatever a test receives was rung. +#[cfg(not(test))] +const IDLE_WAKE: Duration = Duration::from_millis(20); +#[cfg(test)] +const IDLE_WAKE: Duration = Duration::from_secs(30); + /// Immutable config; a change (target, codec, bitrate, application) rebuilds the /// sender so the encoder and socket are recreated cleanly. #[derive(Clone, PartialEq, Eq)] @@ -28,6 +38,8 @@ struct Config { opus_bitrate: u32, opus_application: OpusApplication, sample_rate: u32, + /// The DAG's block at `sample_rate`; one packet carries one block. + block_frames: usize, } pub struct NetSender { @@ -36,7 +48,9 @@ pub struct NetSender { /// Bumped whenever the send rings are replaced, so the task rebuilds every /// channel's encode state together. consumers_gen: Arc, - task: Mutex>>, + /// Rung by the DAG after each block it pushes. + bell: Arc, + stopped: Arc, bytes: Arc, packets: Arc, } @@ -67,7 +81,7 @@ pub fn release(node_id: &str) { } /// Returns the sender for `node_id`, binding the send socket on first use. A -/// config change (target / codec / bitrate / sample rate) tears the old task down and rebuilds. +/// config change (target / codec / bitrate / sample rate / block) tears the old task down and rebuilds. pub fn get_or_create( node_id: &str, target: SocketAddr, @@ -75,6 +89,7 @@ pub fn get_or_create( opus_bitrate: u32, opus_application: OpusApplication, sample_rate: u32, + block_frames: usize, ) -> Arc { let config = Config { target, @@ -82,6 +97,7 @@ pub fn get_or_create( opus_bitrate, opus_application, sample_rate, + block_frames, }; let mut reg = registry().lock().unwrap(); if let Some(s) = reg.get(node_id) { @@ -95,7 +111,8 @@ pub fn get_or_create( config, send_consumers: Arc::new(Mutex::new(Vec::new())), consumers_gen: Arc::new(AtomicU64::new(0)), - task: Mutex::new(None), + bell: Arc::default(), + stopped: Arc::new(AtomicBool::new(false)), bytes: Arc::new(AtomicU64::new(0)), packets: Arc::new(AtomicU64::new(0)), }); @@ -112,19 +129,35 @@ impl NetSender { self.consumers_gen.fetch_add(1, Ordering::SeqCst); } + /// What the DAG rings once it has pushed a block into the send rings. + pub fn bell(&self) -> Arc { + self.bell.clone() + } + fn stop(&self) { - if let Some(t) = self.task.lock().unwrap().take() { - t.abort(); - } + self.stopped.store(true, Ordering::SeqCst); + self.bell.ring(); } fn spawn_send(self: Arc) { - let handle = tauri::async_runtime::spawn(self.clone().send_loop()); - *self.task.lock().unwrap() = Some(handle); + if let Err(e) = std::thread::Builder::new() + .name("net-send".into()) + .spawn(move || self.send_loop()) + { + warn!(error = %e, "net sender thread failed to start"); + } } - async fn send_loop(self: Arc) { - let socket = match UdpSocket::bind(("0.0.0.0", 0)).await { + fn send_loop(&self) { + self.bell.answer_here(); + // Each block the graph makes leaves from here: a send that waits + // behind ordinary threads is a gap at the receiver. + let _rt = crate::audio::pipeline::RtThread::promote( + "net-send", + self.config.block_frames as u32, + self.config.sample_rate, + ); + let socket = match UdpSocket::bind(("0.0.0.0", 0)) { Ok(s) => s, Err(e) => { warn!(error = %e, "net sender bind failed"); @@ -132,11 +165,8 @@ impl NetSender { } }; let target = self.config.target; - let application = match self.config.opus_application { - OpusApplication::Voip => opus::Application::Voip, - OpusApplication::Audio => opus::Application::Audio, - OpusApplication::LowDelay => opus::Application::LowDelay, - }; + let application = opus_application(self.config.opus_application); + let block_frames = self.config.block_frames; info!(%target, "net sender started"); let consumers = self.send_consumers.clone(); @@ -148,11 +178,11 @@ impl NetSender { let mut encoders: Vec = Vec::new(); let mut ins: Vec> = Vec::new(); let mut seqs: Vec = Vec::new(); - let mut interval = tokio::time::interval(Duration::from_millis(20)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - interval.tick().await; + while !self.stopped.load(Ordering::SeqCst) { + // Woken by each block the DAG pushes; the timeout only matters if + // the DAG stops. + self.bell.wait(IDLE_WAKE); // Drain each channel's send ring under the lock, then release it // before the encode / send work. @@ -170,7 +200,12 @@ impl NetSender { } let n = cons.len(); while encoders.len() < n { - encoders.push(ChannelEncoder::new(format, bitrate, application)); + encoders.push(ChannelEncoder::new( + format, + bitrate, + application, + block_frames, + )); ins.push(Vec::new()); seqs.push(0); } @@ -194,12 +229,10 @@ impl NetSender { let mut packets: Vec> = Vec::new(); let sample_rate = self.config.sample_rate; let (opus_bitrate_kbps, opus_app_byte) = if format == Format::Opus { - let app = match self.config.opus_application { - OpusApplication::Voip => 1, - OpusApplication::Audio => 2, - OpusApplication::LowDelay => 3, - }; - ((self.config.opus_bitrate / 1000) as u16, app) + ( + (self.config.opus_bitrate / 1000) as u16, + opus_application_byte(self.config.opus_application), + ) } else { (0, 0) }; @@ -223,7 +256,7 @@ impl NetSender { }); } for p in &packets { - match socket.send_to(p, target).await { + match socket.send_to(p, target) { Ok(_) => { self.bytes.fetch_add(p.len() as u64, Ordering::Relaxed); self.packets.fetch_add(1, Ordering::Relaxed); @@ -253,6 +286,7 @@ mod tests { 0, OpusApplication::Audio, 48_000, + 256, ); let mut consumers = Vec::new(); @@ -264,6 +298,7 @@ mod tests { consumers.push(cons); } sender.set_send_consumers(consumers); + sender.bell().ring(); let mut seqs: BTreeMap> = BTreeMap::new(); let mut buf = [0u8; 2048]; @@ -291,4 +326,34 @@ mod tests { } assert!(stats("test-sender").is_none(), "release frees the node"); } + + #[test] + fn a_ring_sends_at_once_instead_of_on_the_idle_tick() { + let sink = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind sink"); + let sender = get_or_create( + "test-rung-sender", + sink.local_addr().expect("sink address"), + Format::PcmF32, + 0, + OpusApplication::Audio, + 48_000, + 256, + ); + sink.set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + let (mut prod, cons) = rtrb::RingBuffer::new(9_600); + sender.set_send_consumers(vec![cons]); + let bell = sender.bell(); + let mut buf = [0u8; 2048]; + // The idle tick is out of reach here: each packet came from a ring. + for block in 0..5 { + prod.write_chunk_uninit(256) + .expect("ring space") + .fill_from_iter(std::iter::repeat(0.5)); + bell.ring(); + sink.recv(&mut buf) + .unwrap_or_else(|e| panic!("block {block}: the ring did not send it ({e})")); + } + release("test-rung-sender"); + } } diff --git a/src-tauri/src/audio/netaudio/timeline.rs b/src-tauri/src/audio/netaudio/timeline.rs index d419d15..77cb7cb 100644 --- a/src-tauri/src/audio/netaudio/timeline.rs +++ b/src-tauri/src/audio/netaudio/timeline.rs @@ -10,7 +10,7 @@ /// Losses beyond this are an outage, not a gap: filling them would inject /// seconds of concealment, so the jitter buffer re-primes the source instead. -const MAX_GAP_PACKETS: u16 = 50; +const MAX_GAP_MS: u64 = 1_000; /// Consecutive rejected packets that mean the sender restarted its counter /// rather than the network reordering a few packets. const RESTART_RUN: u32 = 25; @@ -25,6 +25,17 @@ pub enum SeqStep { Drop, } +/// Most packets `step` fills for a stream of `packet_samples`-sample packets +/// at `sample_rate`. Before a packet has said its size nothing is owed for a +/// loss, so no gap is too long to fill. +pub fn max_gap_packets(sample_rate: u32, packet_samples: usize) -> u16 { + let limit = (u16::MAX / 2) as u64; + if packet_samples == 0 { + return limit as u16; + } + (sample_rate as u64 * MAX_GAP_MS / 1_000 / packet_samples as u64).clamp(1, limit) as u16 +} + #[derive(Default)] pub struct ChannelTimeline { last_seq: Option, @@ -32,7 +43,7 @@ pub struct ChannelTimeline { } impl ChannelTimeline { - pub fn step(&mut self, seq: u16) -> SeqStep { + pub fn step(&mut self, seq: u16, max_gap: u16) -> SeqStep { let Some(last) = self.last_seq else { self.last_seq = Some(seq); return SeqStep::Advance { gap: 0 }; @@ -43,7 +54,7 @@ impl ChannelTimeline { self.last_seq = Some(seq); self.rejected_run = 0; let gap = delta - 1; - return if gap > MAX_GAP_PACKETS { + return if gap > max_gap { SeqStep::Resync } else { SeqStep::Advance { gap } @@ -63,11 +74,21 @@ impl ChannelTimeline { mod tests { use super::*; + /// One second of 20 ms packets. + const GAP: u16 = 50; + + #[test] + fn the_gap_limit_is_a_second_whatever_the_packet_size() { + assert_eq!(max_gap_packets(48_000, 960), 50); + assert_eq!(max_gap_packets(48_000, 120), 400); + assert_eq!(max_gap_packets(44_100, 32), 1_378); + } + #[test] fn sequential_packets_advance_without_gap() { let mut t = ChannelTimeline::default(); for seq in [0u16, 1, 2, 3] { - match t.step(seq) { + match t.step(seq, GAP) { SeqStep::Advance { gap } => assert_eq!(gap, 0, "seq {seq}"), _ => panic!("sequence {seq} did not advance"), } @@ -77,9 +98,9 @@ mod tests { #[test] fn small_gap_conceals_then_advances() { let mut t = ChannelTimeline::default(); - assert!(matches!(t.step(0), SeqStep::Advance { gap: 0 })); + assert!(matches!(t.step(0, GAP), SeqStep::Advance { gap: 0 })); // Packet 3 arrives after 1 was lost. - match t.step(3) { + match t.step(3, GAP) { SeqStep::Advance { gap } => assert_eq!(gap, 2), _ => panic!("small gap must advance"), } @@ -88,17 +109,17 @@ mod tests { #[test] fn duplicate_and_reordered_are_dropped() { let mut t = ChannelTimeline::default(); - let _ = t.step(5); - assert!(matches!(t.step(5), SeqStep::Drop), "same seq twice"); - assert!(matches!(t.step(4), SeqStep::Drop), "late arrival"); - assert!(matches!(t.step(3), SeqStep::Drop)); + let _ = t.step(5, GAP); + assert!(matches!(t.step(5, GAP), SeqStep::Drop), "same seq twice"); + assert!(matches!(t.step(4, GAP), SeqStep::Drop), "late arrival"); + assert!(matches!(t.step(3, GAP), SeqStep::Drop)); } #[test] fn huge_gap_is_a_resync_not_a_fill() { let mut t = ChannelTimeline::default(); - let _ = t.step(0); - match t.step(200) { + let _ = t.step(0, GAP); + match t.step(200, GAP) { SeqStep::Resync => {} _ => panic!("a 200-packet outage must resync"), } @@ -107,15 +128,15 @@ mod tests { #[test] fn restart_run_resyncs() { let mut t = ChannelTimeline::default(); - let _ = t.step(100); + let _ = t.step(100, GAP); // A sender restarting at 0: 25 rejected packets in a row flip to resync. for seq in [200u16, 201] { - let _ = t.step(seq); + let _ = t.step(seq, GAP); } let mut rejected = 0; let mut last = None; for seq in 0..40u16 { - let step = t.step(seq); + let step = t.step(seq, GAP); match step { SeqStep::Drop => rejected += 1, SeqStep::Resync => { @@ -132,15 +153,15 @@ mod tests { #[test] fn seq_wraparound_continues_the_timeline() { let mut t = ChannelTimeline::default(); - let _ = t.step(u16::MAX - 1); - assert!(matches!(t.step(u16::MAX), SeqStep::Advance { gap: 0 })); + let _ = t.step(u16::MAX - 1, GAP); + assert!(matches!(t.step(u16::MAX, GAP), SeqStep::Advance { gap: 0 })); assert!( - matches!(t.step(0), SeqStep::Advance { gap: 0 }), + matches!(t.step(0, GAP), SeqStep::Advance { gap: 0 }), "65535 -> 0" ); - assert!(matches!(t.step(3), SeqStep::Advance { gap: 2 })); + assert!(matches!(t.step(3, GAP), SeqStep::Advance { gap: 2 })); assert!( - matches!(t.step(u16::MAX), SeqStep::Drop), + matches!(t.step(u16::MAX, GAP), SeqStep::Drop), "pre-wrap straggler" ); } @@ -148,24 +169,24 @@ mod tests { #[test] fn gap_limit_is_inclusive() { let mut t = ChannelTimeline::default(); - let _ = t.step(0); + let _ = t.step(0, GAP); assert!(matches!( - t.step(MAX_GAP_PACKETS + 1), - SeqStep::Advance { gap } if gap == MAX_GAP_PACKETS + t.step(GAP + 1, GAP), + SeqStep::Advance { gap } if gap == GAP )); - assert!(matches!(t.step(2 * MAX_GAP_PACKETS + 3), SeqStep::Resync)); + assert!(matches!(t.step(2 * GAP + 3, GAP), SeqStep::Resync)); } #[test] fn a_fresh_packet_ends_the_rejected_run() { let mut t = ChannelTimeline::default(); - let _ = t.step(1000); + let _ = t.step(1000, GAP); for _ in 0..RESTART_RUN - 1 { - assert!(matches!(t.step(10), SeqStep::Drop)); + assert!(matches!(t.step(10, GAP), SeqStep::Drop)); } - assert!(matches!(t.step(1001), SeqStep::Advance { gap: 0 })); + assert!(matches!(t.step(1001, GAP), SeqStep::Advance { gap: 0 })); assert!( - matches!(t.step(10), SeqStep::Drop), + matches!(t.step(10, GAP), SeqStep::Drop), "run restarted from zero" ); } diff --git a/src-tauri/src/audio/pipeline/asrc.rs b/src-tauri/src/audio/pipeline/asrc.rs new file mode 100644 index 0000000..0c063dc --- /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/cue.rs b/src-tauri/src/audio/pipeline/cue.rs index 502c58c..785c407 100644 --- a/src-tauri/src/audio/pipeline/cue.rs +++ b/src-tauri/src/audio/pipeline/cue.rs @@ -21,6 +21,10 @@ const BEEP_OFF_MP3: &[u8] = include_bytes!("../../../assets/cues/beep_off.mp3"); const BEEP_ON_MP3: &[u8] = include_bytes!("../../../assets/cues/beep_on.mp3"); const RESAMPLE_CHUNK: usize = 1024; +/// Quantum asked of PipeWire for a cue. Nobody waits on a cue, so it asks for +/// a large one rather than pull the whole graph down to a small quantum. +#[cfg(target_os = "linux")] +const CUE_QUANTUM: usize = 1024; // Slack after the clip ends so the device drains before the stream is dropped. const DRAIN: Duration = Duration::from_millis(120); @@ -68,6 +72,7 @@ pub fn play(device_id: &str, muted: bool, gain: f32, beep: bool) -> AppResult<() &spec.node_id, spec.sample_rate, channels, + CUE_QUANTUM, move |out| { let frames = out.len() / channels; render(out, frames); diff --git a/src-tauri/src/audio/pipeline/cushion.rs b/src-tauri/src/audio/pipeline/cushion.rs new file mode 100644 index 0000000..5c068e1 --- /dev/null +++ b/src-tauri/src/audio/pipeline/cushion.rs @@ -0,0 +1,966 @@ +//! 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 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. +//! +//! It never corrects the queue while the source plays, with one exception: +//! the depth guessed before anything was measured is corrected once, by one +//! crossfaded cut, as soon as it is measured. A source rebuilt by an edit +//! starts from the depth its predecessor measured (`DepthMemo`) and has +//! nothing to correct. 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. +//! +//! 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 std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::audio::adaptive_depth::{DepthEstimator, DepthMemo, OutageJudge}; + +/// Correction the source should apply before reading its next block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Adjust { + None, + /// Cut `frames` out of the queue, crossfading the `fade` frames before + /// the cut into the `fade` after it. + Drop { + frames: usize, + fade: usize, + }, +} + +/// Longest crossfade the startup correction cuts under, when the queue holds +/// that much past the cut. One long blend is heard as a single soft +/// transition, where many short splices crackle. +const STARTUP_FADE_MS: f64 = 10.0; + +/// `STARTUP_FADE_MS` in frames at `rate`. +pub(super) const fn startup_fade_frames(rate: u32) -> usize { + let frames = (rate as f64 * STARTUP_FADE_MS / 1000.0 + 0.5) as usize; + if frames == 0 { + 1 + } else { + frames + } +} + +/// Every source's memo, by what makes it the same source: the output it +/// plays in and the input it reads. Lives as long as the pipeline does. +#[derive(Default)] +pub(super) struct DepthMemos(Mutex>>); + +impl DepthMemos { + pub(super) fn get(&self, key: &str) -> Arc { + self.0 + .lock() + .unwrap() + .entry(key.to_string()) + .or_default() + .clone() + } +} + +/// 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 the first deliveries have been seen. Once they +/// have, the guess is their size (see `arrived`). +const PRIOR_MS: f64 = 10.0; +/// How fast the saw's height sinks back once deliveries get smaller. +const QUANTUM_RELEASE_S: f64 = 30.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; +/// When the queue cannot hold the whole startup cut at once, the next part +/// waits this long. +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; +/// 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 { + need: usize, + safety: usize, + depth: DepthEstimator, + sum: f64, + blocks: usize, + low: usize, + /// Startup correction still owed, as frames to drop. + owed: usize, + cooldown: usize, + cooldown_blocks: usize, + /// Longest and shortest crossfade of the startup cut, in frames. + max_fade: usize, + min_fade: usize, + primed: bool, + /// 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, + /// 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: f64, + /// Share of `quantum` kept per block, for `QUANTUM_RELEASE_S`. + quantum_keep: f64, + /// A delivery has arrived since the queue was created. + seen_delivery: bool, + /// Primed at least once. + started: bool, + rate: u32, + memo: Arc, + /// Depth recalled from `memo`: the prior never drops below it. + recalled: usize, +} + +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, memo: Arc) -> Self { + let frames = |ms: f64| ((sample_rate as f64 * ms / 1000.0).round() as usize).max(1); + let recalled = memo.recall(sample_rate); + let mut c = 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), + max_fade: startup_fade_frames(sample_rate), + min_fade: super::dag::splice_fade_frames(sample_rate), + primed: 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.0, + quantum_keep: (-(need.max(1) as f64) / sample_rate.max(1) as f64 / QUANTUM_RELEASE_S) + .exp(), + seen_delivery: false, + started: false, + rate: sample_rate, + memo, + recalled: 0, + }; + if let Some((dip, quantum)) = recalled { + c.recalled = dip; + c.depth.set_prior(dip); + c.quantum = quantum as f64; + c.settled = true; + } + c + } + + /// 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().min(self.max_depth) + } + + fn quantum(&self) -> usize { + self.quantum.round() as usize + } + + /// 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) { + self.since_late = self.since_late.saturating_add(1); + // The largest single arrival is how far the queue saws between reads; + // it sinks back over `QUANTUM_RELEASE_S` if deliveries get smaller. + // Only steady flow counts: a catch-up burst after a gap is the gap, + // not the saw. While first filling, the first delivery may be + // backlog from before the first read; later ones are the saw, and + // until the dip is measured a whole saw of them stands in for it: + // twice an ideal saw's dip, room for jitter not yet measured. + self.quantum *= self.quantum_keep; + let steady = if self.primed { + self.judge.is_clear() + } else { + !self.started && self.seen_delivery + }; + if frames > 0 { + self.seen_delivery = true; + } + if steady && frames > 0 { + let cap = if self.quantum > 0.0 { + 2.0 * self.quantum + } else { + f64::INFINITY + }; + self.quantum = self.quantum.max((frames as f64).min(cap)); + if !self.started { + self.depth.set_prior(self.quantum().max(self.recalled)); + } + } + if let Some(late) = self.judge.delivered(frames, self.need) { + if late > 0 { + if 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 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. + pub(super) fn start_level(&self) -> usize { + let dip = self.depth.depth().min(self.max_depth); + let quantum = self.quantum(); + if quantum == 0 { + return self.target() + dip; + } + let jitter = dip.saturating_sub(quantum / 2); + self.floor() + 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. + /// + /// 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; + } + self.primed = true; + self.started = true; + self.owed = 0; + self.sum = 0.0; + self.blocks = 0; + self.low = usize::MAX; + // 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(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) { + self.primed = false; + self.judge.missing(missing); + // The window holds the drain, which says nothing about jitter. + self.depth.discard_window(); + } + + /// The output stopped reading for a while (a device overload skips its + /// callbacks) while the source delivered on. What arrived meanwhile is + /// for time the listener already lost to that dropout: realign under it, + /// as after any silence, instead of keeping it queued as latency for + /// good. Not the source's fault, so its depth is left as it was. + pub(super) fn output_stalled(&mut self) { + self.primed = false; + self.depth.discard_window(); + } + + /// Call once per block with the frames queued before the read. Returns the + /// startup correction's next splice, if one is due. + pub(super) fn observe(&mut self, queued: usize) -> Adjust { + // 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(); + 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.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.depth.is_measured() { + self.memo + .keep(self.depth.depth(), self.quantum(), self.rate); + if !self.settled { + self.settled = true; + self.owed = self.startup_excess(mean, low); + } + } + } + } + if self.cooldown > 0 { + self.cooldown -= 1; + return Adjust::None; + } + if self.owed > 0 { + // The cut has to leave this block's read and the shortest fade + // behind; the fade then takes whatever the queue holds past the + // cut, up to the longest. + let n = self + .owed + .min(queued.saturating_sub(self.need + self.min_fade)); + if n > 0 { + self.owed -= n; + if self.owed > 0 { + self.cooldown = self.cooldown_blocks; + } + let fade = (queued - n).min(self.max_fade); + return Adjust::Drop { frames: n, fade }; + } + } + 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; + + /// Audible events a listener would notice, after settling. + #[derive(Debug, Default)] + struct Heard { + splices: usize, + underruns: usize, + mean_queued: f64, + max_queued: usize, + } + + /// 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 { frames: 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, + capture_ppm: f64, + jitter: f64, + initial_backlog: usize, + seconds: f64, + settle_s: f64, + ) -> Heard { + simulate_with_quiet( + block, + burst, + capture_ppm, + jitter, + initial_backlog, + seconds, + settle_s, + &[], + ) + } + + #[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)], + ) -> Heard { + let deliveries = capture(burst, capture_ppm, jitter, initial_backlog, seconds, quiet); + play( + Cushion::new(block, SR, Arc::default()), + SR, + block, + Read::Steered, + &deliveries, + seconds, + settle_s, + quiet, + ) + } + + #[test] + fn startup_backlog_is_dropped_before_anything_plays() { + let mut c = Cushion::new(64, SR, Arc::default()); + 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.start_level()); + 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.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() { + // 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 [-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.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); + } + } + } + } + + #[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.underruns, 0); + assert!(r.mean_queued < 3.0 * 480.0, "mean queue {}", r.mean_queued); + } + + #[test] + 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, Arc::default()); + 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 + /// 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 late_audio_deepens_the_queue_once_by_what_was_late() { + let mut c = Cushion::new(32, SR, Arc::default()); + c.prime(c.start_level()).expect("primed"); + let before = c.target(); + // 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}"); + 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, Arc::default()); + 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, Arc::default()); + 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.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 + 480, + "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.underruns, 0); + } + + fn replay( + block: usize, + read: Read, + deliveries: &[(f64, usize)], + seconds: f64, + settle: f64, + ) -> Heard { + let c = Cushion::new(block, 44_100, Arc::default()); + play(c, 44_100, block, read, deliveries, seconds, settle, &[]) + } + + /// 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, Read::Steered, &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(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 + // 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(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 + // 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 the_queue_realigns_only_from_silence() { + let mut c = Cushion::new(64, SR, Arc::default()); + 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 small_deliveries_start_below_the_prior() { + let mut c = Cushion::new(32, SR, Arc::default()); + let guessed = c.start_level(); + for _ in 0..4 { + c.arrived(32); + } + assert!( + c.start_level() < guessed / 2, + "{} vs {guessed}", + c.start_level() + ); + } + + #[test] + fn the_saw_height_sinks_back_at_the_same_pace_at_any_block() { + let sunk = |need: usize| { + let mut c = Cushion::new(need, SR, Arc::default()); + c.arrived(512); + c.arrived(512); + for _ in 0..(10 * SR as usize) / need { + c.arrived(0); + } + c.quantum + }; + let (small, large) = (sunk(32), sunk(1_024)); + assert!((small - large).abs() < 1.0, "{small} vs {large}"); + } + + #[test] + fn a_splice_waits_until_the_queue_can_afford_it() { + let mut c = Cushion::new(64, SR, Arc::default()); + 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 { + frames: 64, + fade: 128 + } + ); + } + + #[test] + fn the_startup_correction_is_one_cut_under_a_long_fade() { + let mut c = Cushion::new(64, SR, Arc::default()); + c.owed = 960; + assert_eq!( + c.observe(4_000), + Adjust::Drop { + frames: 960, + fade: startup_fade_frames(SR) + } + ); + assert_eq!(c.observe(4_000), Adjust::None, "nothing left owed"); + } + + #[test] + fn a_source_rebuilt_by_an_edit_starts_at_the_measured_depth() { + // The first source measures its delivery and corrects its guessed + // start once; the one replacing it starts where that left off and + // never cuts. + let memo: Arc = Arc::default(); + let deliveries = capture(512, 80.0, 0.3, 4_800, 6.0, &[]); + let first = Cushion::new(64, SR, memo.clone()); + let r = play(first, SR, 64, Read::Steered, &deliveries, 6.0, 0.0, &[]); + assert!(r.splices > 0, "the first start corrects its guess"); + let second = Cushion::new(64, SR, memo); + let r = play(second, SR, 64, Read::Steered, &deliveries, 6.0, 0.0, &[]); + assert_eq!(r.splices, 0, "the rebuilt source cuts"); + assert_eq!(r.underruns, 0, "the rebuilt source runs dry"); + } +} + +/// 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 fcb3b60..b296bee 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -4,15 +4,19 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use rtrb::{Consumer, Producer, RingBuffer}; -use tracing::{info, warn}; +use super::asrc::Asrc; +use super::cushion::{startup_fade_frames, Adjust, Cushion, DepthMemos}; +use super::file_reader::SeekFlush; +use super::latency::NodeTiming; +use super::sig::MONITOR_KEY; use crate::audio::effects::{ instantiate_effect, update_meter, EffectControl, EffectRegistry, GrHandle, LufsHandle, - MeterHandle, RuntimeEffect, WaveformHandle, + MeterHandle, Ramp, RuntimeEffect, WaveformHandle, }; 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; @@ -27,7 +31,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 analysis and recording workers (monitor, +/// recorders). Nobody hears their latency, and a timer cannot pace small +/// blocks reliably. Speakers and wire senders run the engine buffer size. +pub const TIMER_BLOCK_FRAMES: usize = 1024; const MAX_NET_CH: u32 = crate::audio::netaudio::MAX_CHANNELS as u32; @@ -38,14 +45,82 @@ 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; +const SPLICE_FADE_MS: f64 = 0.67; + +/// `SPLICE_FADE_MS` in frames at `rate`. +pub(super) const fn splice_fade_frames(rate: u32) -> usize { + let frames = (rate as f64 * SPLICE_FADE_MS / 1000.0 + 0.5) as usize; + if frames == 0 { + 1 + } else { + frames + } +} + +/// Fades `len` frames of interleaved `buf` starting at frame `from`, in +/// (`up`) or out, over at most `max_len`. +fn ramp(buf: &mut [f32], channels: usize, from: usize, len: usize, max_len: usize, up: bool) { + let len = len.min(max_len); + 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 holds a fade's worth on each side of its cut, at the ring's +/// `rate`. +fn splice_samples(channels: usize, rate: u32) -> usize { + 2 * startup_fade_frames(rate) * 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, rate: u32) -> usize { + RESAMPLE_CHUNK * channels + splice_samples(channels, rate) + 8 +} + +/// Largest packet the file decoders hand over at once (a FLAC block; MP3 +/// and AAC frames are smaller): what a file source holds past a block. +const FILE_PACKET_FRAMES: usize = 4096; + +/// A seek fades the old position out and the new one in over this. +const SEEK_FADE_MS: f64 = 5.0; + +fn seek_fade_frames(rate: u32) -> usize { + ((rate as f64 * SEEK_FADE_MS / 1000.0).round() as usize).max(1) +} + +/// Where a source stands in a seek. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum SeekFade { + Off, + /// Fading the old position out; frames to go. + Out { + left: usize, + }, + /// Old position dropped; silence until the new one arrives. + Wait, + /// Fading the new position in; frames to go. + In { + left: usize, + }, +} + +/// Time constant of a source's queue gauge. +const QUEUE_SMOOTHING_MS: f64 = 250.0; + +fn queue_alpha(block_frames: usize, rate: u32) -> f64 { + let block_ms = block_frames as f64 * 1000.0 / rate.max(1) as f64; + 1.0 - (-block_ms / QUEUE_SMOOTHING_MS).exp() +} /// Fixed-capacity FIFO; allocates once. Overrun clamps and counts drops -- /// wrapping the write head past the read head would corrupt subsequent pops. @@ -129,7 +204,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), @@ -177,23 +252,35 @@ 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, /// 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, + /// 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 { - fn new() -> Self { + pub(super) fn new() -> Self { Self { xrun: Arc::new(AtomicU64::new(0)), stalled: Arc::new(AtomicU64::new(0)), 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)), + failed: Arc::new(AtomicU64::new(0)), + online: Arc::new(AtomicBool::new(false)), } } } @@ -216,12 +303,14 @@ 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. 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 @@ -234,6 +323,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,32 +331,102 @@ 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, + /// 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, 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, + /// The captured input this source reads; `None` for a ring-source. + input_id: Option, + /// Fade length at the ring's rate, for splices and for running dry. + fade_frames: usize, + /// Smoothed frames left queued after each read: this source's latency. + queue_avg: f64, + /// Share of the gap `queue_avg` closes per block, for `QUEUE_SMOOTHING_MS`. + queue_alpha: 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, - first_data_logged: bool, volume: Arc, + /// A file source's start level, in frames of its ring: a block and a + /// decoder packet. 0 for every other source. + file_lead: usize, + /// Reached `file_lead` and playing; cleared when it runs dry. + file_started: bool, + /// The volume heard, gliding to a new setting so moving it never steps. + volume_ramp: Ramp, paused: Option>, - // u64 generation (not AtomicBool) so every output's SourceState detects the - // seek independently; swap(false) would clear the flag for the first reader. - drain: Option>, + /// A file's seeks: each one fades out and drops what is queued from the + /// old position, then fades the new one in. A generation, so every + /// output's source sees each seek on its own. + drain: Option>, last_drain_gen: u64, + seek_fade: SeekFade, + /// `SEEK_FADE_MS` at the output rate. + seek_fade_frames: usize, + /// Flushes asked for that this source has yet to answer. + flushes_owed: u64, meter: Option, // 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); + // 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.file_started = old.file_started; + 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.seek_fade = old.seek_fade; + self.flushes_owed = old.flushes_owed; + self.carry_over = false; + } + + fn output_stalled(&mut self) { + if let Some(c) = &mut self.cushion { + c.output_stalled(); + } + } + fn is_stalled(&self) -> bool { self.last_pop_at.elapsed() > STALL_THRESHOLD } @@ -280,84 +440,147 @@ impl SourceState { } } - fn fill_block(&mut self) { + /// Everything queued ahead of the graph goes. + fn flush_queue(&mut self) { + let avail = self.consumer.slots(); + if avail > 0 { + if let Ok(chunk) = self.consumer.read_chunk(avail) { + chunk.commit_all(); + } + } + self.input_staging.clear(); + self.out_pending.clear(); + } + + /// Fades the old position out of this block and, once it is silent, drops + /// the rest of it and answers the seek; fades the new position in. + fn apply_seek_fade(&mut self) { + let total = self.seek_fade_frames; + let w = self.channels; + match self.seek_fade { + SeekFade::Out { mut left } => { + for frame in self.out_buf.chunks_exact_mut(w) { + let g = left as f32 / total as f32; + left = left.saturating_sub(1); + frame.iter_mut().for_each(|s| *s *= g); + } + if left > 0 { + self.seek_fade = SeekFade::Out { left }; + return; + } + self.flush_queue(); + if let Some(d) = &self.drain { + d.answer(std::mem::take(&mut self.flushes_owed)); + } + self.seek_fade = SeekFade::Wait; + } + SeekFade::In { mut left } => { + for frame in self.out_buf.chunks_exact_mut(w) { + let g = 1.0 - left as f32 / total as f32; + left = left.saturating_sub(1); + frame.iter_mut().for_each(|s| *s *= g); + } + self.seek_fade = if left > 0 { + SeekFade::In { left } + } else { + SeekFade::Off + }; + } + SeekFade::Off | SeekFade::Wait => {} + } + } + + fn fill_block(&mut self, now: f64) { if let Some(p) = &self.paused { if p.load(Ordering::SeqCst) { - let avail = self.consumer.slots(); - if avail > 0 { - if let Ok(chunk) = self.consumer.read_chunk(avail) { - chunk.commit_all(); - } + self.flush_queue(); + // A pause drops everything anyway: a seek's fade has nothing + // left to do, and its flush is as good as answered. + if let Some(d) = &self.drain { + let gen = d.requested(); + self.flushes_owed += gen.wrapping_sub(self.last_drain_gen); + self.last_drain_gen = gen; + d.answer(std::mem::take(&mut self.flushes_owed)); } - self.input_staging.clear(); - self.out_pending.clear(); + self.seek_fade = SeekFade::Off; self.silence(); return; } } if let Some(d) = &self.drain { - let gen = d.load(Ordering::SeqCst); + let gen = d.requested(); if gen != self.last_drain_gen { + self.flushes_owed += gen.wrapping_sub(self.last_drain_gen); self.last_drain_gen = gen; - let avail = self.consumer.slots(); - if avail > 0 { - if let Ok(chunk) = self.consumer.read_chunk(avail) { - chunk.commit_all(); - } + // A seek during a fade-out keeps fading from where it got to. + if !matches!(self.seek_fade, SeekFade::Out { .. }) { + self.seek_fade = SeekFade::Out { + left: self.seek_fade_frames, + }; } - self.input_staging.clear(); - self.out_pending.clear(); + } + } + if self.seek_fade == SeekFade::Wait { + // The new position has not arrived yet: silence, not an underrun. + if self.queued_frames() == 0 { self.silence(); + self.publish_queue(); return; } + self.seek_fade = SeekFade::In { + left: self.seek_fade_frames, + }; } - // 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); - } + // A file feeds a graph that joined it mid-play only as fast as it + // plays, so that graph's ring holds no lead of its own. It starts + // once it holds a block and a packet past it, as a live queue primes, + // so a packet landing late never runs it dry. + let mut file_fade_in = false; + if self.file_lead > 0 && !self.file_started { + if self.queued_frames() < self.file_lead { + self.silence(); + self.publish_queue(); + return; } + self.file_started = true; + file_fade_in = true; } - 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(); - if self.out_pending.len() == 0 { - // Ring empty too -- zero-fill the rest (real underrun). - for s in &mut self.out_buf[written..] { - *s = 0.0; - } - // 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 - } else { - &self.stats.xrun - }; - counter.fetch_add((need - written) as u64, Ordering::Relaxed); - break; + 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. + if self.is_stalled() { + self.stats + .stalled + .fetch_add(self.out_buf.len() as u64, Ordering::Relaxed); } - let n = self.out_pending.pop_into(&mut self.out_buf[written..]); - written += n; + self.silence(); + 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) || file_fade_in; + 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; + self.apply_seek_fade(); + self.volume_ramp + .set(f32::from_bits(self.volume.load(Ordering::Relaxed))); + if self.volume_ramp.is_settled() { + let vol = self.volume_ramp.value(); + if vol != 1.0 { + for s in self.out_buf.iter_mut() { + *s *= vol; + } + } + } else { + for frame in self.out_buf.chunks_exact_mut(self.channels.max(1)) { + let vol = self.volume_ramp.next(); + for s in frame { + *s *= vol; + } } } if let Some(m) = &self.meter { @@ -369,13 +592,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,44 +607,238 @@ impl SourceState { self.stats .level .store(self.consumer.slots() as u64, Ordering::Relaxed); + self.publish_queue(); } - /// 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 - /// blend instead of a step. - fn splice_trim(&mut self, drop: usize, fade: usize) { - self.splice_tmp.clear(); - let Ok(outgoing) = self.consumer.read_chunk(fade) else { + /// 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 { + 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..] { + *s = 0.0; + } + // A stalled/paused source silences by design; only a source that + // is actively streaming and ran dry mid-block is a real xrun. + let missing = need - written; + if self.is_stalled() { + 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 / self.channels); + } + self.file_started = false; + } + break; + } + let n = self.out_pending.pop_into(&mut self.out_buf[written..]); + written += n; + } + if self.cushion.is_some() || self.file_lead > 0 { + let w = self.channels; + if fade_in { + ramp(&mut self.out_buf, w, 0, written / w, self.fade_frames, 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(self.fade_frames); + ramp(&mut self.out_buf, w, frames - len, len, len, false); + } + } + } + + /// 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 (first, second) = outgoing.as_slices(); - self.splice_tmp.extend_from_slice(first); - self.splice_tmp.extend_from_slice(second); - outgoing.commit_all(); + 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(); + } + } + let got = asrc.input.len(); + if got > 0 { + self.stats.online.store(true, Ordering::Relaxed); + } + if fade_in { + ramp(&mut asrc.input, w, 0, got / w, self.fade_frames, true); + } + if got < need { + let frames = got / w; + let len = frames.min(self.fade_frames); + ramp(&mut asrc.input, w, frames - len, 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); + } + } + } + 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 + /// 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) { + self.queued_after = self.queued_frames(); + 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) * self.queue_alpha; + 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; + // 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, + Some(excess) => self.discard(excess), + } + } + let queued = self.queued_frames(); + if let Some(Adjust::Drop { frames, fade }) = + self.cushion.as_mut().map(|c| c.observe(queued)) + { + self.splice_trim(frames * self.channels, fade * self.channels); + } + true + } - if let Ok(cut) = self.consumer.read_chunk(drop) { - cut.commit_all(); + /// 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(); + } + } - if let Ok(incoming) = self.consumer.read_chunk(fade) { - let (first, second) = incoming.as_slices(); - crossfade_into(&mut self.splice_tmp, first, second, self.channels); - incoming.commit_all(); + /// 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 blend + /// instead of a step. + fn splice_trim(&mut self, drop: usize, fade: usize) { + self.splice_tmp.clear(); + if drop >= fade { + let Ok(outgoing) = self.consumer.read_chunk(fade) else { + return; + }; + let (first, second) = outgoing.as_slices(); + self.splice_tmp.extend_from_slice(first); + self.splice_tmp.extend_from_slice(second); + outgoing.commit_all(); + if let Ok(cut) = self.consumer.read_chunk(drop - fade) { + cut.commit_all(); + } + if let Ok(incoming) = self.consumer.read_chunk(fade) { + let (first, second) = incoming.as_slices(); + crossfade_into(&mut self.splice_tmp, first, second, self.channels); + incoming.commit_all(); + } + } else { + // The fade outlasts the cut: both sides come out of one read. + let Ok(span) = self.consumer.read_chunk(drop + fade) else { + return; + }; + let (first, second) = span.as_slices(); + self.splice_tmp.extend_from_slice(first); + self.splice_tmp.extend_from_slice(second); + span.commit_all(); + let frames = (fade / self.channels.max(1)).saturating_sub(1).max(1) as f32; + for i in 0..fade { + let w = ((i / self.channels) as f32 / frames).min(1.0); + self.splice_tmp[i] = self.splice_tmp[i] * (1.0 - w) + self.splice_tmp[i + drop] * w; + } + self.splice_tmp.truncate(fade); } self.input_staging.extend_from_slice(&self.splice_tmp); // What left the ring, versus what the stream actually loses: the - // fade-out is re-injected, so only the cut and the fade-in are gone. - let popped = (drop + 2 * fade) as u64; - 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); + // blend is re-injected, so only the cut is gone. + self.stats + .consumed + .fetch_add((drop + fade) as u64, Ordering::Relaxed); + self.stats.trimmed.fetch_add(drop as u64, Ordering::Relaxed); 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 +861,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,15 +898,57 @@ 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); } } } +/// Switching an effect in or out fades across this, so it never steps. +const BYPASS_FADE_MS: f64 = 10.0; + +/// An effect's way around itself. What goes around is delayed as the effect +/// delays it, so a bypassed branch stays in time with every path compensated +/// for the effect, and the switch crossfades. Fully bypassed, the effect does +/// not run. +struct Bypass { + flag: Arc, + /// The node's input, delayed by the effect's own latency. + dry: DelayLine, + dry_buf: Box<[f32]>, + /// Share of the effect in what the node plays: 1 in, 0 bypassed. + mix: f32, + step: f32, + /// The effect is running. Back from a bypass it runs `latency` frames + /// before it is heard: its delay lines still hold what it had before. + running: bool, + warmup: usize, + latency: usize, +} + +impl Bypass { + fn new( + flag: Arc, + latency: usize, + channels: usize, + block_frames: usize, + rate: u32, + ) -> Self { + let bypassed = flag.load(Ordering::Relaxed); + let fade = (rate as f64 * BYPASS_FADE_MS / 1000.0).round().max(1.0); + Self { + flag, + dry: DelayLine::new(latency, channels, block_frames), + dry_buf: vec![0.0; block_frames * channels].into_boxed_slice(), + mix: if bypassed { 0.0 } else { 1.0 }, + step: 1.0 / fade as f32, + running: !bypassed, + warmup: 0, + latency, + } + } +} + struct EffectState { // One instance per stereo pair; each carries its own DSP state but shares // the parameter atomics. `effects[0]` alone for width <= 2. @@ -494,7 +956,7 @@ struct EffectState { // Analyzers (level meter) read the whole N-wide buffer at once instead of // being split into stereo pairs, so they report every channel. full_width: bool, - bypass: Arc, + bypass: Bypass, incoming: Vec, sidechain: Vec, out_buf: Vec, @@ -506,14 +968,62 @@ 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 { - /// Run the effect chain over `out_buf`. Width <= 2 processes in place; wider - /// buffers are split into stereo pairs, each through its own instance so - /// per-channel filter state never bleeds across pairs. + /// The effect, or the way around it, over `out_buf`. RT-safe. + fn process(&mut self, frames: usize) { + let bypassed = self.bypass.flag.load(Ordering::Relaxed); + if !bypassed && self.bypass.mix == 1.0 && self.bypass.latency == 0 { + self.run(frames); + return; + } + let w = self.out_buf.len() / frames.max(1); + let n = frames * w; + let b = &mut self.bypass; + let dry = b.dry.delayed(&self.out_buf[..n]); + b.dry_buf[..n].copy_from_slice(dry); + if bypassed && b.mix == 0.0 { + b.running = false; + self.out_buf[..n].copy_from_slice(&b.dry_buf[..n]); + return; + } + if !b.running { + b.running = true; + b.warmup = b.latency; + } + self.run(frames); + let b = &mut self.bypass; + let target = if bypassed { 0.0 } else { 1.0 }; + for f in 0..frames { + if b.warmup > 0 { + b.warmup -= 1; + } else if b.mix < target { + b.mix = (b.mix + b.step).min(target); + } else if b.mix > target { + b.mix = (b.mix - b.step).max(target); + } + let g = b.mix; + for i in f * w..(f + 1) * w { + self.out_buf[i] = self.out_buf[i] * g + b.dry_buf[i] * (1.0 - g); + } + } + } + + /// Run the effect chain over `out_buf`. Width 2 processes in place; any + /// other width is split into stereo pairs, each through its own instance so + /// per-channel filter state never bleeds across pairs. A channel without a + /// partner (mono, or the last of an odd width) fills both sides of its + /// pair: an effect that links its channels hears it at its own level, as a + /// centred signal, rather than beside a silent one. 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(); @@ -525,13 +1035,13 @@ impl EffectState { for f in 0..frames { let base = f * w; self.pair_main[f * 2] = self.out_buf[base + c0]; - self.pair_main[f * 2 + 1] = if c1 < w { self.out_buf[base + c1] } else { 0.0 }; + self.pair_main[f * 2 + 1] = self.out_buf[base + if c1 < w { c1 } else { c0 }]; } let sc = if let Some(scb) = self.sidechain_buf.as_ref() { for f in 0..frames { let base = f * w; self.pair_side[f * 2] = scb[base + c0]; - self.pair_side[f * 2 + 1] = if c1 < w { scb[base + c1] } else { 0.0 }; + self.pair_side[f * 2 + 1] = scb[base + if c1 < w { c1 } else { c0 }]; } Some(self.pair_side.as_slice()) } else { @@ -569,8 +1079,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), @@ -587,6 +1097,8 @@ struct ConsumerState { incoming: Vec, channel_bufs: Vec<(String, Vec)>, send_producers: Vec>, + /// Wakes the thread that sends what the rings hold. + bell: Arc, } /// `delay` is `Some` when this path is shorter than the longest reaching the @@ -645,10 +1157,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 +1194,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 +1210,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 +1222,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 +1232,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 +1243,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 +1263,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 { @@ -773,12 +1286,18 @@ fn add_block_at(src: &[f32], dst: &mut [f32], off: 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; } } @@ -789,11 +1308,64 @@ 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. +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 new(delay_frames: usize, channels: usize) -> Self { + 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(), - scratch: vec![0.0; DSP_BLOCK_FRAMES * channels].into_boxed_slice(), + scratch: vec![0.0; block_frames * channels].into_boxed_slice(), pos: 0, } } @@ -819,10 +1391,19 @@ 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, 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>, + /// 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 @@ -830,7 +1411,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 +1420,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 +1445,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() @@ -868,22 +1453,186 @@ impl OutputGraph { .clamp(1, self.out_channels) } + /// The device playing this graph stopped calling for a while: every live + /// source realigns its queue under that dropout (see + /// `Cushion::output_stalled`). + pub(super) fn output_stalled(&mut self) { + for node in &mut self.nodes { + if let DagNode::Source(s) = node { + s.output_stalled(); + } + } + } + + /// 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)) { + 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; + } + if let Some(asrc) = &mut s.asrc { + asrc.set_clock(clock.clone()); + } + } + } + /// 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) { + 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)); + } + } + + /// 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 + /// 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); + // A switch mid-fade goes on where it was. + std::mem::swap(&mut new.bypass, &mut prev.bypass); + 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` (`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]) { + 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::Producer(p) => p.process(), + DagNode::Source(s) => s.fill_block(now), + DagNode::Producer(p) => p.process(now), DagNode::Effect(_) | DagNode::Consumer(_) => {} } } @@ -911,13 +1660,14 @@ 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) { bulk_push_counted(prod, buf, &health::TAP_RING_OVERRUN_SAMPLES); } } + cons.bell.ring(); continue; } if let DagNode::Effect(eff) = &mut tail[0] { @@ -932,9 +1682,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,30 +1698,29 @@ 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); - } - let w = eff.out_buf.len() / DSP_BLOCK_FRAMES; + eff.process(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]; } } } // 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); } } @@ -987,9 +1736,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), } } } @@ -1007,7 +1756,38 @@ 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. + 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 @@ -1023,6 +1803,9 @@ pub(super) struct BuiltOutputGraph { pub(super) fn build_output_graph( output_id: Option<&str>, output_sr: u32, + block_frames: usize, + // Most blocks this graph's output renders back to back. + burst_blocks: usize, realtime: bool, valid: &ValidGraph, input_native_sr: &HashMap, @@ -1031,27 +1814,24 @@ pub(super) fn build_output_graph( registry: &mut EffectRegistry, input_volumes: &HashMap>, input_paused: &HashMap>, - input_drain: &HashMap>, + input_drain: &HashMap>, input_meters: &HashMap, // 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, + // What the graph this build replaces is running, for carrying unchanged + // nodes over. Empty when nothing is being replaced in place. + previous: &HashMap, + // What each source measured of its delivery, for the sources built here. + memos: &DepthMemos, ) -> AppResult { let cut_leaf_ids: HashSet = cut_leaves.keys().cloned().collect(); let reachable: HashSet = match output_id { Some(id) => reachable_backward_cut(id, valid, &cut_leaf_ids), - // Monitor: everything feeding an analyzer, stopping at cut nodes (whose + // Monitor: everything feeding its roots, stopping at cut nodes (whose // processed output is read back from the owning output's ring). - None => { - let roots: Vec = valid - .effects - .iter() - .filter(|e| is_analyzer(&e.spec)) - .map(|e| e.id.clone()) - .collect(); - reachable_backward_from(&roots, valid, &cut_leaf_ids) - } + None => reachable_backward_from(&monitor_roots(valid), valid, &cut_leaf_ids), }; // Topo sort restricted to the reachable sub-graph. Inputs have indegree 0 @@ -1098,7 +1878,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(); @@ -1107,14 +1888,35 @@ 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(); + 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()); 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) { - let source = ring_source(id, consumer, owner_sr, output_sr, width, realtime, valid)?; + if let Some(CutLeaf { + consumer, + owner_sr, + width, + clock, + upstream, + }) = cut_leaves.remove(id) + { + let source = ring_source( + id, + consumer, + clock, + owner_sr, + output_sr, + block_frames, + width, + realtime, + valid, + memos.get(&format!("{}|cut:{id}", output_id.unwrap_or(MONITOR_KEY))), + )?; sources.push(SourceMeta { label: format!("{} out={}", source.label, output_id.unwrap_or("monitor")), stats: source.stats.clone(), @@ -1122,8 +1924,9 @@ 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, + upstream: Some(upstream), }); id_to_index.insert(id.clone(), nodes.len()); nodes.push(DagNode::Source(source)); @@ -1140,7 +1943,12 @@ pub(super) fn build_output_graph( InputSpec::NetReceiver { port } => { let receiver = crate::audio::netaudio::receiver::get_or_create(id, *port); Some(ChannelReceiver::new( - receiver.register_consumer(output_sr, realtime), + receiver.register_consumer(output_sr, block_frames, realtime), + memos.get(&format!( + "{}|{id}|{:?}", + output_id.unwrap_or(MONITOR_KEY), + input.spec + )), )) } InputSpec::WebRtcRecv { @@ -1154,12 +1962,18 @@ pub(super) fn build_output_graph( *opus_application, ); Some(ChannelReceiver::new( - session.register_bridge(output_sr, realtime), + session.register_bridge(output_sr, block_frames, realtime), + memos.get(&format!( + "{}|{id}|{:?}", + output_id.unwrap_or(MONITOR_KEY), + input.spec + )), )) } _ => None, }; if let Some(receiver) = network { + receive_buffers.push(receiver.fill_gauge()); let mut handles: Vec = valid .edges .iter() @@ -1174,12 +1988,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, })); @@ -1193,11 +2007,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() @@ -1211,7 +2037,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 +2055,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 { @@ -1256,35 +2081,57 @@ 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, + upstream: None, }); 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, input_sr)), + splice_tmp: Vec::with_capacity(splice_samples(source_channels, input_sr)), + fade_frames: splice_fade_frames(input_sr), + 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; 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(|| { + let key = format!("{}|{id}|{:?}", output_id.unwrap_or(MONITOR_KEY), input.spec); + Cushion::new(input_frames_per_block as usize, input_sr, memos.get(&key)) + }), + queue_avg: 0.0, + queue_alpha: queue_alpha(block_frames, output_sr), + queued_after: 0, + input_id: Some(id.clone()), last_pop_at: Instant::now(), - first_data_logged: false, + volume_ramp: Ramp::new(volume_at_build(input_volumes, id), output_sr), + file_lead: if matches!(input.spec, InputSpec::AudioFile { .. }) { + input_frames_per_block as usize + FILE_PACKET_FRAMES + } else { + 0 + }, + file_started: false, volume: input_volumes .get(id) .cloned() .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))), paused: input_paused.get(id).cloned(), drain: input_drain.get(id).cloned(), - last_drain_gen: 0, + last_drain_gen: input_drain.get(id).map_or(0, |d| d.requested()), + seek_fade: SeekFade::Off, + seek_fade_frames: seek_fade_frames(output_sr), + flushes_owed: 0, 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)); @@ -1319,7 +2166,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 @@ -1337,48 +2186,115 @@ 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}|{burst_blocks}|{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, - realtime, - true, + let (effects, this_effect) = match previous_effect { + Some(p) => (Vec::new(), p), + None => { + let build = instantiate_effect( + &effect.spec, + id, + output_sr, + block_frames, + burst_blocks, + 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, + burst_blocks, + 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 own = this_effect.latency; + let bypass = Bypass::new( + this_effect.bypass.clone(), + own, eff_channels, - registry, + block_frames, + output_sr, ); - 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 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 +2311,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,34 +2328,23 @@ 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 - // 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(); - 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); - effects.push(extra.effect); - } + node_timings.push(NodeTiming { + node_id: id.clone(), + latency_frames: own as u32, + working_block: this_effect.working_block.map(|w| w as u32), + sample_rate: output_sr, + }); + 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, @@ -1447,14 +2352,17 @@ 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(), })); - 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); } @@ -1493,13 +2401,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,12 +2432,12 @@ 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); } - match &spec { + let bell = match &spec { OutputSpec::NetSender { node_id, target, @@ -1544,8 +2458,10 @@ pub(super) fn build_output_graph( *opus_bitrate, *opus_application, output_sr, + block_frames, ); sender.set_send_consumers(send_consumers); + sender.bell() } OutputSpec::WebRtcSend { node_id, @@ -1557,22 +2473,30 @@ pub(super) fn build_output_graph( crate::audio::webrtc::get_or_create(node_id, *opus_bitrate, *opus_application); // This graph already runs at the wire rate, so the encode task's // own resampler stays out of the path. - session.set_send_consumers(send_consumers, output_sr); + session.set_send_consumers(send_consumers, output_sr, block_frames); + session.send_bell.clone() } _ => unreachable!("wire sender spec"), - } + }; nodes.push(DagNode::Consumer(ConsumerState { incoming, channel_bufs, send_producers, + bell, })); + 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, + block_frames, out_channels: 2, nodes, + node_ids, + node_keys, + audible, terminals: Vec::new(), latency_frames: max_up, blocks: blocks.clone(), @@ -1588,10 +2512,15 @@ pub(super) fn build_output_graph( label: out_label, blocks, sample_rate: output_sr, + block_frames, channels: 2, io: None, }, node_meta, + receive_buffers, + node_timings, + carried, + carried_inputs, }); } @@ -1617,14 +2546,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 }, @@ -1635,11 +2569,17 @@ pub(super) fn build_output_graph( None => Vec::new(), }; + 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, + block_frames, out_channels: 2, nodes, + node_ids, + node_keys, + audible, terminals, latency_frames: node_latencies.iter().copied().max().unwrap_or(0), blocks: blocks.clone(), @@ -1655,13 +2595,82 @@ pub(super) fn build_output_graph( label: out_label, blocks, sample_rate: output_sr, + block_frames, channels: 2, io: None, }, node_meta, + receive_buffers, + node_timings, + carried, + carried_inputs, }) } +/// 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( + 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, 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`. /// Reuses the source machinery so per-channel taps and backlog-dropping behave @@ -1670,11 +2679,14 @@ pub(super) fn build_output_graph( fn ring_source( id: &str, consumer: Consumer, + clock: Arc, owner_sr: u32, output_sr: u32, + block_frames: usize, channels: usize, realtime: bool, valid: &ValidGraph, + memo: Arc, ) -> AppResult { let resampler = if owner_sr == output_sr { None @@ -1690,9 +2702,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,34 +2720,61 @@ 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(); + 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, + 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, owner_sr)), + splice_tmp: Vec::with_capacity(splice_samples(channels, owner_sr)), + fade_frames: splice_fade_frames(owner_sr), + asrc, 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, memo)), + input_id: None, + queue_avg: 0.0, + queue_alpha: queue_alpha(block_frames, output_sr), + queued_after: 0, last_pop_at: Instant::now(), - first_data_logged: false, volume: Arc::new(AtomicU32::new(0x3F80_0000)), + volume_ramp: Ramp::new(1.0, output_sr), + file_lead: 0, + file_started: false, paused: None, drain: None, last_drain_gen: 0, + seek_fade: SeekFade::Off, + seek_fade_frames: seek_fade_frames(output_sr), + flushes_owed: 0, meter: None, handle_bufs, stats: SourceStats::new(), + carry_over: false, }) } +fn volume_at_build(volumes: &HashMap>, id: &str) -> f32 { + volumes + .get(id) + .map_or(1.0, |v| f32::from_bits(v.load(Ordering::Relaxed))) +} + /// Cross-output fan-out plan: which effect nodes are computed once and shared /// via rings. `owner[n]` builds node `n` and publishes it; every output in /// `consumers[n]` reads it back as a ring-source. @@ -1801,7 +2840,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() @@ -1810,16 +2849,10 @@ pub(super) fn plan_cuts(valid: &ValidGraph, monitor_key: Option<&str>) -> CutPla .collect(); assign(&out.id, starts); } - // Monitor last: it reaches every analyzer, so shared nodes owned by a real + // Monitor last: it reaches every root, so shared nodes owned by a real // output are read from their ring and only monitor-only nodes stay local. if let Some(mk) = monitor_key { - let starts = valid - .effects - .iter() - .filter(|e| is_analyzer(&e.spec)) - .map(|e| e.id.clone()) - .collect(); - assign(mk, starts); + assign(mk, monitor_roots(valid)); } for v in consumers.values_mut() { @@ -1828,8 +2861,37 @@ pub(super) fn plan_cuts(valid: &ValidGraph, monitor_key: Option<&str>) -> CutPla CutPlan { owner, consumers } } -/// Analyzer effects are monitor-graph roots: they render telemetry and have no -/// audio successor, so the monitor sub-graph is everything that feeds one. +/// 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 +} + +/// What the monitor graph is built from: every analyzer, and every plugin no +/// output hears. An analyzer renders telemetry and has no audio successor; a +/// plugin can be an end point of its own (one that sends its input over the +/// network, say), so it runs whether or not anything follows it. +pub(super) fn monitor_roots(valid: &ValidGraph) -> Vec { + let heard: HashSet = valid + .outputs + .iter() + .flat_map(|o| reachable_backward(&o.id, valid)) + .collect(); + valid + .effects + .iter() + .filter(|e| { + is_analyzer(&e.spec) + || (matches!(e.spec, EffectSpec::Plugin { .. }) && !heard.contains(&e.id)) + }) + .map(|e| e.id.clone()) + .collect() +} + fn is_analyzer(spec: &EffectSpec) -> bool { matches!( spec, @@ -1916,26 +2978,28 @@ 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, splice_fade_frames, DelayLine, TIMER_BLOCK_FRAMES}; + + const SPLICE_FADE_FRAMES: usize = splice_fade_frames(48_000); // 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 +3014,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}"); } @@ -1997,34 +3061,49 @@ 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. - 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); } } @@ -2035,6 +3114,7 @@ pub(super) mod graph_tests { use crate::audio::graph::{EdgeSpec, EffectSpec, GraphSpec, NodeKind, NodeSpec, ValidGraph}; const SR: u32 = 48_000; + const SPLICE_FADE_FRAMES: usize = splice_fade_frames(SR); fn node(id: &str, kind: NodeKind, data: serde_json::Value) -> NodeSpec { NodeSpec { @@ -2075,9 +3155,28 @@ pub(super) mod graph_tests { } /// mic → gain(0 dB) → speaker, validated. + /// Runs every effect of `graph` as a light node on its own thread, its pad + /// sized for `burst_blocks` rendered back to back. + pub(in crate::audio::pipeline) fn offload_every_effect( + graph: &mut OutputGraph, + burst_blocks: usize, + ) { + use crate::audio::effects::offload::{Echo, Offload}; + let (block, rate) = (graph.block_frames(), graph.sample_rate()); + for node in &mut graph.nodes { + if let DagNode::Effect(e) = node { + let Ok(o) = Offload::spawn("test", Echo, 2, None, block, burst_blocks, rate) else { + panic!("spawn offload"); + }; + e.effects = vec![RuntimeEffect::Offloaded(o)]; + } + } + } + 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), @@ -2098,7 +3197,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() } @@ -2108,6 +3207,45 @@ 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>) { + build_wide( + output_id, + output_sr, + block_frames, + valid, + input_sr, + realtime, + 2, + ) + } + + /// `build_with_block` with every input delivering `channels` channels. + fn build_wide( + output_id: Option<&str>, + output_sr: u32, + block_frames: usize, + valid: &ValidGraph, + input_sr: u32, + realtime: bool, + channels: u32, ) -> (BuiltOutputGraph, HashMap>) { let mut producer_pairs = Vec::new(); let native = valid @@ -2115,11 +3253,17 @@ pub(super) mod graph_tests { .iter() .map(|i| (i.id.clone(), input_sr)) .collect(); - let native_ch = valid.inputs.iter().map(|i| (i.id.clone(), 2u32)).collect(); + let native_ch = valid + .inputs + .iter() + .map(|i| (i.id.clone(), channels)) + .collect(); let mut reg = fresh_registry(); let built = build_output_graph( output_id, output_sr, + block_frames, + 1, realtime, valid, &native, @@ -2131,6 +3275,8 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), ) .expect("build succeeds"); let mut map = HashMap::new(); @@ -2157,9 +3303,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 +3317,58 @@ 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] + fn a_file_joined_mid_play_starts_with_a_lead_instead_of_running_dry() { + // A graph that joins a file already playing is fed only as fast as + // it plays, a decoder packet at a time, each landing a block after + // it was wanted. Started empty it runs dry; started a block and a + // packet deep it never does. + let (mut valid, _) = passthrough_graph(); + valid.inputs[0].spec = InputSpec::AudioFile { + file_path: "/tone.wav".into(), + }; + let mut producer_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 mut reg = fresh_registry(); + let mut built = build_output_graph( + Some("s"), + SR, + TIMER_BLOCK_FRAMES, + 1, + true, + &valid, + &native, + &native_ch, + &mut producer_pairs, + &mut reg, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), + ) + .expect("build"); + let packet = vec![0.25f32; FILE_PACKET_FRAMES * 2]; + let per_packet = FILE_PACKET_FRAMES / TIMER_BLOCK_FRAMES; + let mut out = vec![0.0; TIMER_BLOCK_FRAMES * 2]; + let mut heard = 0; + for k in 0..64 { + if k % per_packet == 1 { + push_all(&mut producer_pairs[0].1, &packet); + } + built.graph.process_block(&mut out); + heard += out.iter().filter(|s| **s != 0.0).count(); + } + let xrun = built.sources[0].stats.xrun.load(Ordering::Relaxed); + assert_eq!(xrun, 0, "the file ran dry"); + assert!(heard > 0, "the file never started"); } #[test] @@ -2187,6 +3383,8 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, + 1, false, &valid, &native, @@ -2198,24 +3396,36 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), ) .expect("build"); let prod = &mut producer_pairs[0].1; 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); - // Drop the volume to 0.5 and confirm the next block scales. + // Drop the volume to 0.5: the next block glides there and holds it. 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]; - for (o, w) in out.iter().zip(want) { + let want = &fed[TIMER_BLOCK_FRAMES * 6..TIMER_BLOCK_FRAMES * 8]; + let glide = 5 * SR as usize / 1000; + for (o, w) in out.iter().zip(want).skip(glide * 2) { assert!((o - 0.5 * w).abs() < 1e-5, "{o} vs {w}"); } + let steps = out + .chunks_exact(2) + .zip(want.chunks_exact(2)) + .map(|(o, w)| o[0] / w[0].max(1e-6)) + .collect::>(); + assert!( + steps.windows(2).take(glide).all(|g| g[1] <= g[0] + 1e-4), + "the volume falls without stepping" + ); } #[test] @@ -2230,6 +3440,8 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, + 1, false, &valid, &native, @@ -2241,13 +3453,15 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), ) .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!( @@ -2257,10 +3471,10 @@ pub(super) mod graph_tests { } #[test] - fn drain_generation_change_flushes_the_source() { + fn a_seek_fades_the_old_position_out_and_the_new_one_in() { let (valid, _) = passthrough_graph(); - let mut drain: HashMap> = HashMap::new(); - drain.insert("m".to_string(), Arc::new(AtomicU64::new(0))); + let mut drain: HashMap> = HashMap::new(); + drain.insert("m".to_string(), Arc::default()); let mut producer_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(); @@ -2268,6 +3482,8 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, + 1, false, &valid, &native, @@ -2279,125 +3495,1490 @@ pub(super) mod graph_tests { &drain, &HashMap::new(), HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), ) .expect("build"); let prod = &mut producer_pairs[0].1; - push_all(prod, &stereo_ramp(4096, 0.0)); + push_all(prod, &vec![0.5; 4096 * 2]); + let fade = seek_fade_frames(SR); + let at = |buf: &[f32], frame: usize| buf[frame * 2]; - 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); + let before = drain["m"].request(); + // The old position fades out, then everything queued behind it goes, + // and the reader hears that it may go on. built.graph.process_block(&mut out); - assert_eq!(out, vec![0.0; DSP_BLOCK_FRAMES * 2]); - // The flushed ring stays empty: the next block is silence too. - let mut out2 = vec![0.0; DSP_BLOCK_FRAMES * 2]; - built.graph.process_block(&mut out2); - assert_eq!(out2, vec![0.0; DSP_BLOCK_FRAMES * 2]); + assert_eq!(at(&out, 0), 0.5, "the fade starts at full level"); + assert!((at(&out, fade / 2) - 0.25).abs() < 0.01); + assert!( + out[fade * 2..].iter().all(|s| *s == 0.0), + "silent once faded" + ); + assert_eq!(drain["m"].answered_since(before), 1); + // Waiting for the new position is silence, not an underrun. + let xruns = built.sources[0].stats.xrun.load(Ordering::Relaxed); + built.graph.process_block(&mut out); + assert!(out.iter().all(|s| *s == 0.0)); + assert_eq!(built.sources[0].stats.xrun.load(Ordering::Relaxed), xruns); + // The new position fades in from its first frame. + push_all(&mut producer_pairs[0].1, &vec![0.25; 2048 * 2]); + built.graph.process_block(&mut out); + assert_eq!(at(&out, 0), 0.0, "no step into the new position"); + assert!((at(&out, fade / 2) - 0.125).abs() < 0.01); + assert!(out[fade * 2..].iter().all(|s| *s == 0.25)); } - #[test] - fn limiter_latency_pads_the_parallel_path() { - // Two branches to one output: a passthrough gain and a limiter with - // lookahead. The graph must report the lookahead as its latency. - let g = GraphSpec { + /// 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, - 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), - ], - }; - // Replace one gain with a limiter spec by editing its node kind. - let mut valid = g.validate().expect("valid"); + 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: 2.0, + lookahead_ms, release_ms: 50.0, bypassed: false, }); } } - let (built, _) = build(Some("s"), SR, &valid, SR, false); - assert_eq!(built.graph.latency_frames(), 96, "2 ms lookahead @ 48k"); - // Both paths are padded to the same length → mix stays aligned. - assert_eq!(built.graph.active_output_channels(), 2); + valid } - #[test] - fn missing_sample_rate_is_a_validation_error() { - let g = GraphSpec { - sample_rate: None, - nodes: vec![mic("m"), speaker("s")], - edges: vec![edge("e1", "m", None, "s", None)], - }; - let valid = g.validate().expect("valid"); - let mut producer_pairs = Vec::new(); - let native = HashMap::new(); // no entry for "m" - let native_ch = HashMap::new(); - let mut reg = fresh_registry(); - let err = build_output_graph( + /// 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, - false, - &valid, + 64, + 1, + realtime, + valid, &native, &native_ch, - &mut producer_pairs, - &mut reg, + &mut pairs, + registry, &HashMap::new(), &HashMap::new(), &HashMap::new(), &HashMap::new(), HashMap::new(), - ); - assert!(err.is_err(), "input without SR must fail"); + previous, + &DepthMemos::default(), + ) + .expect("build succeeds"); + (built, pairs.into_iter().collect()) } #[test] - fn monitor_graph_has_no_terminals_and_tracks_blocks() { - let mut g = GraphSpec { - sample_rate: None, - nodes: vec![mic("m"), speaker("s")], - edges: vec![edge("e1", "m", None, "s", None)], - }; - // An analyzer is a monitor-only node. - g.nodes - .push(node("lm", NodeKind::LevelMeter, serde_json::json!({}))); - g.edges.push(edge("e2", "m", None, "lm", None)); - let valid = g.validate().expect("valid"); - 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]; - built.graph.process_block(&mut out); - assert_eq!( - built - .output - .blocks - .load(std::sync::atomic::Ordering::Relaxed), - 1 + 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, ); - // Monitor has no terminals: the active width floors at 1. - assert_eq!(built.graph.active_output_channels(), 1); + 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 fan_out_effect_is_computed_once_and_shared_via_ring() { - // mic → gain → spkrA, and gain → spkrB: the gain node is shared. - let g = GraphSpec { - sample_rate: None, - nodes: vec![mic("m"), gain_node("g", 0.0), speaker("a"), speaker("b")], + 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"); + } + } + + /// `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 bypassing_a_latent_effect_keeps_its_branch_in_time_and_fades() { + // Two branches of a 100 Hz tone meet at the speaker, one through a + // 5 ms lookahead: half a period. A bypass that dropped the lookahead + // would cancel them; one that switched hard would step. + let mut registry = fresh_registry(); + let (mut a, mut producers) = rebuild( + ¶llel_with_lookahead(false, 5.0, false), + &mut registry, + &HashMap::new(), + false, + ); + let bypass = a + .bypasses + .iter() + .find(|(id, _)| id == "l") + .map(|(_, b)| b.clone()) + .expect("limiter bypass"); + let tone: Vec = (0..SR as usize) + .flat_map(|i| { + let v = 0.25 * (std::f32::consts::TAU * 100.0 * i as f32 / SR as f32).sin(); + [v, v] + }) + .collect(); + push_all(producers.get_mut("m").unwrap(), &tone); + let before = render(&mut a.graph, 40); + bypass.store(true, Ordering::Relaxed); + let after = render(&mut a.graph, 60); + bypass.store(false, Ordering::Relaxed); + let back = render(&mut a.graph, 60); + let peak = |x: &[f32]| x.iter().fold(0.0f32, |m, s| m.max(s.abs())); + let tail = |x: &[f32]| peak(&x[x.len() / 2..]); + assert!((tail(&before) - 0.5).abs() < 0.02, "in: {}", tail(&before)); + assert!( + (tail(&after) - 0.5).abs() < 0.02, + "bypassed: {}", + tail(&after) + ); + assert!((tail(&back) - 0.5).abs() < 0.02, "back in: {}", tail(&back)); + let all: Vec = [before, after, back].concat(); + let step = all + .chunks_exact(2) + .map(|f| f[0]) + .collect::>() + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0.0f32, f32::max); + // A 100 Hz tone at 0.5 moves at most 0.0066 per sample. + assert!(step < 0.01, "a step of {step}"); + } + + #[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); + // Past the bypass crossfade. + let got = render(&mut b.graph, 12); + 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("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 + // 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), + 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), + ], + }; + // Replace one gain with a limiter spec by editing its node kind. + 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 (built, _) = build(Some("s"), SR, &valid, SR, false); + assert_eq!(built.graph.latency_frames(), 96, "2 ms lookahead @ 48k"); + // Both paths are padded to the same length → mix stays aligned. + 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, 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, + capture_ppm: f64, + seconds: f64, + settle: f64, + signal: impl Fn(usize) -> f32, + ) -> (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; + 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]; + let mut trimmed_at_settle = 0; + 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; + } + clock.record(chunk.len(), t_in); + assert_eq!(push_all(input, &chunk), chunk.len()); + fed += burst; + t_in += in_period; + continue; + } + 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; + } + 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 + /// 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, + } + + /// `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; + 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; + } + clock.record(chunk.len(), t_in); + push_all(input, &chunk); + fed += burst; + } + t_in += in_period; + continue; + } + 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.saturating_sub(played)), + 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)]; + 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() + .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_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() + .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(); + 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, + "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. + let early = worst_delay_between(&gappy, 6.0, 20.0); + let late = worst_delay_between(&gappy, 100.0, 125.0); + assert!( + late <= early + 64, + "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, false); + // Measured once the pauses have begun, so both sides include a resume. + let early = ticks + .iter() + .filter(|k| (5.0..10.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" + ); + } + + 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, + /// 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, + } + + /// 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); + 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; + 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, + 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 { + 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; + silent_since = true; + 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 { + 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 { + // Counting starts from the first frame played after settling. + prev_raw = out[(block - 1) * 2]; + last_played = 0; + } + 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_speaker_that_stalls_does_not_keep_what_piled_up() { + // Mic and speaker on one clock, 32-frame blocks. The speaker misses + // 85 ms of callbacks (a device overload) while the mic delivers on. + let block = 32; + 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 level = built.sources[0].stats.level.clone(); + let prod = producers.get_mut("m").expect("mic ring"); + let mut out = vec![0.0; block * 2]; + let period = block as f64 / SR as f64; + let (mut before, mut after) = (Vec::new(), Vec::new()); + let mut was_stalled = false; + let mut t = 0.0; + while t < 6.0 { + push_all(prod, &vec![0.5; block * 2]); + let stalled = (3.0..3.085).contains(&t); + if !stalled { + if was_stalled { + built.graph.output_stalled(); + } + built.graph.process_block_at(&mut out, t); + } + was_stalled = stalled; + let queued = level.load(Ordering::Relaxed) as f64 / 2.0; + if (2.5..3.0).contains(&t) { + before.push(queued); + } + if (5.5..6.0).contains(&t) { + after.push(queued); + } + t += period; + } + let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; + let (before, after) = (mean(&before), mean(&after)); + assert!( + after - before < 2.0 * block as f64, + "85 ms of backlog stayed: {before:.0} -> {after:.0} frames" + ); + } + + #[test] + 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); + // 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 <= 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 <= 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)], true); + 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 [-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), 0, "{ppm} ppm: ran dry"); + assert_eq!(spliced, 0, "{ppm} ppm: spliced once settled"); + } + } + + #[test] + 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] + 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, + 1, + true, + &valid, + &native, + &native_ch, + &mut pairs, + &mut reg, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), + ) + .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)], + }; + let valid = g.validate().expect("valid"); + let mut producer_pairs = Vec::new(); + let native = HashMap::new(); // no entry for "m" + let native_ch = HashMap::new(); + let mut reg = fresh_registry(); + let err = build_output_graph( + Some("s"), + SR, + TIMER_BLOCK_FRAMES, + 1, + false, + &valid, + &native, + &native_ch, + &mut producer_pairs, + &mut reg, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), + ); + assert!(err.is_err(), "input without SR must fail"); + } + + #[test] + 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)], + }; + // An analyzer is a monitor-only node. + g.nodes + .push(node("lm", NodeKind::LevelMeter, serde_json::json!({}))); + g.edges.push(edge("e2", "m", None, "lm", None)); + let valid = g.validate().expect("valid"); + 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; TIMER_BLOCK_FRAMES * 2]; + built.graph.process_block(&mut out); + assert_eq!( + built + .output + .blocks + .load(std::sync::atomic::Ordering::Relaxed), + 1 + ); + // Monitor has no terminals: the active width floors at 1. + assert_eq!(built.graph.active_output_channels(), 1); + } + + #[test] + fn fan_out_effect_is_computed_once_and_shared_via_ring() { + // 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), edge("e2", "g", None, "a", None), @@ -2419,11 +5000,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)); + 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(); @@ -2432,6 +5025,8 @@ pub(super) mod graph_tests { let mut built_b = build_output_graph( Some("b"), SR, + TIMER_BLOCK_FRAMES, + 1, false, &valid, &native, @@ -2443,18 +5038,20 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), cut_leaves, + &HashMap::new(), + &DepthMemos::default(), ) .expect("build B"); assert_eq!(built_b.sources.len(), 1, "the shared node is a ring source"); 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; 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 +5060,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,14 +5083,68 @@ 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); } + #[test] + fn a_net_sender_graph_streams_to_a_local_receiver_at_the_engine_block() { + // Chrome to a Net Sender on 127.0.0.1 and a Net Receiver on its port, + // the way the field report wired it, at 64-frame blocks. + let port = std::net::UdpSocket::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let rx = crate::audio::netaudio::receiver::get_or_create("loop-rx", port); + let _consumer = rx.register_consumer(SR, 64, true); + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + node( + "loop-tx", + NodeKind::NetSender, + serde_json::json!({ + "targetIp": "127.0.0.1", + "port": port, + "codec": "pcm-f32", + "opusBitrate": 96_000, + "opusApplication": "audio", + "channels": 2 + }), + ), + ], + edges: vec![ + edge("e1", "m", Some("ch1"), "loop-tx", Some("ch1")), + edge("e2", "m", Some("ch2"), "loop-tx", Some("ch2")), + ], + }; + let valid = g.validate().expect("send graph valid"); + let (built, mut producers) = build_with_block(Some("loop-tx"), SR, 64, &valid, SR, true); + assert_eq!(built.graph.block_frames(), 64); + let (worker, _ctrl) = + super::super::output::start_wire_sender_worker(built.graph).expect("worker"); + let input = producers.get_mut("m").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut packets = 0; + while packets < 20 && Instant::now() < deadline { + push_all(input, &vec![0.3; 480 * 2]); + std::thread::sleep(Duration::from_millis(10)); + packets = crate::audio::netaudio::receiver::stats("loop-rx").map_or(0, |s| s.packets); + } + worker.stop.store(true, Ordering::SeqCst); + crate::audio::netaudio::receiver::release("loop-rx"); + crate::audio::netaudio::sender::release("loop-tx"); + assert!(packets >= 20, "only {packets} packets arrived"); + } + #[test] fn file_recording_output_builds_terminals() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m"), node( @@ -2511,15 +5163,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 +5187,8 @@ pub(super) mod graph_tests { let mut built = build_output_graph( Some("s"), SR, + TIMER_BLOCK_FRAMES, + 1, false, &valid, &native, @@ -2545,13 +5200,15 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), + &DepthMemos::default(), ) .expect("build"); let mut a = stereo_ramp(4096, 0.0); 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 +5329,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 +5397,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 +5423,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 +5457,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 +5477,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 +5490,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 +5505,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 +5515,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 +5558,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 +5591,101 @@ pub(super) mod graph_tests { ); } + fn plugin_node(id: &str) -> NodeSpec { + node( + id, + NodeKind::Plugin, + serde_json::json!({ "format": null, "path": "", "pluginId": "", "bypassed": false, "state": null }), + ) + } + + #[test] + fn a_plugin_no_output_hears_still_runs() { + // A plugin can be its own end point (one that sends its input over the + // network): fed by the mic, followed by nothing. + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![mic("m"), speaker("s"), plugin_node("p")], + edges: vec![ + edge("e1", "m", None, "s", None), + edge("e2", "m", None, "p", None), + ], + }; + let valid = g.validate().expect("valid"); + assert_eq!(monitor_roots(&valid), vec!["p".to_string()]); + let plan = plan_cuts(&valid, Some(MONITOR_KEY)); + assert_eq!(plan.owner.get("p").map(String::as_str), Some(MONITOR_KEY)); + let (built, _) = build_with_block(None, SR, TIMER_BLOCK_FRAMES, &valid, SR, true); + assert!( + built + .graph + .nodes + .iter() + .any(|n| matches!(n, DagNode::Effect(_))), + "the monitor builds the plugin" + ); + } + + #[test] + fn a_plugin_an_output_hears_runs_there_alone() { + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![mic("m"), plugin_node("p"), speaker("s")], + edges: vec![ + edge("e1", "m", None, "p", None), + edge("e2", "p", None, "s", None), + ], + }; + let valid = g.validate().expect("valid"); + assert!( + monitor_roots(&valid).is_empty(), + "no monitor for a heard plugin" + ); + } + + #[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 +5714,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 +5741,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), @@ -2998,10 +5751,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)); + 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 @@ -3014,6 +5779,8 @@ pub(super) mod graph_tests { let mut built_b = build_output_graph( Some("b"), SR, + TIMER_BLOCK_FRAMES, + 1, false, &valid, &native, @@ -3025,6 +5792,8 @@ pub(super) mod graph_tests { &HashMap::new(), &HashMap::new(), cut_leaves, + &HashMap::new(), + &DepthMemos::default(), ) .expect("build B"); assert_eq!(built_b.sources[0].native_sr, 44_100); @@ -3032,12 +5801,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]; - built_a.graph.attach_tap(node_idx, prod); + let mut out_a = vec![0.0; TIMER_BLOCK_FRAMES * 2]; + 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. - 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] @@ -3051,4 +5820,251 @@ pub(super) mod graph_tests { ); assert!(out_b.iter().all(|s| s.is_finite())); } + + /// Every effect with its default parameters, as the editor creates it. + fn every_effect() -> Vec<(NodeKind, serde_json::Value)> { + use serde_json::json; + vec![ + (NodeKind::Gain, json!({ "gainDb": 0 })), + (NodeKind::Mute, json!({ "muted": false })), + ( + NodeKind::ChannelBalance, + json!({ "leftGainDb": 0, "rightGainDb": 0 }), + ), + ( + NodeKind::Saturator, + json!({ "thresholdDb": -0.3, "driveDb": 0 }), + ), + ( + NodeKind::Eq, + json!({ "gainsDb": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }), + ), + (NodeKind::LevelMeter, json!({})), + (NodeKind::LufsMeter, json!({ "target": -14 })), + (NodeKind::Waveform, json!({ "segs": 4 })), + (NodeKind::Spectrum, json!({ "smoothing": 0.5 })), + ( + NodeKind::Limiter, + json!({ "ceilingDb": -0.3, "lookaheadMs": 5, "releaseMs": 50 }), + ), + ( + NodeKind::Compressor, + json!({ "thresholdDb": -18, "ratio": 3, "attackMs": 10, "releaseMs": 100, + "kneeDb": 6, "makeupDb": 0 }), + ), + ( + NodeKind::NoiseGate, + json!({ "thresholdDb": -40, "rangeDb": -40, "attackMs": 1, "holdMs": 50, + "releaseMs": 200 }), + ), + ( + NodeKind::Delay, + json!({ "timeMs": 250, "feedback": 0.4, "mix": 0.35 }), + ), + ( + NodeKind::Reverb, + json!({ "roomSize": 0.5, "damping": 0.5, "width": 1, "mix": 0.33 }), + ), + ( + NodeKind::NoiseSuppressor, + json!({ "attenuationLimitDb": 100, "postFilterBeta": 0, "minThreshDb": -10, + "maxErbThreshDb": 30, "maxDfThreshDb": 20 }), + ), + ( + NodeKind::Declick, + json!({ "sensitivity": 0.5, "maxWidthMs": 2 }), + ), + ( + NodeKind::DeEsser, + json!({ "frequency": 6500, "thresholdDb": -30, "ratio": 4 }), + ), + ] + } + + const FX_BLOCK: usize = 256; + + /// mic (`channels` wide) -> `kind` -> speaker, fed `input` (interleaved at + /// `channels`). Returns the effect node's width, the graph's latency and + /// everything the effect put out, interleaved at its width. + fn run_effect_wide( + kind: NodeKind, + data: &serde_json::Value, + channels: usize, + input: &[f32], + ) -> (usize, usize, Vec) { + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![mic("m"), node("fx", kind, data.clone()), speaker("s")], + edges: vec![ + edge("e1", "m", None, "fx", None), + edge("e2", "fx", None, "s", None), + ], + }; + let valid = g.validate().expect("valid"); + let (mut built, mut producers) = + build_wide(Some("s"), SR, FX_BLOCK, &valid, SR, false, channels as u32); + push_all(producers.get_mut("m").expect("mic ring"), input); + let latency = built.graph.latency_frames(); + let mut out = vec![0.0; FX_BLOCK * built.graph.out_channels().max(2)]; + let mut got = Vec::new(); + let mut width = 0; + for _ in 0..input.len() / channels / FX_BLOCK { + built.graph.process_block(&mut out); + let fx = built + .graph + .nodes + .iter() + .find_map(|n| match n { + DagNode::Effect(e) => Some(&e.out_buf), + _ => None, + }) + .expect("effect node"); + width = fx.len() / FX_BLOCK; + got.extend_from_slice(fx); + } + (width, latency, got) + } + + /// Half a second of a distinct tone per channel: 0.2 amplitude, the + /// channel's own pitch, so a channel landing on another shows. + fn tones(channels: usize, frames: usize, only: Option<&[usize]>) -> Vec { + let mut out = vec![0.0; frames * channels]; + for f in 0..frames { + for c in 0..channels { + if only.is_some_and(|o| !o.contains(&c)) { + continue; + } + let hz = 220.0 * (c + 1) as f32; + let t = f as f32 / SR as f32; + out[f * channels + c] = 0.2 * (std::f32::consts::TAU * hz * t).sin(); + } + } + out + } + + fn channel(buf: &[f32], width: usize, c: usize) -> Vec { + buf.iter().skip(c).step_by(width).copied().collect() + } + + fn energy(samples: &[f32]) -> f64 { + samples.iter().map(|s| (*s as f64).powi(2)).sum() + } + + const FX_FRAMES: usize = FX_BLOCK * 94; + + /// Interleaves per-channel signals. + fn interleave(chans: &[&[f32]]) -> Vec { + let frames = chans[0].len(); + (0..frames * chans.len()) + .map(|i| chans[i % chans.len()][i / chans.len()]) + .collect() + } + + fn assert_close(got: &[f32], want: &[f32], case: &str) { + assert_eq!(got.len(), want.len(), "{case}: length"); + for (i, (g, w)) in got.iter().zip(want).enumerate() { + assert!((g - w).abs() < 1e-5, "{case}: frame {i}: {g} vs {w}"); + } + } + + #[test] + fn every_effect_runs_mono_stereo_and_wide() { + for (kind, data) in every_effect() { + for channels in [1usize, 2, 6] { + let input = tones(channels, FX_FRAMES, None); + let (width, _, got) = run_effect_wide(kind, &data, channels, &input); + let case = format!("{kind:?} {channels}ch"); + assert_eq!(width, channels, "{case}: width"); + assert_eq!(got.len(), input.len(), "{case}: length"); + assert!(got.iter().all(|s| s.is_finite()), "{case}: non-finite"); + // A steady tone is exactly what a noise suppressor removes. + if kind == NodeKind::NoiseSuppressor { + continue; + } + let tail = FX_FRAMES / 2; + for c in 0..channels { + let out = energy(&channel(&got, width, c)[tail..]); + let inp = energy(&channel(&input, channels, c)[tail..]); + assert!(out > 0.25 * inp, "{case}: channel {c} lost its tone"); + } + } + } + } + + #[test] + fn every_pair_of_a_wide_node_plays_like_a_stereo_node() { + let left = channel(&tones(1, FX_FRAMES, None), 1, 0); + let right: Vec = channel(&tones(2, FX_FRAMES, None), 2, 1); + let stereo = interleave(&[&left, &right]); + let wide = interleave(&[&left, &right, &left, &right, &left, &right]); + for (kind, data) in every_effect() { + let (_, _, want) = run_effect_wide(kind, &data, 2, &stereo); + let (width, _, got) = run_effect_wide(kind, &data, 6, &wide); + for c in 0..width { + let case = format!("{kind:?} channel {c}"); + assert_close(&channel(&got, width, c), &channel(&want, 2, c % 2), &case); + } + } + } + + #[test] + fn no_effect_leaks_one_pair_into_another() { + for (kind, data) in every_effect() { + let input = tones(6, FX_FRAMES, Some(&[0, 1])); + let (width, _, got) = run_effect_wide(kind, &data, 6, &input); + for c in 2..width { + let leaked = energy(&channel(&got, width, c)); + assert!(leaked < 1e-12, "{kind:?}: {leaked} reached channel {c}"); + } + } + } + + #[test] + fn mono_plays_like_a_centred_stereo_signal() { + let mono = channel(&tones(1, FX_FRAMES, None), 1, 0); + let centred = interleave(&[&mono, &mono]); + for (kind, data) in every_effect() { + let (_, _, want) = run_effect_wide(kind, &data, 2, ¢red); + let (_, _, got) = run_effect_wide(kind, &data, 1, &mono); + assert_close(&got, &channel(&want, 2, 0), &format!("{kind:?}")); + } + } + + #[test] + fn neutral_settings_pass_every_channel_through() { + let neutral = [ + NodeKind::Gain, + NodeKind::Mute, + NodeKind::ChannelBalance, + NodeKind::Eq, + NodeKind::LevelMeter, + NodeKind::LufsMeter, + NodeKind::Waveform, + NodeKind::Spectrum, + NodeKind::Limiter, + NodeKind::NoiseGate, + ]; + for (kind, data) in every_effect() { + if !neutral.contains(&kind) { + continue; + } + for channels in [1usize, 2, 6] { + let input = tones(channels, FX_FRAMES, None); + let (width, latency, got) = run_effect_wide(kind, &data, channels, &input); + // Past the gate opening and the lookahead filling. + let from = 4_096; + for c in 0..width { + let out = channel(&got, width, c); + let inp = channel(&input, channels, c); + let case = format!("{kind:?} {channels}ch channel {c}"); + assert_close( + &out[from..], + &inp[from - latency..inp.len() - latency], + &case, + ); + } + } + } + } } diff --git a/src-tauri/src/audio/pipeline/file_reader.rs b/src-tauri/src/audio/pipeline/file_reader.rs index deff063..c54abf0 100644 --- a/src-tauri/src/audio/pipeline/file_reader.rs +++ b/src-tauri/src/audio/pipeline/file_reader.rs @@ -1,6 +1,6 @@ use std::fs::File; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -15,7 +15,7 @@ use symphonia::core::formats::{ }; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; -use symphonia::core::units::{Time, Timestamp}; +use symphonia::core::units::{Time, TimeBase, Timestamp}; use tauri::{AppHandle, Emitter}; use tracing::{info, warn}; @@ -27,11 +27,51 @@ const BACKOFF_WHEN_FULL: Duration = Duration::from_micros(200); /// source's backlog cushion, deep enough that a scheduler hiccup on either side /// never runs it dry. const PACE_QUEUE_MS: usize = 120; +/// The last frame fades to silence over this at end of file. +const EOF_FADE_MS: usize = 3; /// Cap on how long end-of-file waits for the queued tail to play out. const EOF_DRAIN_MAX: Duration = Duration::from_secs(1); const PROGRESS_INTERVAL: Duration = Duration::from_millis(100); -const PROGRESS_EVENT: &str = "audio://audio_file_progress"; +pub(super) const PROGRESS_EVENT: &str = "audio://audio_file_progress"; const SEEK_NONE: i64 = -1; +/// Longest a seek waits for the graphs to let go of the old position. Only a +/// graph that stopped reading waits this long. +const SEEK_FLUSH_MAX: Duration = Duration::from_millis(250); + +/// The handshake a seek runs with every graph reading the file. The reader +/// asks for the old position's queued audio to go; each source fades it out, +/// drops it and answers; only then does the reader hand over the new +/// position. Dropping on the command alone races the reader, which may still +/// be pushing old audio, and a source that happened to run dry would take +/// the new audio for the old. +#[derive(Default)] +pub(crate) struct SeekFlush { + requested: AtomicU64, + answered: AtomicU64, +} + +impl SeekFlush { + /// Flushes asked for so far; a change means a new one. + pub(crate) fn requested(&self) -> u64 { + self.requested.load(Ordering::Acquire) + } + + /// A source finished `n` flushes. + pub(crate) fn answer(&self, n: u64) { + self.answered.fetch_add(n, Ordering::Release); + } + + /// Asks for a flush; returns the answers counted before it. + pub(crate) fn request(&self) -> u64 { + let before = self.answered.load(Ordering::Acquire); + self.requested.fetch_add(1, Ordering::AcqRel); + before + } + + pub(crate) fn answered_since(&self, before: u64) -> u64 { + self.answered.load(Ordering::Acquire).saturating_sub(before) + } +} /// Progress payloads go to the frontend via Tauri; tests drive the reader /// with a recording emitter instead of an `AppHandle`. @@ -60,12 +100,21 @@ impl AudioFileReader { pub(super) fn loop_enabled(&self) -> Arc { self.loop_enabled.clone() } + + /// Wakes a paused reader to look at its controls again: a resume or a + /// seek is taken at once rather than at its next progress tick. + pub(super) fn wake(&self) { + if let Some(j) = &self.join { + j.thread().unpark(); + } + } } impl Drop for AudioFileReader { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); if let Some(j) = self.join.take() { + j.thread().unpark(); let _ = j.join(); } } @@ -137,6 +186,7 @@ pub(super) fn start_audio_file_reader( bridge: BroadcastRx, initial_loop: bool, paused: Arc, + flush: Option>, app: E, ) -> AppResult { let stop = Arc::new(AtomicBool::new(false)); @@ -158,6 +208,7 @@ pub(super) fn start_audio_file_reader( &seek_to_thread, &loop_enabled_thread, &paused_thread, + flush.as_deref(), &app, ) { warn!(path = %path.display(), error = %e, "audio file reader failed"); @@ -180,6 +231,11 @@ struct OpenedDecoder { sample_rate: u32, channels: usize, total_frames: u64, + time_base: Option, + /// Frames still to drop from the next decodes: a seek lands on the packet + /// holding its target, and what that packet carries before the target is + /// not part of the position asked for. + skip: u64, /// A broken Xing/Info tag can report a zero frame count; symphonia's mpa /// demuxer then treats the track as ending at timestamp 0 and trims every /// packet to zero length, so decode yields silent empty buffers and the @@ -302,6 +358,11 @@ fn open_decoder(path: &Path) -> AppResult { decoder.reset(); } + let time_base = format + .tracks() + .iter() + .find(|t| t.id == track_id) + .and_then(|t| t.time_base); Ok(OpenedDecoder { format, decoder, @@ -309,6 +370,8 @@ fn open_decoder(path: &Path) -> AppResult { sample_rate, channels, total_frames, + time_base, + skip: 0, fix_gapless_trim, }) } @@ -365,17 +428,49 @@ fn scan_frames( total } +/// Waits until `consumers` sources have answered the flush asked for after +/// `before` answers. +fn await_flush( + flush: &SeekFlush, + before: u64, + consumers: usize, + stop: &AtomicBool, + paused: &AtomicBool, +) { + let deadline = Instant::now() + SEEK_FLUSH_MAX; + while flush.answered_since(before) < consumers as u64 { + if stop.load(Ordering::SeqCst) || paused.load(Ordering::SeqCst) { + return; + } + if Instant::now() >= deadline { + warn!( + answered = flush.answered_since(before), + consumers, "seek went ahead before every graph let go of the old position" + ); + return; + } + thread::sleep(BACKOFF_WHEN_FULL); + } +} + fn do_seek(od: &mut OpenedDecoder, target_frame: u64) { let secs_f64 = target_frame as f64 / od.sample_rate as f64; let time = Time::try_from_secs_f64(secs_f64).unwrap_or(Time::ZERO); + od.skip = 0; match od.format.seek( - SeekMode::Coarse, + SeekMode::Accurate, SeekTo::Time { time, track_id: Some(od.track_id), }, ) { - Ok(_) => {} + Ok(SeekedTo { actual_ts, .. }) => { + let landed = od + .time_base + .and_then(|tb| tb.calc_time(actual_ts)) + .map(|t| (t.as_secs_f64() * od.sample_rate as f64).round() as u64); + od.skip = landed.map_or(0, |at| target_frame.saturating_sub(at)); + } Err(e) => warn!("seek failed: {e}"), } od.decoder.reset(); @@ -400,6 +495,7 @@ fn run( seek_to: &AtomicI64, loop_enabled: &AtomicBool, paused: &AtomicBool, + flush: Option<&SeekFlush>, app: &E, ) -> AppResult<()> { let mut od = open_decoder(path)?; @@ -477,19 +573,25 @@ fn run( ); last_paused_progress = Instant::now(); } - thread::sleep(Duration::from_millis(10)); + thread::park_timeout(PROGRESS_INTERVAL); continue; } last_paused_progress = Instant::now(); let pending = seek_to.swap(SEEK_NONE, Ordering::SeqCst); if pending >= 0 { + // Asked before the decoder moves, so the graphs fade the old + // position out while it does. + let asked = flush.map(|f| (f, f.request(), bridge.active_consumers())); let target = clamp_frame(pending as u64, od.total_frames); if target == 0 { reopen_decoder(&mut od, path); } else { do_seek(&mut od, target); } + if let Some((f, before, consumers)) = asked { + await_flush(f, before, consumers, stop, paused); + } frames_played = target; emit_progress( app, @@ -508,7 +610,10 @@ fn run( // a per-chunk wall-clock delay after the push below. let mut unrouted = false; loop { - if stop.load(Ordering::SeqCst) || paused.load(Ordering::SeqCst) { + if stop.load(Ordering::SeqCst) + || paused.load(Ordering::SeqCst) + || seek_to.load(Ordering::SeqCst) != SEEK_NONE + { break; } match bridge.max_queued() { @@ -520,7 +625,11 @@ fn run( } } } - if stop.load(Ordering::SeqCst) || paused.load(Ordering::SeqCst) { + // A seek that arrived while waiting is taken before decoding on. + if stop.load(Ordering::SeqCst) + || paused.load(Ordering::SeqCst) + || seek_to.load(Ordering::SeqCst) != SEEK_NONE + { continue; } @@ -546,10 +655,10 @@ fn run( continue; } // Fade out to avoid a hard click at end of file. - const FADE_FRAMES: usize = 128; - let mut fade_buf = vec![0.0f32; FADE_FRAMES * ch]; - for f in 0..FADE_FRAMES { - let t = 1.0 - (f as f32 + 1.0) / FADE_FRAMES as f32; + let fade_frames = (od.sample_rate as usize * EOF_FADE_MS / 1000).max(1); + let mut fade_buf = vec![0.0f32; fade_frames * ch]; + for f in 0..fade_frames { + let t = 1.0 - (f as f32 + 1.0) / fade_frames as f32; for c in 0..ch { fade_buf[f * ch + c] = last_frame[c] * t; } @@ -594,10 +703,13 @@ fn run( } if last_progress.elapsed() >= PROGRESS_INTERVAL { + // Decoded ahead is not played yet: the position shown is the one + // being heard, behind what was handed over by what still waits. + let ahead = bridge.min_queued().unwrap_or(0) / ch.max(1); emit_progress( app, &node_id, - frames_played, + frames_played.saturating_sub(ahead as u64), od.total_frames, od.sample_rate, od.channels as u32, @@ -675,7 +787,15 @@ fn decode_next( } } - return Ok(frames); + let skip = od.skip.min(frames as u64) as usize; + if skip > 0 { + od.skip -= skip as u64; + if skip == frames { + continue; + } + out.copy_within(skip * dst_ch..frames * dst_ch, 0); + } + return Ok(frames - skip); } } @@ -736,6 +856,93 @@ mod tests { } } + struct Quiet; + + impl ProgressEmitter for Quiet { + fn emit_progress(&self, _: serde_json::Value) {} + } + + #[test] + fn nothing_from_before_a_seek_reaches_the_graph_after_it() { + let path = temp_path("seek.wav"); + let _ = std::fs::remove_file(&path); + // Each frame carries its own index, so any sample says where it is from. + let frames = 96_000; + let pos = |f: usize| f as f32 / 1_000_000.0; + let block: Vec = (0..frames).flat_map(|f| [pos(f), pos(f)]).collect(); + let mut enc = build_encoder( + &path, + 48_000, + 2, + RecordingFormat::Wav { + bit_depth: WavBitDepth::F32, + }, + false, + ) + .unwrap(); + enc.write_interleaved(&block).unwrap(); + enc.finalize().unwrap(); + + let (mut tx, rx) = crate::audio::input_bridge::broadcast_channel(); + let (prod, mut cons) = rtrb::RingBuffer::::new(48_000 * 2); + tx.add(prod).unwrap(); + let flush = Arc::new(SeekFlush::default()); + let reader = start_audio_file_reader( + "f".into(), + path.clone(), + rx, + false, + Arc::new(AtomicBool::new(false)), + Some(flush.clone()), + Quiet, + ) + .unwrap(); + + // A source reading in real time, answering flushes the way the graph does. + let target = 60_000; + let mut seen = flush.requested(); + let mut asked = false; + let mut after: Vec = Vec::new(); + let started = Instant::now(); + while after.len() < 4_096 && started.elapsed() < Duration::from_secs(5) { + thread::sleep(Duration::from_millis(2)); + if !asked && started.elapsed() > Duration::from_millis(150) { + asked = true; + reader.seek_to().store(target as i64, Ordering::SeqCst); + } + if flush.requested() != seen { + seen = flush.requested(); + let n = cons.slots(); + cons.read_chunk(n).unwrap().commit_all(); + flush.answer(1); + after.push(f32::NAN); + continue; + } + let n = cons.slots().min(192); + let Ok(chunk) = cons.read_chunk(n) else { + continue; + }; + let (a, b) = chunk.as_slices(); + if !after.is_empty() { + after.extend_from_slice(a); + after.extend_from_slice(b); + } + chunk.commit_all(); + } + drop(reader); + let _ = std::fs::remove_file(&path); + let after = &after[1..]; + assert!(after.len() >= 4_096, "the new position never arrived"); + assert_eq!( + after[0], + pos(target), + "the first sample after a seek is its target" + ); + for pair in after.chunks_exact(2).collect::>().windows(2) { + assert!(pair[1][0] > pair[0][0], "old audio after the seek"); + } + } + #[test] fn open_decoder_reports_real_frame_count() { let path = temp_path("open.wav"); @@ -1124,6 +1331,7 @@ mod tests { &seek_to, &loop_enabled, &paused, + None, &emitter, ) .expect("reader reaches EOF"); diff --git a/src-tauri/src/audio/pipeline/host.rs b/src-tauri/src/audio/pipeline/host.rs new file mode 100644 index 0000000..f250952 --- /dev/null +++ b/src-tauri/src/audio/pipeline/host.rs @@ -0,0 +1,57 @@ +//! What a pipeline needs from outside the engine: somewhere to send UI events, +//! and audio devices. The app hands it its window and the system's devices; +//! tests hand it a recorder of events and virtual devices, so a whole pipeline +//! runs where there is no sound hardware at all. + +use std::sync::Arc; + +use tauri::{AppHandle, Emitter}; + +use super::file_reader::ProgressEmitter; +use super::virtual_io::VirtualDevices; + +type EventSink = Arc; + +#[derive(Clone)] +pub struct Host { + events: EventSink, + devices: Option>, +} + +impl Host { + /// The app's window for events, the system's devices for audio. + pub fn app(app: AppHandle) -> Self { + Self { + events: Arc::new(move |event, payload| { + let _ = app.emit(event, payload); + }), + devices: None, + } + } + + /// `devices` stand in for every microphone, capture and speaker the + /// graph names; `events` receives what the UI would. + pub fn with_virtual_devices( + devices: Arc, + events: impl Fn(&str, serde_json::Value) + Send + Sync + 'static, + ) -> Self { + Self { + events: Arc::new(events), + devices: Some(devices), + } + } + + pub fn emit(&self, event: &str, payload: serde_json::Value) { + (self.events)(event, payload); + } + + pub(super) fn virtual_devices(&self) -> Option<&Arc> { + self.devices.as_ref() + } +} + +impl ProgressEmitter for Host { + fn emit_progress(&self, payload: serde_json::Value) { + self.emit(super::file_reader::PROGRESS_EVENT, payload); + } +} diff --git a/src-tauri/src/audio/pipeline/input/linux.rs b/src-tauri/src/audio/pipeline/input/linux.rs index da9654a..fe68209 100644 --- a/src-tauri/src/audio/pipeline/input/linux.rs +++ b/src-tauri/src/audio/pipeline/input/linux.rs @@ -1,13 +1,14 @@ use std::sync::atomic::AtomicBool; use std::sync::Arc; -use tauri::AppHandle; use tracing::info; use crate::audio::graph::{InputSpec, ValidInput}; use crate::audio::input_bridge::BroadcastRx; use crate::error::AppResult; +use super::super::file_reader::SeekFlush; +use super::super::host::Host; use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; pub(in crate::audio::pipeline) fn resolve_input( @@ -43,13 +44,32 @@ 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 +} + +/// 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 +} + +#[allow(clippy::too_many_arguments)] pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, bridge: BroadcastRx, paused: Option>, + flush: Option>, meter: Option, - app: &AppHandle, + io_frames: u32, + host: &Host, ) -> AppResult { match resolved { ResolvedInput::PwSource { @@ -68,21 +88,37 @@ pub(in crate::audio::pipeline) fn start_input_stream( }; let capture = if let Some(sink) = node_id.strip_prefix("monitor:") { info!(sink, "starting microphone capture (PipeWire sink monitor)"); - crate::audio::capture::Capture::start_sink_monitor(sink, sample_rate, channels, cb)? + crate::audio::capture::Capture::start_sink_monitor( + sink, + sample_rate, + channels, + io_frames, + cb, + )? } else { info!(%node_id, "starting microphone capture (PipeWire source)"); - crate::audio::capture::Capture::start_source(&node_id, sample_rate, channels, cb)? + crate::audio::capture::Capture::start_source( + &node_id, + sample_rate, + channels, + io_frames, + cb, + )? }; Ok(InputHandle::Capture(capture)) } ResolvedInput::SystemAudio { sample_rate, .. } => { info!("starting system-audio capture (PipeWire sink monitor)"); let mut bridge = bridge; - let capture = - crate::audio::capture::Capture::start_system(sample_rate, 2, move |samples| { + let capture = crate::audio::capture::Capture::start_system( + sample_rate, + 2, + io_frames, + move |samples| { bridge.apply_commands(); bridge.broadcast(samples); - })?; + }, + )?; Ok(InputHandle::Capture(capture)) } ResolvedInput::AppAudio { @@ -95,6 +131,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( &bundle_id, sample_rate, 2, + io_frames, move |samples| { bridge.apply_commands(); bridge.broadcast(samples); @@ -103,7 +140,8 @@ pub(in crate::audio::pipeline) fn start_input_stream( Ok(InputHandle::Capture(capture)) } ResolvedInput::AudioFile { path, .. } => { - start_audio_file(node_id, path, bridge, paused, app) + start_audio_file(node_id, path, bridge, paused, flush, host) } + ResolvedInput::Virtual { .. } => unreachable!("virtual inputs start in input::mod"), } } diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs index 6fa4896..3e62aad 100644 --- a/src-tauri/src/audio/pipeline/input/macos.rs +++ b/src-tauri/src/audio/pipeline/input/macos.rs @@ -2,7 +2,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use serde_json::json; -use tauri::{AppHandle, Emitter}; use tracing::error; use crate::audio::device::{self, DeviceKind}; @@ -13,8 +12,13 @@ use crate::audio::input_bridge::BroadcastRx; use crate::audio::streams; use crate::error::{AppError, AppResult}; +use super::super::file_reader::SeekFlush; +use super::super::host::Host; +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,13 +67,65 @@ pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult Option { + let ResolvedInput::Cpal { + device, + sample_rate, + .. + } = resolved + else { + return None; + }; + 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"); + 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), + }) +} + +/// 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. +#[allow(clippy::too_many_arguments)] pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, bridge: BroadcastRx, paused: Option>, + flush: Option>, meter: Option, - app: &AppHandle, + io_frames: u32, + host: &Host, ) -> AppResult { match resolved { ResolvedInput::Cpal { @@ -81,19 +137,21 @@ pub(in crate::audio::pipeline) fn start_input_stream( } => { let dead = Arc::new(AtomicBool::new(false)); let dead_cb = dead.clone(); - let app_err = app.clone(); + let host_err = host.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; } health::bump(&health::STREAM_ERRORS, 1); error!(node_id = %node_id_cb, error = %e, "input stream error"); - let _ = app_err.emit( + host_err.emit( "audio://input_error", json!({ "nodeId": node_id_cb, "error": format!("{e}") }), ); }; + let name = crate::audio::device::cpal_name(&device).unwrap_or_default(); + let formats = macos_hal::physical_formats(DeviceKind::Input, &name); let stream = streams::build_input_stream( &device, &config, @@ -103,6 +161,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( meter, err_cb, )?; + macos_hal::warn_if_physical_format_changed(DeviceKind::Input, &name, &formats); Ok(InputHandle::Cpal(stream)) } ResolvedInput::SystemAudio { @@ -112,6 +171,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)?; @@ -121,13 +181,18 @@ 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)) } ResolvedInput::AudioFile { path, .. } => { - start_audio_file(node_id, path, bridge, paused, app) + start_audio_file(node_id, path, bridge, paused, flush, host) } + ResolvedInput::Virtual { .. } => unreachable!("virtual inputs start in input::mod"), } } diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index 4991ca0..0788943 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -4,20 +4,23 @@ use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; +use super::host::Host; +use super::virtual_io::{self, DeviceClock, VirtualInput}; #[cfg(any(target_os = "macos", target_os = "windows"))] use cpal::traits::StreamTrait; use rtrb::RingBuffer; -use tauri::AppHandle; #[cfg(any(target_os = "macos", target_os = "windows"))] use tracing::warn; use crate::audio::effects::{update_meter, MeterHandle}; use crate::audio::input_bridge::{broadcast_channel, BroadcastRx}; use crate::audio::resample::MultiResampler; +use crate::audio::wake::Doorbell; 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::dag::ring_capacity_frames; +use super::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader, SeekFlush}; +use super::output::device_block; #[cfg(target_os = "macos")] mod macos; @@ -32,8 +35,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, same_clock}; /// RAII handle held only for its `Drop` -- stops the cpal stream, tears /// down the capture, or signals + joins the file reader thread. @@ -44,17 +47,20 @@ pub(super) enum InputHandle { Capture(crate::audio::capture::Capture), AudioFile(AudioFileReader), Normalized(NormalizedInput), + Virtual(DeviceClock), } pub(super) struct NormalizedInput { _input: Box, stop: Arc, + bell: Arc, join: Option>, } impl Drop for NormalizedInput { fn drop(&mut self) { self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + self.bell.ring(); if let Some(join) = self.join.take() { let _ = join.join(); } @@ -136,6 +142,11 @@ pub(super) enum ResolvedInput { channels: u32, path: PathBuf, }, + /// A device that exists only in memory (see `virtual_io`). + Virtual { + id: String, + input: VirtualInput, + }, } impl ResolvedInput { @@ -148,6 +159,7 @@ impl ResolvedInput { ResolvedInput::SystemAudio { sample_rate, .. } => *sample_rate, ResolvedInput::AppAudio { sample_rate, .. } => *sample_rate, ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, + ResolvedInput::Virtual { input, .. } => input.sample_rate, } } @@ -160,6 +172,7 @@ impl ResolvedInput { #[cfg(target_os = "linux")] ResolvedInput::PwSource { channels, .. } => (*channels).max(1), ResolvedInput::AudioFile { channels, .. } => (*channels).max(1), + ResolvedInput::Virtual { input, .. } => input.channels.max(1), _ => 2, } } @@ -182,7 +195,8 @@ pub(super) fn start_audio_file( path: PathBuf, bridge: BroadcastRx, paused: Option>, - app: &AppHandle, + flush: Option>, + host: &Host, ) -> AppResult { // Loop is a runtime atomic, not in InputSpec; frontend syncs it // via `set_audio_file_loop` after pipeline start. @@ -193,20 +207,70 @@ pub(super) fn start_audio_file( bridge, false, paused_arc, - app.clone(), + flush, + host.clone(), )?; Ok(InputHandle::AudioFile(reader)) } +/// Starts the device, capture or file itself, feeding `bridge` at its own +/// rate. A virtual device starts here; everything else on its platform. +#[allow(clippy::too_many_arguments)] +fn start_raw_input( + node_id: &str, + resolved: ResolvedInput, + bridge: BroadcastRx, + paused: Option>, + flush: Option>, + meter: Option, + io_frames: u32, + host: &Host, +) -> AppResult { + if let ResolvedInput::Virtual { id, input } = &resolved { + let clock = virtual_io::start_capture(id, input, io_frames as usize, bridge, meter)?; + return Ok(InputHandle::Virtual(clock)); + } + start_native_input_stream( + node_id, resolved, bridge, paused, flush, meter, io_frames, host, + ) +} + +/// Longest the normalizer sleeps without its capture ringing: only a capture +/// that stopped delivering waits this long. +const NORMALIZER_IDLE_WAKE: Duration = Duration::from_millis(20); + +/// Frames the capture normalizer holds back, at the target 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, + block_frames: usize, +) -> u64 { + let native = resolved.sample_rate(); + if matches!(resolved, ResolvedInput::AudioFile { .. }) || native == target_sample_rate { + return 0; + } + let chunk = device_block(block_frames, target_sample_rate, native) as usize; + // The chunk is gathered in device frames; the filter delay is reported + // in output frames. + let chunk_out = (chunk as u64 * target_sample_rate as u64).div_ceil(native.max(1) as u64); + MultiResampler::new(native, target_sample_rate, chunk, 1) + .map_or(0, |r| chunk_out + r.delay_frames() as u64) +} + +/// Resamples `chunk` frames at a time: one capture buffer, so a chunk is +/// ready as soon as a delivery lands. fn input_resampler( native_rate: u32, target_sample_rate: u32, + chunk: usize, channels: usize, ) -> AppResult> { if native_rate == target_sample_rate { Ok(None) } else { - MultiResampler::new(native_rate, target_sample_rate, RESAMPLE_CHUNK, channels).map(Some) + MultiResampler::new(native_rate, target_sample_rate, chunk, channels).map(Some) } } @@ -220,40 +284,75 @@ pub(super) fn start_input_stream( bridge: BroadcastRx, target_sample_rate: u32, paused: Option>, + flush: Option>, meter: Option, - app: &AppHandle, + block_frames: usize, + host: &Host, ) -> 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_raw_input( + node_id, resolved, bridge, paused, flush, meter, io_frames, host, + ); + } + if matches!(resolved, ResolvedInput::Virtual { .. }) + && resolved.sample_rate() == target_sample_rate + { + return start_raw_input( + node_id, resolved, bridge, paused, flush, meter, io_frames, host, + ); + } + // 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_raw_input( + node_id, resolved, bridge, paused, flush, meter, io_frames, host, + ); } let sample_rate = resolved.sample_rate(); let channels = resolved.native_channels() as usize; let (raw_producer, mut raw_consumer) = RingBuffer::::new(ring_capacity_frames(sample_rate) * channels.max(1)); - let (mut raw_tx, raw_rx) = broadcast_channel(); + let (mut raw_tx, mut raw_rx) = broadcast_channel(); raw_tx.add(raw_producer)?; - let input = start_native_input_stream(node_id, resolved, raw_rx, paused, None, app)?; + let bell = Arc::new(Doorbell::default()); + raw_rx.ring_after_broadcast(bell.clone()); + let chunk = io_frames as usize; + let input = start_raw_input( + node_id, resolved, raw_rx, paused, flush, None, io_frames, host, + )?; #[cfg(any(target_os = "macos", target_os = "linux"))] let rate_probe = input.rate_probe(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); + let bell_thread = bell.clone(); let label = node_id.to_string(); 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", io_frames, sample_rate); + bell_thread.answer_here(); let mut bridge = bridge; - let mut input_buf = vec![0.0; RESAMPLE_CHUNK * channels]; + let mut input_buf = vec![0.0; chunk * channels]; #[cfg(any(target_os = "macos", target_os = "linux"))] let mut native_rate = sample_rate; #[cfg(not(any(target_os = "macos", target_os = "linux")))] let native_rate = sample_rate; - let mut resampler = match input_resampler(native_rate, target_sample_rate, channels) { - Ok(resampler) => resampler, - Err(_) => return, - }; + let mut resampler = + match input_resampler(native_rate, target_sample_rate, chunk, channels) { + Ok(resampler) => resampler, + Err(_) => return, + }; let mut output_buf = Vec::with_capacity( resampler .as_ref() @@ -278,7 +377,7 @@ pub(super) fn start_input_stream( } } native_rate = rate; - resampler = input_resampler(rate, target_sample_rate, channels) + resampler = input_resampler(rate, target_sample_rate, chunk, channels) .ok() .flatten(); output_buf = Vec::with_capacity( @@ -289,17 +388,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() { + bell_thread.wait(NORMALIZER_IDLE_WAKE); + continue; + } + input_buf.len() + } else { + let whole = available.min(input_buf.len()); + let whole = whole - whole % channels.max(1); + if whole == 0 { + bell_thread.wait(NORMALIZER_IDLE_WAKE); + 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 +425,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); @@ -323,6 +437,7 @@ pub(super) fn start_input_stream( Ok(InputHandle::Normalized(NormalizedInput { _input: Box::new(input), stop, + bell, join: Some(join), })) } @@ -331,12 +446,36 @@ pub(super) fn start_input_stream( mod tests { use super::*; + #[test] + fn normalizer_frames_are_counted_at_the_target_rate() { + let mic = |sample_rate| ResolvedInput::Virtual { + id: "mic".to_string(), + input: VirtualInput { + sample_rate, + channels: 1, + tone_hz: 440.0, + amplitude: 0.1, + ..Default::default() + }, + }; + // A 96 kHz chunk of 256 frames is 128 frames at 48 kHz. + let delay = MultiResampler::new(96_000, 48_000, 256, 1) + .unwrap() + .delay_frames() as u64; + assert_eq!(normalizer_frames(&mic(96_000), 48_000, 128), 128 + delay); + let delay = MultiResampler::new(44_100, 48_000, 118, 1) + .unwrap() + .delay_frames() as u64; + assert_eq!(normalizer_frames(&mic(44_100), 48_000, 128), 129 + delay); + assert_eq!(normalizer_frames(&mic(48_000), 48_000, 128), 0); + } + #[test] fn test_input_resampler_bypassed_when_rates_match() { // When native_rate == target_sample_rate (e.g. 96 kHz App Audio and 96 kHz pipeline), // no resampler must be allocated, ensuring bit-transparent passthrough with zero quality loss. for rate in [44_100, 48_000, 96_000, 192_000] { - let resampler = input_resampler(rate, rate, 2).unwrap(); + let resampler = input_resampler(rate, rate, 256, 2).unwrap(); assert!( resampler.is_none(), "resampler should be None for matching rate {rate}" @@ -346,7 +485,7 @@ mod tests { #[test] fn test_input_resampler_only_allocated_when_rates_differ() { - let resampler = input_resampler(48_000, 96_000, 2).unwrap(); + let resampler = input_resampler(48_000, 96_000, 256, 2).unwrap(); assert!( resampler.is_some(), "resampler must be Some when rates differ" @@ -356,12 +495,12 @@ mod tests { #[test] fn test_input_bit_transparent_sample_passthrough() { // Verify that when resampler is None, samples pass through bit-for-bit without any modification. - let mut input_buf = vec![0.0f32; RESAMPLE_CHUNK * 2]; + let mut input_buf = vec![0.0f32; 256 * 2]; for (i, sample) in input_buf.iter_mut().enumerate() { *sample = ((i as f32) * 0.001).sin(); } - let mut resampler = input_resampler(96_000, 96_000, 2).unwrap(); + let mut resampler = input_resampler(96_000, 96_000, 256, 2).unwrap(); let mut output_buf = Vec::new(); let normalized = if let Some(resampler) = &mut resampler { diff --git a/src-tauri/src/audio/pipeline/input/windows.rs b/src-tauri/src/audio/pipeline/input/windows.rs index ad2ccb8..9c1793b 100644 --- a/src-tauri/src/audio/pipeline/input/windows.rs +++ b/src-tauri/src/audio/pipeline/input/windows.rs @@ -2,7 +2,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use serde_json::json; -use tauri::{AppHandle, Emitter}; use tracing::{error, info}; use crate::audio::device::{self, DeviceKind}; @@ -12,11 +11,11 @@ use crate::audio::input_bridge::BroadcastRx; use crate::audio::streams; use crate::error::AppResult; +use super::super::file_reader::SeekFlush; +use super::super::host::Host; use super::super::native::native_config; use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; -const LOOPBACK_CHANNELS: usize = 2; - pub(in crate::audio::pipeline) fn resolve_input( inp: &ValidInput, target_sample_rate: u32, @@ -50,13 +49,32 @@ 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 +} + +/// 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 +} + +#[allow(clippy::too_many_arguments)] pub(in crate::audio::pipeline) fn start_input_stream( node_id: &str, resolved: ResolvedInput, bridge: BroadcastRx, paused: Option>, + flush: Option>, meter: Option, - app: &AppHandle, + io_frames: u32, + host: &Host, ) -> AppResult { match resolved { ResolvedInput::Cpal { @@ -68,15 +86,15 @@ pub(in crate::audio::pipeline) fn start_input_stream( } => { let dead = Arc::new(AtomicBool::new(false)); let dead_cb = dead.clone(); - let app_err = app.clone(); + let host_err = host.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; } health::bump(&health::STREAM_ERRORS, 1); error!(node_id = %node_id_cb, error = %e, "input stream error"); - let _ = app_err.emit( + host_err.emit( "audio://input_error", json!({ "nodeId": node_id_cb, "error": format!("{e}") }), ); @@ -103,7 +121,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( let capture = crate::audio::capture::Capture::start_system( exclude_current_app, sample_rate, - LOOPBACK_CHANNELS as u32, + io_frames, bridge, )?; Ok(InputHandle::Capture(capture)) @@ -116,13 +134,14 @@ pub(in crate::audio::pipeline) fn start_input_stream( let capture = crate::audio::capture::Capture::start_app( &bundle_id, sample_rate, - LOOPBACK_CHANNELS as u32, + io_frames, bridge, )?; Ok(InputHandle::Capture(capture)) } ResolvedInput::AudioFile { path, .. } => { - start_audio_file(node_id, path, bridge, paused, app) + start_audio_file(node_id, path, bridge, paused, flush, host) } + ResolvedInput::Virtual { .. } => unreachable!("virtual inputs start in input::mod"), } } diff --git a/src-tauri/src/audio/pipeline/latency.rs b/src-tauri/src/audio/pipeline/latency.rs new file mode 100644 index 0000000..8d5aced --- /dev/null +++ b/src-tauri/src/audio/pipeline/latency.rs @@ -0,0 +1,237 @@ +//! 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 frames of `sample_rate`. + pub latency_frames: u32, + /// Block the node actually works in, when larger than the engine buffer. + pub working_block: Option, + /// Rate of the graph the node runs in: a wire sender's or a recording's + /// may differ from the pipeline's. + pub sample_rate: u32, +} + +#[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, + /// Rate the engine runs at, which every frame count here is in. + pub sample_rate: 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); + // 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.is_none_or(|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); + 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] + 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 f1554fe..eaf6072 100644 --- a/src-tauri/src/audio/pipeline/meter.rs +++ b/src-tauri/src/audio/pipeline/meter.rs @@ -4,13 +4,13 @@ use std::thread::{self, JoinHandle}; 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::host::Host; use super::output::LIVE_SPEAKER_STREAMS; const METER_EVENT: &str = "audio://meter"; @@ -42,6 +42,9 @@ impl Drop for XrunTickThread { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); if let Some(j) = self.join.take() { + // Woken rather than waited out: a reconcile drops this thread and + // must not sit through the rest of its tick. + j.thread().unpark(); let _ = j.join(); } } @@ -95,6 +98,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 +111,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 @@ -128,7 +133,10 @@ pub(super) fn spawn_xrun_thread( // it so the second window starts clean. let mut warmup = true; while !stop_thread.load(Ordering::SeqCst) { - thread::sleep(XRUN_TICK); + thread::park_timeout(XRUN_TICK); + if stop_thread.load(Ordering::SeqCst) { + break; + } let now = Instant::now(); let elapsed_secs = now.duration_since(last_tick).as_secs_f64(); last_tick = now; @@ -138,6 +146,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]); @@ -161,7 +183,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 @@ -186,11 +212,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 { @@ -231,33 +253,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 +285,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 +308,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!( @@ -336,7 +356,7 @@ impl Drop for MeterTickThread { } pub(super) fn spawn_meter_thread( - app: AppHandle, + host: Host, meters: Vec, lufs: Vec, gr_handles: Vec, @@ -351,7 +371,7 @@ pub(super) fn spawn_meter_thread( thread::sleep(METER_TICK); for m in &meters { let snap = m.snapshot_and_decay(); - let _ = app.emit( + host.emit( METER_EVENT, json!({ "nodeId": m.node_id, @@ -362,7 +382,7 @@ pub(super) fn spawn_meter_thread( } for l in &lufs { let snap = l.snapshot(); - let _ = app.emit( + host.emit( LUFS_EVENT, json!({ "nodeId": l.node_id, @@ -384,7 +404,7 @@ pub(super) fn spawn_meter_thread( for g in &gr_handles { let gr_lin = f32::from_bits(g.gr_lin.load(std::sync::atomic::Ordering::Relaxed)); - let _ = app.emit(GR_EVENT, json!({ "nodeId": g.node_id, "grLin": gr_lin })); + host.emit(GR_EVENT, json!({ "nodeId": g.node_id, "grLin": gr_lin })); } for s in &scopes { // Scopes emit a delta since the last tick; spectrum emits the @@ -424,7 +444,7 @@ pub(super) fn spawn_meter_thread( "sampleRate": s.sample_rate, }), }; - let _ = app.emit(SCOPE_EVENT, payload); + host.emit(SCOPE_EVENT, payload); } } }) diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index a71811b..4c4fbb8 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -13,49 +13,76 @@ //! in the validator. use std::collections::{HashMap, HashSet}; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; -use rtrb::{Consumer, Producer}; -use tauri::AppHandle; +use rtrb::Producer; use tracing::{info, warn}; use crate::audio::effects::{ EffectControl, EffectRegistry, GrHandle, LufsHandle, MeterHandle, WaveformHandle, }; -use crate::audio::graph::{ - EffectSpec, InputSpec, NetCodec, OutputSpec, RecordingFormat, ValidGraph, -}; -use crate::audio::input_bridge::{broadcast_channel, BroadcastTx, CaptureStats}; +use crate::audio::graph::{InputSpec, OutputSpec, RecordingFormat, ValidGraph}; +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; pub(crate) mod dag; mod file_reader; +mod host; +pub use host::Host; 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 virtual_io; +pub use virtual_io::{take_host_lateness, VirtualDevices, VirtualInput, VirtualSpeaker}; mod worker; use dag::{build_output_graph, ring_capacity_frames, OutputGraph, OutputMeta, SourceMeta}; -use input::{resolve_input, start_input_stream, InputHandle, ResolvedInput}; +use file_reader::SeekFlush; +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, - start_wire_sender_worker, RecorderWorker, ResolvedOutput, SpeakerHandle, SpeakerIo, + resolve_output, start_monitor_worker, start_recorder_worker, start_wire_sender_worker, + RecorderWorker, ResolvedOutput, SpeakerIo, SpeakerStream, }; use sig::{compute_output_sig, OutputSig, MONITOR_KEY}; use worker::WorkerCtrl; -/// Overlap between a hot-swapped output's old and new bridges: one DSP block at -/// 48 kHz plus slack, so the incoming sub-graph starts with its rings primed. -const SWAP_PREFILL: std::time::Duration = std::time::Duration::from_millis(25); +/// A source's counters (see `dag::SourceStats`), in samples. +#[derive(Debug, Clone)] +pub struct SourceHealth { + pub label: String, + /// The output whose graph reads this source (`monitor` for the monitor). + pub output_id: String, + pub xrun: u64, + pub stalled: u64, + pub trimmed: u64, + pub consumed: u64, +} + +/// An output's worker: blocks rendered so far, and what a block is. +#[derive(Debug, Clone)] +pub struct OutputHealth { + pub label: String, + pub blocks: u64, + pub block_frames: usize, + pub sample_rate: u32, +} + +/// Longest a hot swap waits for its fresh bridges to collect a block. Only an +/// input that delivers nothing (paused, or a quiet tap) waits this long. +const SWAP_PREFILL_MAX: std::time::Duration = std::time::Duration::from_millis(25); /// Long-lived audio runtime. Owns every cpal/SCK stream, every DspWorker /// thread, the meter tick thread, and the effect parameter registry. @@ -90,22 +117,44 @@ 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, + /// 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>, + /// 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, + /// What each source measured of its delivery, for the source that + /// replaces it. + depth_memos: cushion::DepthMemos, } 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>, + /// 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>, + drain: Option>, } struct SpeakerState { /// Held only for its `Drop` -- cpal stream stop + worker join. - _handle: SpeakerHandle, + _handle: SpeakerStream, #[allow(dead_code)] sample_rate: u32, sig: OutputSig, @@ -163,11 +212,17 @@ impl ActivePipeline { output_stats: Vec::new(), xrun_thread: None, stale_bridges: Vec::new(), + node_timings: HashMap::new(), + carried_nodes: HashMap::new(), + swapping_slots: HashMap::new(), + receive_buffers: HashMap::new(), + reported_overloads: HashMap::new(), + depth_memos: cushion::DepthMemos::default(), } } /// Diff `graph` against the running pipeline; only touch what changed. - pub fn reconcile(&mut self, graph: &ValidGraph, app: AppHandle) -> AppResult<()> { + pub fn reconcile(&mut self, graph: &ValidGraph, host: Host) -> AppResult<()> { // Param-only resend: nothing structural changed, so leave every worker // (and the meter thread) running untouched. if self.is_structurally_current(graph) { @@ -190,7 +245,7 @@ impl ActivePipeline { state.bridge_tx.drain_discarded(); } - match self.apply_full(graph, app) { + match self.apply_full(graph, host) { Ok(()) => { self.current = Some(graph.clone()); Ok(()) @@ -231,15 +286,15 @@ impl ActivePipeline { } } - /// Queue a seek on the audio-file input identified by `node_id`. Silent - /// no-op when the node isn't an AudioFile or the pipeline is stopped. + /// Queue a seek on the audio-file input identified by `node_id`. The + /// reader flushes what is queued from the old position itself, at the + /// moment it seeks (see `SeekFlush`). Silent no-op when the node isn't an + /// AudioFile or the pipeline is stopped. pub fn seek_audio_file(&self, node_id: &str, frame: i64) { if let Some(state) = self.inputs.get(node_id) { if let Some(reader) = state._handle.audio_file_reader() { reader.seek_to().store(frame.max(0), Ordering::SeqCst); - } - if let Some(d) = &state.drain { - d.fetch_add(1, Ordering::SeqCst); + reader.wake(); } } } @@ -260,6 +315,9 @@ impl ActivePipeline { if let Some(p) = &state.paused { p.store(paused, Ordering::SeqCst); } + if let Some(reader) = state._handle.audio_file_reader() { + reader.wake(); + } } } @@ -270,36 +328,131 @@ 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 + /// Every source's and output's counters as they stand: what tests read + /// to tell a pipeline that keeps up from one that drops or stalls. + pub fn health(&self) -> (Vec, Vec) { + let sources = self + .source_stats .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 + .map(|s| SourceHealth { + label: s.label.clone(), + output_id: s.output_id.clone(), + xrun: s.stats.xrun.load(Ordering::Relaxed), + stalled: s.stats.stalled.load(Ordering::Relaxed), + trimmed: s.stats.trimmed.load(Ordering::Relaxed), + consumed: s.stats.consumed.load(Ordering::Relaxed), }) - .max() - .unwrap_or(0) + .collect(); + let outputs = self + .output_stats + .iter() + .map(|o| OutputHealth { + label: o.label.clone(), + blocks: o.blocks.load(Ordering::Relaxed), + block_frames: o.block_frames, + sample_rate: o.sample_rate, + }) + .collect(); + (sources, outputs) + } + + /// 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, + sample_rate: self.current.as_ref().map_or(0, |g| g.sample_rate), + 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 mut inputs: Vec = self + .source_stats + .iter() + .filter(|m| m.output_id == *id) + .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.frames.load(Ordering::Relaxed) as u64, + normalizer_frames: 0, + rate: fill.rate.load(Ordering::Relaxed), + }); + } + 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 + } + + /// 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)); + // A file's queue is audio decoded ahead, not audio held back: nothing + // live comes out later for it, and pause and seek drop it at once. + let read_ahead = input.is_some_and(|i| i._handle.audio_file_reader().is_some()); + let own = PathInput { + device: input.and_then(|i| i.io), + queue_frames: if read_ahead { + 0 + } else { + 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) { @@ -317,12 +470,63 @@ impl ActivePipeline { } self.tear_down_outputs(); 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(); 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 @@ -380,13 +584,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) { @@ -413,6 +619,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()); @@ -434,6 +645,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)); @@ -468,7 +683,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 @@ -488,7 +702,7 @@ impl ActivePipeline { .inputs .keys() .filter(|id| { - if sample_rate_changed { + if engine_format_changed { return true; } match ( @@ -522,7 +736,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,24 +807,17 @@ 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; - } - graph.effects.iter().any(|e| { - matches!( - e.spec, - EffectSpec::LevelMeter(_) - | EffectSpec::LufsMeter(_) - | EffectSpec::Waveform(_) - | EffectSpec::Spectrum(_) - ) - }) + graph.outputs.is_empty() || !dag::monitor_roots(graph).is_empty() } -pub fn build(graph: &ValidGraph, app: AppHandle) -> AppResult { +pub fn build(graph: &ValidGraph, host: Host) -> AppResult { let mut p = ActivePipeline::new(); - p.reconcile(graph, app)?; + p.reconcile(graph, host)?; Ok(p) } @@ -618,7 +825,7 @@ impl ActivePipeline { /// Surviving entries (left in place by `prepare_for_reconcile`) are /// reused; the rest are built fresh. On error `self` is in a half-built /// state -- the caller is responsible for calling `teardown`. - fn apply_full(&mut self, graph: &ValidGraph, app: AppHandle) -> AppResult<()> { + fn apply_full(&mut self, graph: &ValidGraph, host: Host) -> AppResult<()> { let monitor_mode = monitor_mode(graph); let pipeline_sr = graph.sample_rate; @@ -638,10 +845,17 @@ impl ActivePipeline { input_native_sr.insert(inp.id.clone(), state.sample_rate); input_native_channels.insert(inp.id.clone(), state.channels); } else { - #[cfg(any(target_os = "linux", target_os = "windows"))] - let resolved = resolve_input(inp, pipeline_sr)?; - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - let resolved = resolve_input(inp)?; + let devices = host.virtual_devices(); + let resolved = match devices.and_then(|d| d.input_for(&inp.spec)) { + Some(device) => { + let (id, input) = device?; + ResolvedInput::Virtual { id, input } + } + #[cfg(any(target_os = "linux", target_os = "windows"))] + None => resolve_input(inp, pipeline_sr)?, + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + None => resolve_input(inp)?, + }; let sr = match &resolved { ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, _ => pipeline_sr, @@ -680,7 +894,7 @@ impl ActivePipeline { // the output DAG source nodes before InputState is constructed. let mut new_input_volumes: HashMap> = HashMap::new(); let mut new_input_paused: HashMap> = HashMap::new(); - let mut new_input_drain: HashMap> = HashMap::new(); + let mut new_input_drain: HashMap> = HashMap::new(); let mut new_input_meters: HashMap = HashMap::new(); for inp in &graph.inputs { if !self.inputs.contains_key(&inp.id) { @@ -692,13 +906,13 @@ impl ActivePipeline { if matches!(&inp.spec, InputSpec::AudioFile { .. }) { new_input_paused .insert(inp.id.clone(), Arc::new(AtomicBool::new(!inp.auto_start))); - new_input_drain.insert(inp.id.clone(), Arc::new(AtomicU64::new(0))); + new_input_drain.insert(inp.id.clone(), Arc::default()); } } } let mut input_volumes: HashMap> = HashMap::new(); let mut input_paused: HashMap> = HashMap::new(); - let mut input_drain: HashMap> = HashMap::new(); + let mut input_drain: HashMap> = HashMap::new(); let mut input_meters: HashMap = HashMap::new(); for (id, state) in &self.inputs { input_volumes.insert(id.clone(), state.volume.clone()); @@ -773,16 +987,21 @@ impl ActivePipeline { sr @ (32_000 | 44_100 | 48_000) => Some(sr), _ => Some(48_000), }, - OutputSpec::FileRecording { .. } => Some(pipeline_sr), + OutputSpec::FileRecording { .. } | OutputSpec::NetSender { .. } => { + Some(pipeline_sr) + } _ => None, }; - let resolved = resolve_output(out, file_sr_hint)?; + let resolved = resolve_output(out, file_sr_hint, &host)?; output_runtime.insert(out.id.clone(), resolved); } // 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 @@ -794,34 +1013,49 @@ 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(); - for out in &graph.outputs { + let mut pending_cuts: HashMap> = HashMap::new(); + for out in dag::owner_order(graph) { if !output_runtime.contains_key(&out.id) { continue; } let output_sr = match &out.spec { OutputSpec::Speaker { .. } => pipeline_sr, - OutputSpec::FileRecording { .. } => output_runtime + OutputSpec::FileRecording { .. } + | OutputSpec::NetSender { .. } + | OutputSpec::WebRtcSend { .. } => output_runtime .get(&out.id) .map(|o| o.sample_rate()) .unwrap_or(pipeline_sr), - OutputSpec::NetSender { - codec, sample_rate, .. - } => { - if *codec == NetCodec::Opus { - crate::audio::netaudio::SR - } else { - sample_rate.unwrap_or(pipeline_sr) - } + }; + // A speaker is paced by its device. A wire sender rides a timer, + // but its latency is heard at the other end, so it runs the engine + // block too. Recordings run large timer blocks nobody hears. + let block_frames = match &out.spec { + OutputSpec::Speaker { .. } + | OutputSpec::NetSender { .. } + | OutputSpec::WebRtcSend { .. } => graph.buffer_frames as usize, + OutputSpec::FileRecording { .. } => dag::TIMER_BLOCK_FRAMES, + }; + // A speaker resampled to its device renders in bursts; a timer + // renders a block a tick. + let burst_blocks = match (&out.spec, output_runtime.get(&out.id)) { + (OutputSpec::Speaker { .. }, Some(runtime)) => { + output::render_burst(output_sr, runtime.sample_rate(), block_frames) } - OutputSpec::WebRtcSend { .. } => pipeline_sr, + _ => 1, }; 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, + block_frames, + burst_blocks, !matches!(out.spec, OutputSpec::FileRecording { .. }), graph, &input_native_sr, @@ -833,7 +1067,14 @@ impl ActivePipeline { &input_drain, &input_meters, cut_leaves, + &previous, + &self.depth_memos, )?; + 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() @@ -841,17 +1082,27 @@ 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 { let (prod, consumer) = rtrb::RingBuffer::::new(ring_capacity_frames(output_sr) * width); - built.graph.attach_tap(idx, prod); - pending_cuts - .entry(o2.clone()) - .or_default() - .insert(node.clone(), (consumer, 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(), + 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 { @@ -878,6 +1129,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); @@ -896,9 +1150,19 @@ 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, + 1, true, graph, &input_native_sr, @@ -910,7 +1174,16 @@ impl ActivePipeline { &input_drain, &input_meters, pending_cuts.remove(MONITOR_KEY).unwrap_or_default(), + &previous, + &self.depth_memos, )?; + 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)); } @@ -935,6 +1208,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); @@ -951,7 +1227,10 @@ 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; + // Bridges added beside a running one, and the samples that make one + // engine block of their input. + let mut fresh: Vec<(CaptureStats, u64)> = Vec::new(); for (input_id, tagged) in by_input { if self.inputs.contains_key(&input_id) { let state = self.inputs.get_mut(&input_id).unwrap(); @@ -970,6 +1249,13 @@ impl ActivePipeline { state.bridge_tx.drain_discarded(); } let (slot, capture) = state.bridge_tx.add(prod)?; + state.capture_by_slot.insert(slot, capture.clone()); + let block = output::device_block( + graph.buffer_frames as usize, + pipeline_sr, + state.sample_rate, + ); + fresh.push((capture.clone(), block as u64 * state.channels as u64)); captured.push((input_id.clone(), out_id.clone(), capture)); state .bridges_by_output @@ -998,19 +1284,26 @@ 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); } + let io = configure_io(&resolved, graph.buffer_frames as usize, pipeline_sr); + let normalizer_frames = + input::normalizer_frames(&resolved, pipeline_sr, graph.buffer_frames as usize); let handle = start_input_stream( &input_id, resolved, bridge_rx, pipeline_sr, paused.clone(), + drain.clone(), None, - &app, + graph.buffer_frames as usize, + &host, )?; self.inputs.insert( input_id, @@ -1018,8 +1311,11 @@ impl ActivePipeline { _handle: handle, sample_rate, channels, + io, + normalizer_frames, bridge_tx, bridges_by_output, + capture_by_slot, volume, paused, drain, @@ -1028,6 +1324,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 @@ -1062,14 +1365,19 @@ 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, graph.buffer_frames as usize); let handle = start_input_stream( &input_id, resolved, bridge_rx, pipeline_sr, paused.clone(), + drain.clone(), Some(meter), - &app, + graph.buffer_frames as usize, + &host, )?; self.inputs.insert( input_id, @@ -1077,8 +1385,11 @@ impl ActivePipeline { _handle: handle, sample_rate, channels, + io, + normalizer_frames, bridge_tx, bridges_by_output: HashMap::new(), + capture_by_slot: HashMap::new(), volume, paused, drain, @@ -1092,7 +1403,14 @@ impl ActivePipeline { // sub-graph whose sources are empty emits zero-fill until the input // callback catches up, which is an audible dropout on every edit. if !self.stale_bridges.is_empty() { - std::thread::sleep(SWAP_PREFILL); + let deadline = std::time::Instant::now() + SWAP_PREFILL_MAX; + while fresh + .iter() + .any(|(c, block)| c.fed.load(Ordering::Relaxed) < *block) + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(1)); + } } // Hot-swap the new sub-graph into an existing worker when @@ -1109,10 +1427,25 @@ impl ActivePipeline { let new_sig = compute_output_sig(graph, &out.id); match resolved { ResolvedOutput::Speaker(spec) => { - let out_channels = spec.out_channels; + 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| { + host.virtual_devices().is_none() + && 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 { + if state.sample_rate == spec.sample_rate() { state.ctrl.send_graph(og)?; state.sig = new_sig; // Same cpal stream keeps running -- carry its @@ -1128,10 +1461,11 @@ impl ActivePipeline { // restart the cpal stream. Drop the worker first. self.speakers.remove(&out.id); } - let sample_rate = spec.sample_rate; + let sample_rate = spec.sample_rate(); let meter = MeterHandle::new(out.id.clone()); - let (handle, ctrl, dead, io) = - start_speaker_stream(&out.id, spec, og, meter.clone(), &app)?; + let (handle, ctrl, dead, io) = spec.open(&out.id, og, meter.clone(), &host)?; + // A new stream counts its overloads from zero. + self.reported_overloads.remove(&out.id); if let Some(&idx) = output_stat_idx.get(&out.id) { self.output_stats[idx].channels = out_channels; self.output_stats[idx].io = Some(io.clone()); @@ -1179,7 +1513,7 @@ impl ActivePipeline { append, base_frames, og, - app.clone(), + host.clone(), )?; // Scope the recorder's waveform so the meter tick thread // publishes it alongside the effect nodes' scopes. @@ -1229,6 +1563,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) { @@ -1238,6 +1573,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) { @@ -1276,7 +1614,7 @@ impl ActivePipeline { let gr_snapshot: Vec = self.gr_handles.values().cloned().collect(); let scopes_snapshot: Vec = self.scopes.values().cloned().collect(); Some(spawn_meter_thread( - app, + host, meters_snapshot, lufs_snapshot, gr_snapshot, @@ -1345,6 +1683,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 +1701,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 +1726,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 +1769,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(); @@ -1435,6 +1790,173 @@ 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; + + 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)), + None, + 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 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)), + None, + 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; @@ -1457,7 +1979,7 @@ mod tests { enc.finalize().unwrap(); let paused = Arc::new(AtomicBool::new(true)); - let drain = Arc::new(AtomicU64::new(0)); + let drain = Arc::new(SeekFlush::default()); let volume = Arc::new(AtomicU32::new(1.0f32.to_bits())); let bridge = broadcast_channel().1; let emitter = TestEmitter::default(); @@ -1468,6 +1990,7 @@ mod tests { bridge, false, paused.clone(), + Some(drain.clone()), emitter, ) .expect("reader"); @@ -1476,8 +1999,11 @@ 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(), + capture_by_slot: HashMap::new(), volume: volume.clone(), paused: Some(paused.clone()), drain: Some(drain.clone()), @@ -1485,9 +2011,9 @@ mod tests { let mut p = ActivePipeline::new(); p.inputs.insert("f".to_string(), state); - // Seek queues on the reader and bumps the drain generation. + // Seek queues on the reader. A paused file has nothing queued, so the + // reader moves without asking the graphs to let go of anything. p.seek_audio_file("f", 500); - assert_eq!(drain.load(Ordering::SeqCst), 1); let deadline = Instant::now() + Duration::from_secs(1); while Instant::now() < deadline && !events diff --git a/src-tauri/src/audio/pipeline/native.rs b/src-tauri/src/audio/pipeline/native.rs index a143ddb..069a7f1 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/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs index 29c8e3f..508f83f 100644 --- a/src-tauri/src/audio/pipeline/output/linux.rs +++ b/src-tauri/src/audio/pipeline/output/linux.rs @@ -1,14 +1,14 @@ use std::sync::atomic::AtomicBool; use std::sync::Arc; -use tauri::AppHandle; use tracing::info; use crate::error::AppResult; use super::super::dag::OutputGraph; +use super::super::host::Host; 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)?; @@ -37,41 +45,34 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( spec: SpeakerResolved, graph: OutputGraph, meter: crate::audio::effects::MeterHandle, - _app: &AppHandle, + _host: &Host, ) -> 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 (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, + block_frames, fill_pw, )?; - - let (worker_handle, ctrl) = spawn_speaker_worker( - producer, - level, - target, - io.sample_rate.clone(), - spec.out_channels, - graph, - 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 f646b2c..69fd6fb 100644 --- a/src-tauri/src/audio/pipeline/output/macos.rs +++ b/src-tauri/src/audio/pipeline/output/macos.rs @@ -1,22 +1,25 @@ -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; 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}; 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::host::Host; 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); } } @@ -65,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( @@ -75,9 +80,10 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( spec: SpeakerResolved, graph: OutputGraph, meter: crate::audio::effects::MeterHandle, - app: &AppHandle, + host: &Host, ) -> 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, @@ -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,30 +113,35 @@ 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"); + + // Built before the stream opens: a renderer that cannot be built must not + // leave behind a stream whose AudioUnit nothing stops. + 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)?; + + let formats = macos_hal::physical_formats(DeviceKind::Output, &device_name); 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 app_err = app.clone(); + let (link, fill) = speaker_callback(); + let host_err = host.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; } health::bump(&health::STREAM_ERRORS, 1); error!(node_id = %node_id_cb, error = %e, "speaker stream error"); - let _ = app_err.emit( + host_err.emit( "audio://speaker_error", json!({ "nodeId": node_id_cb, "error": format!("{e}") }), ); @@ -144,11 +155,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 +169,13 @@ 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 (worker_handle, ctrl) = spawn_speaker_worker( - producer, - level, - target, - io.sample_rate.clone(), - spec.out_channels, - graph, - meter, - )?; + let (stream, mut link) = opened.expect("loop opens the stream or returns Err"); + macos_hal::warn_if_physical_format_changed(DeviceKind::Output, &device_name, &formats); + 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 dcbf93a..da819f4 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -1,23 +1,24 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::Duration; -use rtrb::{Producer, RingBuffer}; +use rtrb::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::audio::health; +use crate::audio::streams::bulk_push_counted; +use crate::audio::wake::Doorbell; use crate::error::{AppError, AppResult}; -use super::dag::{ring_capacity_frames, OutputGraph, DSP_BLOCK_FRAMES}; +use super::dag::OutputGraph; +use super::host::Host; use super::worker::{dsp_worker, WorkerCtrl}; #[cfg(target_os = "macos")] @@ -33,28 +34,114 @@ mod windows; #[cfg(target_os = "windows")] use windows as platform; -pub(super) use platform::{resolve_speaker, start_speaker_stream, SpeakerHandle, SpeakerResolved}; +mod render; -// No live inputs -> fall back to 48 kHz for the recorder. -const RECORDER_DEFAULT_SR: u32 = 48_000; +pub(super) use platform::resolve_speaker; +use platform::{start_speaker_stream, SpeakerHandle, SpeakerResolved}; +pub(super) use render::{render_burst, SpeakerIo}; +use render::{speaker_link, SpeakerLink, SpeakerRenderer}; + +use super::virtual_io::{DeviceClock, VirtualSpeaker}; + +/// A speaker to open: a device, or one that exists only in memory. +pub(super) enum SpeakerTarget { + Device(SpeakerResolved), + Virtual { + id: String, + speaker: VirtualSpeaker, + played: Arc>>, + }, +} + +/// An open speaker. Held for its `Drop`: the renderer is taken back with a +/// fade, then the stream stops. +pub(super) enum SpeakerStream { + Device(#[allow(dead_code)] SpeakerHandle), + Virtual(#[allow(dead_code)] VirtualSpeakerStream), +} + +pub(super) struct VirtualSpeakerStream { + link: SpeakerLink, + _clock: DeviceClock, + _alive: StreamGuard, +} + +impl Drop for VirtualSpeakerStream { + fn drop(&mut self) { + // Taken back while the clock still calls, so the stop fade plays out; + // the clock then stops as the fields drop. + drop(self.link.retire()); + } +} -// 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; +impl SpeakerTarget { + pub(super) fn sample_rate(&self) -> u32 { + match self { + SpeakerTarget::Device(s) => s.sample_rate, + SpeakerTarget::Virtual { speaker, .. } => speaker.sample_rate, + } + } -// 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; + pub(super) fn out_channels(&self) -> usize { + match self { + SpeakerTarget::Device(s) => s.out_channels, + SpeakerTarget::Virtual { speaker, .. } => speaker.channels.max(1) as usize, + } + } -#[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 + /// Opens the stream and hands it `graph`. Returns the stream, the worker's + /// control, the flag the stream raises when it dies, and its counters. + pub(super) fn open( + self, + node_id: &str, + graph: OutputGraph, + meter: MeterHandle, + host: &Host, + ) -> AppResult<(SpeakerStream, WorkerCtrl, Arc, SpeakerIo)> { + match self { + SpeakerTarget::Device(spec) => { + let (handle, ctrl, dead, io) = + start_speaker_stream(node_id, spec, graph, meter, host)?; + Ok((SpeakerStream::Device(handle), ctrl, dead, io)) + } + SpeakerTarget::Virtual { + id, + speaker, + played, + } => { + let channels = speaker.channels.max(1) as usize; + let rate = speaker.sample_rate; + let period = device_block(graph.block_frames(), graph.sample_rate(), rate) as usize; + let (renderer, ctrl, io) = speaker_renderer(graph, rate, channels, None, meter)?; + let (mut link, mut fill) = speaker_callback(); + link.attach(renderer); + let mut buf = vec![0.0f32; period * channels]; + let clock = DeviceClock::start(&id, rate, 0.0, period, move |frames| { + let out = &mut buf[..frames * channels]; + fill(out, frames); + played.lock().unwrap().extend_from_slice(out); + })?; + let stream = VirtualSpeakerStream { + link, + _clock: clock, + _alive: StreamGuard::new(), + }; + Ok(( + SpeakerStream::Virtual(stream), + ctrl, + Arc::new(AtomicBool::new(false)), + io, + )) + } + } + } } +// No live inputs -> fall back to 48 kHz for the recorder. +const RECORDER_DEFAULT_SR: u32 = 48_000; + pub(super) enum ResolvedOutput { - Speaker(SpeakerResolved), + Speaker(SpeakerTarget), File { path: PathBuf, sample_rate: u32, @@ -74,7 +161,7 @@ pub(super) enum ResolvedOutput { impl ResolvedOutput { pub(super) fn sample_rate(&self) -> u32 { match self { - ResolvedOutput::Speaker(s) => s.sample_rate, + ResolvedOutput::Speaker(s) => s.sample_rate(), ResolvedOutput::File { sample_rate, .. } => *sample_rate, ResolvedOutput::WireSender(sr) => *sr, } @@ -84,11 +171,19 @@ impl ResolvedOutput { pub(super) fn resolve_output( out: &ValidOutput, file_sr_hint: Option, + host: &Host, ) -> AppResult { match &out.spec { - OutputSpec::Speaker { device_id } => Ok(ResolvedOutput::Speaker( - platform::resolve_speaker(device_id)?, - )), + OutputSpec::Speaker { device_id } => { + Ok(ResolvedOutput::Speaker(match host.virtual_devices() { + Some(devices) => SpeakerTarget::Virtual { + id: device_id.clone(), + speaker: devices.speaker(device_id)?, + played: devices.sink(device_id), + }, + None => SpeakerTarget::Device(platform::resolve_speaker(device_id)?), + })) + } OutputSpec::FileRecording { file_path, format, @@ -155,34 +250,22 @@ pub(super) fn resolve_output( base_frames, }) } + // Opus encodes at 48 kHz, and WebRTC always carries 48 kHz, so their + // graphs run there and the engine converts the rate block by block; + // raw PCM goes out at the rate it is set to, else the pipeline's. OutputSpec::NetSender { codec, sample_rate, .. } => { let sr = if *codec == NetCodec::Opus { - crate::audio::netaudio::SR + Some(crate::audio::netaudio::SR) } else { - sample_rate - .or(file_sr_hint) - .unwrap_or(crate::audio::netaudio::SR) + sample_rate.or(file_sr_hint) }; - Ok(ResolvedOutput::WireSender(sr)) + sr.map(ResolvedOutput::WireSender) + .ok_or_else(|| AppError::Validation("network sender has no sample rate".into())) } - OutputSpec::WebRtcSend { .. } => Ok(ResolvedOutput::WireSender( - file_sr_hint.unwrap_or(crate::audio::netaudio::SR), - )), - } -} - -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(); + OutputSpec::WebRtcSend { .. } => { + Ok(ResolvedOutput::WireSender(crate::audio::webrtc::OPUS_SR)) } } } @@ -201,42 +284,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 +339,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 +348,10 @@ 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); + // Ticks one graph block at a time, whatever block the graph was built at, + // and makes up a late tick: one skipped is audio left queued in its + // sources, the meters falling behind by that much for good. + let ticker = timer_clock(sample_rate, graph.block_frames()); let (worker, ctrl) = dsp_worker(graph); let join = thread::Builder::new() .name("monitor".into()) @@ -456,7 +385,10 @@ 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); + // A late tick is made up: one skipped is audio left queued in its + // sources, latency the far end hears for good. Bounded in time, not in + // blocks, so a small engine buffer rides out the same stall. + let ticker = timer_clock(sample_rate, graph.block_frames()); let (worker, ctrl) = dsp_worker(graph); let join = thread::Builder::new() .name("netsender".into()) @@ -478,6 +410,24 @@ pub(super) fn start_wire_sender_worker( )) } +/// Longest a timer-paced graph's clock makes up for at once after its thread +/// was held up: within what its sources' rings hold, so nothing they +/// delivered is lost or left queued as latency. +const TIMER_CATCHUP_SECS: f64 = 0.5; + +/// Rendered audio waiting for the file: what a disk that stalls may hold up +/// before the recording loses any. +const RECORDER_QUEUE_SECS: usize = 4; + +/// Longest the writer sleeps without being rung. +const RECORDER_WRITER_WAKE: Duration = Duration::from_millis(50); + +fn timer_clock(rate: u32, block: usize) -> SystemClockTicker { + let catchup = ((rate as f64 * TIMER_CATCHUP_SECS) as usize / block.max(1)).max(1); + SystemClockTicker::with_catchup(rate, block, catchup as u32) +} + +#[allow(clippy::too_many_arguments)] pub(super) fn start_recorder_worker( node_id: String, path: PathBuf, @@ -487,35 +437,67 @@ pub(super) fn start_recorder_worker( append: bool, base_frames: u64, graph: OutputGraph, - app: AppHandle, + host: Host, ) -> AppResult<(RecorderWorker, WorkerCtrl, WaveformHandle)> { let stop = Arc::new(AtomicBool::new(false)); - let stop_thread = stop.clone(); - let (worker, ctrl) = dsp_worker(graph); + let stop_dsp = stop.clone(); + let stop_writer = stop.clone(); // Transport-paced: recording must follow the wall clock, not the source. // 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)); + // A late tick is made up rather than skipped: skipped time is audio the + // sources delivered and the file never gets. + let graph_rate = graph.sample_rate(); + let clock: Box = Box::new(timer_clock(graph_rate, graph.block_frames())); + let (worker, ctrl) = dsp_worker(graph); // 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); let wave_thread = wave.clone(); let session = wave.session; - // No real-time promotion: this worker blocks on encoder file I/O. + // The graph renders on one thread and the file is written on another: a + // disk that stalls holds up the writer, never the clock the sources are + // read on, so the rendering thread can run at real-time priority. let channels_usize = channels as usize; + let (mut to_writer, mut from_dsp) = + RingBuffer::::new(graph_rate as usize * channels_usize * RECORDER_QUEUE_SECS); + let bell = Arc::new(Doorbell::default()); + let rung = bell.clone(); + let rendered = Arc::new(AtomicBool::new(false)); + let done = rendered.clone(); + let dsp = thread::Builder::new() + .name(format!("recorder-dsp:{}", path.display())) + .spawn(move || { + worker.run( + stop_dsp, + clock, + Some(("recorder", graph_rate)), + |block, _| { + bulk_push_counted(&mut to_writer, block, &health::RECORDER_OVERRUN_SAMPLES); + rung.ring(); + Ok(()) + }, + ); + done.store(true, Ordering::Release); + rung.ring(); + }) + .map_err(|e| AppError::Stream(format!("spawn recorder thread: {e}")))?; + let join = thread::Builder::new() .name(format!("recorder:{}", path.display())) .spawn(move || { - // Inside the worker thread so slow encoder init (libopus, + bell.answer_here(); + // Inside the writer thread so slow encoder init (libopus, // libmp3lame, AVAudioFile) doesn't stagger recorder starts. let encoder: Box = match build_encoder(&path, sample_rate, channels, format, append) { Ok(e) => e, Err(e) => { warn!(node = %node_id, error = %e, "recorder init failed"); - let _ = app.emit( + stop_writer.store(true, Ordering::SeqCst); + let _ = dsp.join(); + host.emit( "audio://recorder_progress", json!({ "nodeId": node_id, @@ -541,11 +523,34 @@ pub(super) fn start_recorder_worker( let mut frames_written: u64 = base_frames; let mut encoder = encoder; - worker.run(stop_thread, clock, None, |block, _| { - encoder.write_interleaved(block)?; - frames_written += (block.len() / channels_usize) as u64; - wave_thread.push_interleaved(block, block.len() / channels_usize, base_frames); - + loop { + let finished = rendered.load(Ordering::Acquire); + let queued = from_dsp.slots(); + if queued > 0 { + let Ok(chunk) = from_dsp.read_chunk(queued) else { + continue; + }; + let (a, b) = chunk.as_slices(); + let mut failed = false; + for part in [a, b] { + if part.is_empty() { + continue; + } + if let Err(e) = encoder.write_interleaved(part) { + warn!(node = %node_id, error = %e, "recorder write failed; stopping"); + failed = true; + break; + } + let frames = part.len() / channels_usize; + frames_written += frames as u64; + wave_thread.push_interleaved(part, frames, base_frames); + } + chunk.commit_all(); + if failed { + stop_writer.store(true, Ordering::SeqCst); + break; + } + } if last_flush.elapsed() >= FLUSH_INTERVAL { if let Err(e) = encoder.flush() { warn!(error = %e, "recorder flush failed"); @@ -553,7 +558,7 @@ pub(super) fn start_recorder_worker( last_flush = std::time::Instant::now(); } if last_progress.elapsed() >= PROGRESS_INTERVAL { - let _ = app.emit( + host.emit( "audio://recorder_progress", json!({ "nodeId": node_id, @@ -565,10 +570,15 @@ pub(super) fn start_recorder_worker( ); last_progress = std::time::Instant::now(); } - Ok(()) - }); + // Everything rendered before the stop is in the file. + if finished && from_dsp.is_empty() { + break; + } + bell.wait(RECORDER_WRITER_WAKE); + } + let _ = dsp.join(); - let _ = app.emit( + host.emit( "audio://recorder_progress", json!({ "nodeId": node_id, @@ -584,7 +594,7 @@ pub(super) fn start_recorder_worker( warn!(error = %e, "recorder finalize failed"); } }) - .map_err(|e| AppError::Stream(format!("spawn recorder thread: {e}")))?; + .map_err(|e| AppError::Stream(format!("spawn recorder writer: {e}")))?; Ok(( RecorderWorker { @@ -599,8 +609,29 @@ pub(super) fn start_recorder_worker( #[cfg(test)] mod tests { use super::*; - use crate::audio::pipeline::dag::RESAMPLE_CHUNK; - use std::collections::HashMap; + + #[test] + fn a_held_up_recorder_makes_up_the_time_it_lost() { + // Every tick skipped is audio its sources delivered and the file + // never gets: after a stall the missed ticks fire back to back. + let mut clock = timer_clock(48_000, 1_024); + let stop = AtomicBool::new(false); + assert!(clock.wait_for_tick(&stop)); + std::thread::sleep(Duration::from_millis(200)); + let started = std::time::Instant::now(); + let mut immediate = 0; + while started.elapsed() < Duration::from_millis(5) { + assert!(clock.wait_for_tick(&stop)); + immediate += 1; + } + // 200 ms is nine 1024-frame periods; the first was due anyway. + assert!(immediate >= 8, "made up {immediate} ticks"); + } + + /// A host offering no devices: enough for outputs that need none. + fn no_devices() -> Host { + Host::with_virtual_devices(Arc::default(), |_, _| {}) + } fn recording_output( path: &std::path::Path, @@ -632,7 +663,7 @@ mod tests { RecordingMode::Overwrite, ); - let error = match resolve_output(&output, Some(48_000)) { + let error = match resolve_output(&output, Some(48_000), &no_devices()) { Ok(_) => panic!("invalid bitrate was accepted"), Err(error) => error, }; @@ -659,7 +690,7 @@ mod tests { RecordingMode::Overwrite, ); - resolve_output(&output, Some(48_000)).expect("valid recording output"); + resolve_output(&output, Some(48_000), &no_devices()).expect("valid recording output"); assert!( !path.exists(), "confirmed overwrite must clear the old path" @@ -667,64 +698,50 @@ 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" + fn wire_senders_run_at_their_wire_rate() { + use crate::audio::graph::{NetCodec, OpusApplication}; + let net = |codec: NetCodec, sample_rate: Option| ValidOutput { + id: "n".into(), + spec: OutputSpec::NetSender { + node_id: "n".into(), + target: "127.0.0.1:5004".parse().unwrap(), + channels: 2, + codec, + opus_bitrate: 96_000, + opus_application: OpusApplication::Audio, + sample_rate, + }, + }; + let rate = |out: &ValidOutput| { + resolve_output(out, Some(96_000), &no_devices()) + .unwrap() + .sample_rate() + }; + assert_eq!( + rate(&net(NetCodec::Opus, None)), + 48_000, + "Opus encodes at 48 kHz" ); - } - - #[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!( + rate(&net(NetCodec::PcmF32, None)), + 96_000, + "PCM follows the pipeline" + ); + assert_eq!(rate(&net(NetCodec::PcmI16, Some(44_100))), 44_100); + let webrtc = ValidOutput { + id: "w".into(), + spec: OutputSpec::WebRtcSend { + node_id: "w".into(), + channels: 2, + opus_bitrate: 96_000, + opus_application: OpusApplication::Voip, + }, }; - - 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); + assert_eq!( + rate(&webrtc), + 48_000, + "WebRTC carries 48 kHz, so its graph runs there" + ); } #[test] @@ -747,25 +764,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 +780,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 +789,7 @@ mod tests { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ NodeSpec { id: "m".into(), @@ -903,6 +822,8 @@ mod tests { let built = build_output_graph( None, 48_000, + super::super::dag::TIMER_BLOCK_FRAMES, + 1, true, &valid, &native, @@ -914,6 +835,8 @@ mod tests { &HashMap::new(), &HashMap::new(), HashMap::new(), + &HashMap::new(), + &crate::audio::pipeline::cushion::DepthMemos::default(), ) .expect("build"); @@ -937,4 +860,82 @@ mod tests { "worker stopped on drop" ); } + + #[test] + fn the_monitor_runs_in_real_time_at_any_block() { + use super::super::dag::build_output_graph; + use crate::audio::effects::EffectRegistry; + use crate::audio::graph::{EdgeSpec, GraphSpec, NodeKind, NodeSpec, ValidGraph}; + use std::collections::HashMap; + + for block in [32, 1024] { + let g = GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + NodeSpec { + id: "m".into(), + kind: NodeKind::Microphone, + data: serde_json::json!({ "deviceId": "dev" }), + }, + NodeSpec { + id: "lm".into(), + kind: NodeKind::LevelMeter, + data: serde_json::json!({}), + }, + ], + edges: vec![EdgeSpec { + id: "e".into(), + source: "m".into(), + source_handle: None, + target: "lm".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( + None, + 48_000, + block, + 1, + true, + &valid, + &native, + &native_ch, + &mut producer_pairs, + &mut reg, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + HashMap::new(), + &HashMap::new(), + &crate::audio::pipeline::cushion::DepthMemos::default(), + ) + .expect("build"); + let (recorder, _ctrl) = start_monitor_worker(built.graph).expect("spawn monitor"); + std::thread::sleep(Duration::from_millis(100)); + let from = built.output.blocks.load(Ordering::Relaxed); + let started = std::time::Instant::now(); + std::thread::sleep(Duration::from_millis(500)); + let blocks = built.output.blocks.load(Ordering::Relaxed) - from; + let secs = started.elapsed().as_secs_f64(); + drop(recorder); + // A monitor that falls behind real time stops draining its rings, + // and a shared source then starves every other output. + let rate = blocks as f64 * block as f64 / secs; + assert!( + (40_000.0..56_000.0).contains(&rate), + "{block}-frame monitor ran at {rate:.0} frames/s" + ); + } + } } 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 0000000..82f9d10 --- /dev/null +++ b/src-tauri/src/audio/pipeline/output/render.rs @@ -0,0 +1,939 @@ +//! 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 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, Mutex}; +use std::time::{Duration, Instant}; + +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}; + +/// 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 +/// 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 +/// resampler may take up to `input_period` frames before it emits anything, +/// so small blocks may go in before any comes out. Twice that, for room. +fn max_silent_blocks(input_period: usize, block_frames: usize) -> usize { + 2 * (input_period.div_ceil(block_frames.max(1)) + 2) +} + +/// Most engine blocks one device callback renders back to back once running. +/// On the device's rate the callback is one block; through a resampler that +/// emits only once it holds a period of input, the graph renders in bursts +/// of that period, plus one for a callback the last burst fell just short of. +/// A node working on its own thread has to answer a whole burst at once. +pub(in crate::audio::pipeline) fn render_burst( + pipeline_rate: u32, + device_rate: u32, + block_frames: usize, +) -> usize { + if pipeline_rate == device_rate { + return 1; + } + let block = block_frames.max(1); + FixedRateResampler::new(pipeline_rate, device_rate, block, 1) + .map_or(1, |r| r.steady_period().div_ceil(block) + 1) +} + +/// 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, + max_silent_blocks: usize, + /// When the previous callback came, to tell a device that stopped + /// calling for a while from its ordinary jitter. + last_callback: Option, + meter: MeterHandle, + io: SpeakerIo, +} + +/// Shortest gap between callbacks counted as the device having stalled. +const STALL_MIN: Duration = Duration::from_millis(5); +/// ...and at least this many of its callback periods, which no ordinary +/// scheduling jitter spans. +const STALL_PERIODS: u32 = 3; + +/// Whether a `gap` between callbacks of `callback_frames` at `rate` means +/// the device skipped some. +fn device_stalled(gap: Duration, callback_frames: usize, rate: u32) -> bool { + let period = Duration::from_secs_f64(callback_frames as f64 / rate.max(1) as f64); + gap > STALL_MIN.max(period * STALL_PERIODS) +} + +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 max_silent_blocks = max_silent_blocks( + resampler + .as_ref() + .map_or(0, FixedRateResampler::input_period), + block_frames, + ); + 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, + max_silent_blocks, + last_callback: None, + 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(); + if let Some(last) = self.last_callback.replace(started) { + if device_stalled(started - last, callback_frames, self.io.sample_rate) { + health::bump(&health::OUTPUT_STALLS, 1); + self.worker.output_stalled(); + } + } + 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 == self.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 + } +} + +/// 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: this, or a few of its +/// callback periods when those are longer. +const DEAD_DEVICE_AFTER: Duration = Duration::from_millis(100); +const DEAD_DEVICE_PERIODS: u32 = 3; + +/// 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 { + slot: Slot, + retire: Arc, + /// Set once the stop fade has played out. + faded: Arc, + /// Frames of silence the device has asked for since the fade, which + /// says how far the fade has travelled out of its buffers. + drained: 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. + faded_in: usize, +} + +impl SpeakerCallback { + pub(in crate::audio::pipeline) fn fill(&mut self, out: &mut [f32], callback_frames: usize) { + 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; + }; + let Some(r) = slot.as_mut() else { + out.fill(0.0); + if retiring { + self.drain(callback_frames); + } + return; + }; + let total = (r.io.sample_rate * STOP_FADE_MS / 1000).max(1) as usize; + 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.drain(callback_frames); + return; + } + 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 { + self.faded.store(true, Ordering::Release); + } + return; + } + 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; + } + } + } +} + +impl SpeakerCallback { + /// A whole callback of silence after the fade. + fn drain(&self, frames: usize) { + self.faded.store(true, Ordering::Release); + self.drained.fetch_add(frames as u64, Ordering::Relaxed); + } +} + +/// The control-thread end of a speaker's handoff. +pub(in crate::audio::pipeline) struct SpeakerLink { + slot: Slot, + retire: Arc, + faded: Arc, + drained: Arc, + calls: Arc, + /// The attached renderer's device callback size and rate. + period: Option<(Arc, u32)>, + /// Frames the device holds past its buffer before they sound. + hardware_frames: u64, +} + +pub(in crate::audio::pipeline) fn speaker_link() -> (SpeakerLink, SpeakerCallback) { + let slot: Slot = Arc::new(Mutex::new(None)); + let retire = Arc::new(AtomicBool::new(false)); + let faded = Arc::new(AtomicBool::new(false)); + let drained = Arc::new(AtomicU64::new(0)); + let calls = Arc::new(AtomicU64::new(0)); + ( + SpeakerLink { + slot: slot.clone(), + retire: retire.clone(), + faded: faded.clone(), + drained: drained.clone(), + calls: calls.clone(), + period: None, + hardware_frames: 0, + }, + SpeakerCallback { + slot, + retire, + faded, + drained, + calls, + fade_left: None, + faded_in: 0, + }, + ) +} + +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) { + self.period = Some((renderer.io.callback_frames.clone(), renderer.io.sample_rate)); + self.hardware_frames = renderer.io.hardware_frames.unwrap_or(0) as u64; + 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"); + } + } + + /// 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 while the device keeps calling back until the fade has left its + /// buffer and hardware, since stopping a device drops whatever it has not + /// played yet; 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 period = self + .period + .as_ref() + .map_or(Duration::ZERO, |(frames, rate)| { + Duration::from_secs_f64( + frames.load(Ordering::Relaxed) as f64 / (*rate).max(1) as f64, + ) + }); + let dead_after = DEAD_DEVICE_AFTER.max(period * DEAD_DEVICE_PERIODS); + let timeout = RETIRE_TIMEOUT.max(2 * dead_after); + let started = Instant::now(); + let mut calls = self.calls.load(Ordering::Relaxed); + let mut last_call = started; + let in_flight = |link: &Self| { + let buffer = link + .period + .as_ref() + .map_or(0, |(frames, _)| frames.load(Ordering::Relaxed) as u64); + buffer + link.hardware_frames + }; + while !self.faded.load(Ordering::Acquire) + || self.drained.load(Ordering::Relaxed) < in_flight(self) + { + let now = Instant::now(); + let seen = self.calls.load(Ordering::Relaxed); + if seen != calls { + calls = seen; + last_call = now; + } + if now - last_call >= dead_after || now - started >= 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() + } +} + +#[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 only_a_gap_of_several_periods_is_a_stall() { + let ms = Duration::from_millis; + // 32 frames at 48 kHz: a period is 0.67 ms, and the 5 ms floor rules. + assert!(!device_stalled(ms(2), 32, SR)); + assert!(device_stalled(ms(6), 32, SR)); + // 2048 frames: a period is 43 ms; one late callback is jitter. + assert!(!device_stalled(ms(80), 2048, SR)); + assert!(device_stalled(ms(140), 2048, SR)); + } + + #[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 an_odd_device_rate_renders_about_one_block_per_callback() { + // 48 kHz into 48.001 kHz share no period shorter than a second; the + // first callback must not render that second up front. + let mut t = rig(64, 48_001); + feed(&mut t.input, &vec![0.25_f32; 48_000 * 2]); + let mut out = vec![f32::NAN; 64 * 2]; + t.r.render(&mut out, 64); + // The sinc filter fills over its first few blocks. + let first = t.blocks.load(Ordering::Relaxed); + assert!(first <= 6, "{first} blocks for the first 64-frame callback"); + let got = play(&mut t.r, &[64], 16_000); + let blocks = t.blocks.load(Ordering::Relaxed); + assert!( + blocks <= first + 16_000 / 64 + 2, + "{blocks} blocks for 250 callbacks after the first" + ); + assert!( + got[8_000 * 2..].iter().all(|s| (s - 0.25).abs() < 1e-3), + "gap or glitch in the resampled stream" + ); + } + + #[test] + fn a_callback_never_renders_more_blocks_than_its_burst() { + // What an offloaded node's pad is sized from must hold for the + // renderer as it runs, at every block and through either resampler. + for (device_rate, block) in [ + (SR, 64), + (44_100, 32), + (44_100, 64), + (44_100, 128), + (44_100, 1024), + (96_000, 64), + (48_001, 64), + ] { + let mut t = rig(block, device_rate); + feed(&mut t.input, &vec![0.25_f32; 48_000 * 2]); + let callback = super::super::device_block(block, SR, device_rate) as usize; + let burst = render_burst(SR, device_rate, block); + let mut worst = 0; + for call in 0..400 { + let before = t.blocks.load(Ordering::Relaxed); + let mut out = vec![0.0; callback * 2]; + t.r.render(&mut out, callback); + // The resampler's fill on the first callbacks is not a burst. + if call >= 8 { + worst = worst.max(t.blocks.load(Ordering::Relaxed) - before); + } + } + assert!( + worst as usize <= burst, + "{device_rate} Hz @ {block}: {worst} blocks in one callback, burst {burst}" + ); + } + } + + /// Frames that left a steady 0.25 when a light node on its own thread, + /// its pad sized for `burst` blocks, plays through a `device_rate` + /// speaker called back in real time. + fn dropouts_through_an_offloaded_node(block: usize, device_rate: u32, burst: usize) -> usize { + use crate::audio::pipeline::dag::graph_tests::offload_every_effect; + let (valid, _) = passthrough_graph(); + let (mut built, mut producers) = build_with_block(Some("s"), SR, block, &valid, SR, false); + offload_every_effect(&mut built.graph, burst); + let (mut r, _ctrl, _io) = + SpeakerRenderer::new(built.graph, device_rate, None, MeterHandle::new("s".into())) + .expect("renderer"); + feed(producers.get_mut("m").unwrap(), &vec![0.25_f32; 48_000 * 2]); + let callback = super::super::device_block(block, SR, device_rate) as usize; + let period = Duration::from_secs_f64(callback as f64 / device_rate as f64); + let mut played: Vec = Vec::new(); + let mut out = vec![0.0; callback * 2]; + while played.len() < device_rate as usize * 6 / 10 { + r.render(&mut out, callback); + played.extend(out.iter().step_by(2)); + // Paced, never made up: a late test thread slows the device + // rather than bunching its callbacks into a bigger burst. + std::thread::sleep(period); + } + played[device_rate as usize / 20..] + .iter() + .filter(|s| (*s - 0.25).abs() > 0.02) + .count() + } + + #[test] + fn a_light_offloaded_node_plays_steadily_into_a_resampled_speaker() { + // A speaker at 44.1 kHz renders the 48 kHz graph in bursts; a node on + // its own thread has to answer a whole burst at once. + let block = 64; + let burst = render_burst(SR, 44_100, block); + assert_eq!(dropouts_through_an_offloaded_node(block, 44_100, burst), 0); + assert_eq!(dropouts_through_an_offloaded_node(block, SR, 1), 0); + // That a pad sized for one block starves here is pinned by the + // offload's model test: the worker sizes its pad from timing it, and + // a loaded machine times it slow enough to cover a burst anyway. + } + + #[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); + 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), + 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); + } + + #[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], 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()); + 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 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 a_retire_lets_the_fade_leave_the_device_before_stopping_it() { + // Stopping a device drops what it has not played. The fade is only + // out once the device has asked for a buffer and its hardware + // latency's worth of silence after it. + let (mut link, mut cb) = speaker_link(); + let mut t = rig(64, SR); + feed(&mut t.input, &vec![0.5; 48_000]); + t.r.io.hardware_frames = Some(480); + link.attach(t.r); + let mut out = vec![0.0; 64 * 2]; + for _ in 0..20 { + cb.fill(&mut out, 64); + } + let retire = cb.retire.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let retiring = std::thread::spawn(move || { + let r = link.retire(); + tx.send(r.is_some()).unwrap(); + }); + while !retire.load(Ordering::Acquire) { + std::thread::yield_now(); + } + // The fade (480 frames) ends in the 8th callback; 544 frames of + // silence (a 64-frame buffer and 480 of hardware) take 9 more. + let wait = Duration::from_millis(5); + for _ in 0..16 { + cb.fill(&mut out, 64); + assert!( + rx.recv_timeout(wait).is_err(), + "stopped with the fade in flight" + ); + } + cb.fill(&mut out, 64); + assert!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + "renderer handed back once the fade is out" + ); + retiring.join().unwrap(); + } + + #[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 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); + } + + #[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 e018b01..e7d1122 100644 --- a/src-tauri/src/audio/pipeline/output/windows.rs +++ b/src-tauri/src/audio/pipeline/output/windows.rs @@ -1,9 +1,8 @@ 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}; use crate::audio::device::{self, DeviceKind}; @@ -12,9 +11,10 @@ use crate::audio::streams; use crate::error::AppResult; use super::super::dag::OutputGraph; +use super::super::host::Host; 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); } } @@ -60,9 +61,10 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( spec: SpeakerResolved, graph: OutputGraph, meter: crate::audio::effects::MeterHandle, - app: &AppHandle, + host: &Host, ) -> 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, @@ -71,24 +73,24 @@ 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 (producer, fill, level, target, io) = speaker_ring( - spec.out_channels, - graph.sample_rate(), - spec.sample_rate, - graph.latency_frames(), - ); - let app_err = app.clone(); + let (mut link, fill) = speaker_callback(); + let host_err = host.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; } health::bump(&health::STREAM_ERRORS, 1); error!(node_id = %node_id_cb, error = %e, "speaker stream error"); - let _ = app_err.emit( + host_err.emit( "audio://speaker_error", json!({ "nodeId": node_id_cb, "error": format!("{e}") }), ); @@ -102,20 +104,11 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( fill, err_cb, )?; - - let (worker_handle, ctrl) = spawn_speaker_worker( - producer, - level, - target, - io.sample_rate.clone(), - spec.out_channels, - graph, - 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 62fcf49..1d4b3c9 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 { @@ -166,8 +166,8 @@ fn structural_effect(spec: &EffectSpec) -> EffectSpec { d.bypassed = false; } E::Declick(d) => { + // max_width_ms sizes the delay line at build time -- keep it structural. d.sensitivity = 0.0; - d.max_width_ms = 0.0; d.bypassed = false; } E::DeEsser(d) => { @@ -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( @@ -342,11 +346,44 @@ mod tests { assert!(base != different, "lookahead sizes the delay line"); } + #[test] + fn declick_width_is_structural_but_sensitivity_is_not() { + let mk = |width: f32, sensitivity: f32| { + GraphSpec { + sample_rate: None, + buffer_frames: None, + nodes: vec![ + mic("m"), + node( + "d", + NodeKind::Declick, + serde_json::json!({ "sensitivity": sensitivity, "maxWidthMs": width }), + ), + speaker("s"), + ], + edges: vec![edge("e1", "m", "d"), edge("e2", "d", "s")], + } + .validate() + .expect("valid") + }; + let base = compute_output_sig(&mk(2.0, 0.5), "s"); + assert_eq!( + base, + compute_output_sig(&mk(2.0, 0.9), "s"), + "sensitivity is live" + ); + assert!( + base != compute_output_sig(&mk(4.0, 0.5), "s"), + "width sizes the delay line" + ); + } + #[test] fn plugin_state_is_not_structural_path_is() { let mk = |state: Option<&str>| { GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ node( "p", @@ -370,6 +407,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 +456,7 @@ mod tests { fn sidechain_edges_sort_after_main() { let g = GraphSpec { sample_rate: None, + buffer_frames: None, nodes: vec![ mic("m1"), mic("m2"), @@ -496,8 +535,8 @@ mod tests { ), ( NodeKind::Declick, - serde_json::json!({ "sensitivity": 0.9, "maxWidthMs": 5.0 }), - serde_json::json!({ "sensitivity": 0.0, "maxWidthMs": 0.3 }), + serde_json::json!({ "sensitivity": 0.9, "maxWidthMs": 2.0 }), + serde_json::json!({ "sensitivity": 0.0, "maxWidthMs": 2.0 }), ), ( NodeKind::DeEsser, @@ -520,6 +559,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/testdata/chrome_tap_512.csv b/src-tauri/src/audio/pipeline/testdata/chrome_tap_512.csv new file mode 100644 index 0000000..52f0ed4 --- /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 0000000..a585ef1 --- /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 diff --git a/src-tauri/src/audio/pipeline/virtual_io.rs b/src-tauri/src/audio/pipeline/virtual_io.rs new file mode 100644 index 0000000..9d024a7 --- /dev/null +++ b/src-tauri/src/audio/pipeline/virtual_io.rs @@ -0,0 +1,216 @@ +//! Audio devices that exist only in memory, so a whole pipeline runs where +//! there is no sound hardware (tests, CI). A virtual input plays a tone into +//! the graph on its own clock, as a capture callback would; a virtual speaker +//! pulls the graph on its own clock, as a device callback would, and keeps +//! what it played. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::audio::effects::{update_meter, MeterHandle}; +use crate::audio::graph::InputSpec; +use crate::audio::input_bridge::BroadcastRx; +use crate::error::{AppError, AppResult}; + +/// A capture device: a sine on every channel. +#[derive(Clone, Debug, Default)] +pub struct VirtualInput { + pub sample_rate: u32, + pub channels: u32, + pub tone_hz: f32, + pub amplitude: f32, + /// Frames per delivery, whatever buffer is asked for, as a device that + /// keeps its own IO size does; `None` delivers the buffer asked for. + pub burst_frames: Option, + /// How far the device's clock runs from its nominal rate. + pub clock_ppm: f64, +} + +/// A playback device. Its callback asks for one engine block in its own +/// frames, as a real device is set up to. +#[derive(Clone, Debug)] +pub struct VirtualSpeaker { + pub sample_rate: u32, + pub channels: u32, +} + +/// The devices a virtual host offers, by the id a graph names them with: a +/// microphone's `deviceId`, `system` for system audio, `app:` for +/// an app's audio, a speaker's `deviceId`. +#[derive(Default)] +pub struct VirtualDevices { + inputs: Mutex>, + speakers: Mutex>, + played: Mutex>>>>, +} + +impl VirtualDevices { + pub fn add_input(&self, id: &str, input: VirtualInput) { + self.inputs.lock().unwrap().insert(id.to_string(), input); + } + + pub fn add_speaker(&self, id: &str, speaker: VirtualSpeaker) { + self.speakers + .lock() + .unwrap() + .insert(id.to_string(), speaker); + } + + /// Everything speaker `id` has played so far, interleaved. + pub fn played(&self, id: &str) -> Vec { + self.played + .lock() + .unwrap() + .get(id) + .map(|p| p.lock().unwrap().clone()) + .unwrap_or_default() + } + + /// Forgets what every speaker has played. + pub fn clear_played(&self) { + for p in self.played.lock().unwrap().values() { + p.lock().unwrap().clear(); + } + } + + /// The device a live input spec names, or `None` for inputs that are not + /// devices (files, network). + pub(super) fn input_for(&self, spec: &InputSpec) -> Option> { + let id = match spec { + InputSpec::Microphone { device_id } => device_id.clone(), + InputSpec::SystemAudio { .. } => "system".to_string(), + InputSpec::AppAudio { bundle_id } => format!("app:{bundle_id}"), + _ => return None, + }; + Some( + self.inputs + .lock() + .unwrap() + .get(&id) + .cloned() + .map(|input| (id.clone(), input)) + .ok_or_else(|| AppError::Device(format!("no virtual input {id:?}"))), + ) + } + + pub(super) fn speaker(&self, id: &str) -> AppResult { + self.speakers + .lock() + .unwrap() + .get(id) + .cloned() + .ok_or_else(|| AppError::Device(format!("no virtual speaker {id:?}"))) + } + + /// Where speaker `id` keeps what it plays; a reopened speaker carries on. + pub(super) fn sink(&self, id: &str) -> Arc>> { + self.played + .lock() + .unwrap() + .entry(id.to_string()) + .or_default() + .clone() + } +} + +/// Latest any virtual device has woken past a tick, in microseconds, since the +/// last `take_host_lateness`: how far the host kept them from real time. +static HOST_LATE_US: AtomicU64 = AtomicU64::new(0); + +/// How late, at worst, the host let any virtual device tick since the last +/// call. A device the host held up drops out however sound the engine is. +pub fn take_host_lateness() -> Duration { + Duration::from_micros(HOST_LATE_US.swap(0, Ordering::Relaxed)) +} + +/// Calls `tick(frames)` once per `period_frames` of `rate`, on the wall +/// clock, from its own thread until dropped. A late wake makes up every +/// period it missed at once, as the device's own clock would have run on. +pub(super) struct DeviceClock { + stop: Arc, + join: Option>, +} + +impl DeviceClock { + pub(super) fn start( + name: &str, + rate: u32, + clock_ppm: f64, + period_frames: usize, + mut tick: impl FnMut(usize) + Send + 'static, + ) -> AppResult { + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let true_rate = rate.max(1) as f64 * (1.0 + clock_ppm * 1e-6); + let period = Duration::from_secs_f64(period_frames as f64 / true_rate); + let join = thread::Builder::new() + .name(format!("virtual:{name}")) + .spawn(move || { + // A device's callback runs at real-time priority; so does this. + let _rt = super::RtThread::promote("virtual device", period_frames as u32, rate); + let started = Instant::now(); + let mut ticks: u32 = 0; + while !stop_thread.load(Ordering::SeqCst) { + let due = started + period * (ticks + 1); + let now = Instant::now(); + if due > now { + thread::sleep(due - now); + continue; + } + HOST_LATE_US.fetch_max((now - due).as_micros() as u64, Ordering::Relaxed); + tick(period_frames); + ticks += 1; + } + }) + .map_err(|e| AppError::Stream(format!("spawn virtual device: {e}")))?; + Ok(Self { + stop, + join: Some(join), + }) + } +} + +impl Drop for DeviceClock { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +/// A running virtual input: its tone, delivered `io_frames` at a time. +pub(super) fn start_capture( + id: &str, + input: &VirtualInput, + io_frames: usize, + mut bridge: BroadcastRx, + meter: Option, +) -> AppResult { + let io_frames = input.burst_frames.unwrap_or(io_frames); + let channels = input.channels.max(1) as usize; + let step = std::f32::consts::TAU * input.tone_hz / input.sample_rate.max(1) as f32; + let amplitude = input.amplitude; + let mut phase = 0.0f32; + let mut buf = vec![0.0f32; io_frames * channels]; + DeviceClock::start( + id, + input.sample_rate, + input.clock_ppm, + io_frames, + move |frames| { + for frame in buf[..frames * channels].chunks_exact_mut(channels) { + frame.fill(amplitude * phase.sin()); + phase = (phase + step) % std::f32::consts::TAU; + } + if let Some(m) = &meter { + update_meter(m, &buf[..frames * channels], channels); + } + bridge.apply_commands(); + bridge.broadcast(&buf[..frames * channels]); + }, + ) +} diff --git a/src-tauri/src/audio/pipeline/worker.rs b/src-tauri/src/audio/pipeline/worker.rs index 1f5adc5..87f784e 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. @@ -47,13 +47,29 @@ impl Drop for RtThread { } } +/// 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, + /// 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, + /// Frames a graph that just swapped in stays silent before it fades in: + /// its new delay lines start empty, and audio arriving at the end of one + /// mid-fade would step in. + hold: usize, /// 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, } @@ -81,10 +97,13 @@ 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); + let (old_tx, old_rx) = RingBuffer::::new(4); ( DspWorker { graph, + pending: None, + gain: 1.0, + hold: 0, cmd_rx, old_graph_tx: old_tx, }, @@ -97,19 +116,90 @@ 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. + /// + /// 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(new_graph) = self.cmd_rx.pop() { - let old = std::mem::replace(&mut self.graph, new_graph); - let _ = self.old_graph_tx.push(old); + 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()); + // 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); + } + } + } + + /// 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. + #[inline] + pub(super) fn next_block(&mut self, block: &mut [f32]) -> usize { + self.drain_swaps(); + self.graph.process_block(block); + let active = self.graph.active_output_channels(); + 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) { + if self.hold > 0 && self.pending.is_none() { + self.hold -= 1; + frame.fill(0.0); + continue; + } + 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; + } + } + } + if self.gain <= 0.0 { + if let Some(new_graph) = self.pending.take() { + self.hold = new_graph.latency_frames(); + self.swap_in(new_graph); + } } + active + } + + pub(super) fn graph(&self) -> &OutputGraph { + &self.graph + } + + /// See `OutputGraph::output_stalled`. + pub(super) fn output_stalled(&mut self) { + self.graph.output_stalled(); } - /// 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. + /// 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 +210,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 +224,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; @@ -162,11 +251,16 @@ 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(); + // 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(), @@ -191,6 +285,219 @@ mod tests { 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_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); + 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 a_graph_with_new_delay_lines_fades_in_once_they_fill() { + // A fresh lookahead plays its empty delay line first: the fade-in + // waits for the audio behind it rather than letting it step in. + use super::super::dag::graph_tests::{fresh_registry, parallel_with_lookahead}; + let (old, _old_in) = constant_graph(0.5); + let mut registry = fresh_registry(); + let (new, _new_in) = carrying_graph( + ¶llel_with_lookahead(false, 5.0, false), + &mut registry, + &std::collections::HashMap::new(), + 0.25, + ); + assert!(new.graph.latency_frames() > 0, "the new graph delays"); + let (mut worker, mut ctrl) = dsp_worker(old); + let mut block = block_for(&worker); + worker.next_block(&mut block); + ctrl.send_graph(new.graph).expect("queue swap"); + let mut played: Vec = Vec::new(); + for _ in 0..40 { + 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 <= 0.5 / fade as f32 + 1e-4, "a step of {worst}"); + assert!( + (played.last().unwrap() - 0.5).abs() < 1e-4, + "the new graph plays" + ); + } + + #[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.pending.is_none(), + "an edit nobody hears goes in at once" + ); + assert_eq!( + ctrl.old_graph_rx.slots(), + 1, + "the old graph went back at once" + ); + } + + /// `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)); diff --git a/src-tauri/src/audio/playback.rs b/src-tauri/src/audio/playback.rs index eaaedef..09e021d 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 abfc7be..9a4b0cc 100644 --- a/src-tauri/src/audio/plugins/host_api.rs +++ b/src-tauri/src/audio/plugins/host_api.rs @@ -18,7 +18,7 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use super::{ParamRing, PluginFormat, PluginNode}; 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. @@ -155,7 +155,13 @@ 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, + burst_blocks: usize, + ) -> Self { let latency = node.latency_frames(); let width = node.channels(); if !realtime { @@ -168,17 +174,24 @@ impl HostedEffect { 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, - } - } + // 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, + burst_blocks, + sample_rate, + ) { + Ok(o) => Self { + latency: latency + o.latency_frames(), + backend: HostedBackend::Offloaded(o), + channels: width, + }, Err(p) => Self { backend: HostedBackend::Inline { node: p.node }, channels: width, diff --git a/src-tauri/src/audio/plugins/main_thread.rs b/src-tauri/src/audio/plugins/main_thread.rs index 8f8d0af..f4fa7e9 100644 --- a/src-tauri/src/audio/plugins/main_thread.rs +++ b/src-tauri/src/audio/plugins/main_thread.rs @@ -4,14 +4,23 @@ //! VST3 put nearly everything there, AU only its AppKit half -- but they agree //! on the mechanism, so it lives here rather than three times over. +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::mpsc; -use std::sync::Once; -use std::time::Duration; +use std::sync::{Arc, Once}; +use std::time::{Duration, Instant}; -/// How long a main-thread call may take before the caller gives up. A plugin -/// that blocks the UI thread longer than this has already broken the app; the -/// timeout keeps the calling thread from hanging with it. -const MAIN_THREAD_TIMEOUT: Duration = Duration::from_secs(5); +/// How long a main-thread call may wait to start. The UI thread busy this +/// long with something else gets the call withdrawn, so it never runs late +/// for a caller that has already given up on it. +const START_TIMEOUT: Duration = Duration::from_secs(5); +/// How long a call that has started may run. A large plugin can take many +/// seconds to load and is waited for; one past this has hung, and the caller +/// stops waiting rather than hang with it. +const RUN_TIMEOUT: Duration = Duration::from_secs(60); + +const QUEUED: u8 = 0; +const STARTED: u8 = 1; +const WITHDRAWN: u8 = 2; use std::sync::OnceLock; use std::thread::ThreadId; @@ -49,13 +58,52 @@ pub fn run(f: impl FnOnce() -> R + Send + 'static) -> Result< return Ok(f()); } let app = crate::app_handle().ok_or_else(|| "app handle not ready".to_string())?; + run_via( + |job| app.run_on_main_thread(job).map_err(|e| e.to_string()), + f, + START_TIMEOUT, + RUN_TIMEOUT, + ) +} + +/// `run` over any way of reaching the main thread. A call is withdrawn if it +/// has not started within `start_timeout`; once started it is waited for, up +/// to `run_timeout`. +fn run_via( + dispatch: impl FnOnce(Box) -> Result<(), String>, + f: impl FnOnce() -> R + Send + 'static, + start_timeout: Duration, + run_timeout: Duration, +) -> Result { + let state = Arc::new(AtomicU8::new(QUEUED)); + let job_state = state.clone(); let (tx, rx) = mpsc::channel(); - app.run_on_main_thread(move || { - let _ = tx.send(f()); - }) - .map_err(|e| e.to_string())?; - rx.recv_timeout(MAIN_THREAD_TIMEOUT) - .map_err(|_| "main-thread plugin op timed out".to_string()) + let sent = Instant::now(); + dispatch(Box::new(move || { + if job_state + .compare_exchange(QUEUED, STARTED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let _ = tx.send(f()); + } + }))?; + if let Ok(r) = rx.recv_timeout(start_timeout) { + return Ok(r); + } + if state + .compare_exchange(QUEUED, WITHDRAWN, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Err("main thread busy; plugin call withdrawn before it started".into()); + } + rx.recv_timeout(run_timeout.saturating_sub(sent.elapsed())) + .map_err(|_| { + tracing::warn!( + after = ?sent.elapsed(), + "a plugin call on the main thread never finished; it may still hold an instance" + ); + "main-thread plugin call hung".to_string() + }) } /// Starts the shared 16 ms tick, once. Every host's `tick_and_reclaim` runs on @@ -79,3 +127,49 @@ pub fn ensure_ticker() { .ok(); }); } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + + /// A main thread that picks the job up after `delay`. + fn after(delay: Duration) -> impl FnOnce(Box) -> Result<(), String> { + move |job| { + std::thread::spawn(move || { + std::thread::sleep(delay); + job(); + }); + Ok(()) + } + } + + #[test] + fn a_call_that_never_started_is_withdrawn_and_never_runs() { + let ran = Arc::new(AtomicBool::new(false)); + let flag = ran.clone(); + let r = run_via( + after(Duration::from_millis(100)), + move || flag.store(true, Ordering::SeqCst), + Duration::from_millis(20), + Duration::from_secs(5), + ); + assert!(r.is_err()); + std::thread::sleep(Duration::from_millis(200)); + assert!(!ran.load(Ordering::SeqCst), "a withdrawn call ran anyway"); + } + + #[test] + fn a_slow_call_that_started_is_waited_for() { + let r = run_via( + after(Duration::ZERO), + || { + std::thread::sleep(Duration::from_millis(100)); + 7 + }, + Duration::from_millis(20), + Duration::from_secs(5), + ); + assert_eq!(r, Ok(7)); + } +} diff --git a/src-tauri/src/audio/resample.rs b/src-tauri/src/audio/resample.rs index d9ea13c..4666ff7 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) { @@ -100,8 +140,19 @@ pub struct MultiResampler { /// Fixed-rate conversion for device outputs. Unlike capture and network /// clock conversion, a speaker stream keeps the same rate for its lifetime. +/// +/// The FFT resampler is exact and cheap, but it emits nothing until it holds +/// a whole period of the two rates (160 frames for 48 -> 44.1 kHz). Rates +/// whose period is longer than `MAX_FFT_PERIOD_MS` (a custom rate one hertz +/// off a standard one has a period of a second) go through a sinc filter, +/// which emits on every chunk. pub struct FixedRateResampler { - inner: FftFixedIn, + inner: FixedInner, + /// Input frames the resampler may take before it emits anything. + input_period: usize, + /// The same once its filter has filled: what it may take between two + /// outputs for as long as it runs. + steady_period: usize, channels: usize, in_planar: Vec>, out_planar: Vec>, @@ -110,6 +161,21 @@ pub struct FixedRateResampler { out_max: usize, } +enum FixedInner { + Fft(FftFixedIn), + Sinc(SincFixedIn), +} + +const MAX_FFT_PERIOD_MS: usize = 10; + +fn gcd(a: usize, b: usize) -> usize { + if b == 0 { + a + } else { + gcd(b, a % b) + } +} + impl FixedRateResampler { pub fn new( from_rate: u32, @@ -118,17 +184,33 @@ impl FixedRateResampler { channels: usize, ) -> AppResult { const FFT_SUB_CHUNKS: usize = 4; - let inner = FftFixedIn::::new( - from_rate as usize, - to_rate as usize, - chunk_size, - FFT_SUB_CHUNKS, - channels, - ) - .map_err(|e| AppError::Stream(format!("fixed resampler init: {e}")))?; - let out_max = inner.output_frames_max(); + let init = |e: rubato::ResamplerConstructionError| { + AppError::Stream(format!("fixed resampler init: {e}")) + }; + let (from, to) = (from_rate.max(1) as usize, to_rate.max(1) as usize); + let period = from / gcd(from, to); + let (inner, input_period, steady_period) = if period * 1000 <= from * MAX_FFT_PERIOD_MS { + let fft = FftFixedIn::::new(from, to, chunk_size, FFT_SUB_CHUNKS, channels) + .map_err(init)?; + (FixedInner::Fft(fft), period, period) + } else { + let ratio = to as f64 / from as f64; + let sinc = SincFixedIn::::new(ratio, 1.0, sinc_params(), chunk_size, channels) + .map_err(init)?; + // The filter fills before the first frame comes out; downsampling + // hard, a small chunk may yield no whole frame after that either. + let fill = (sinc.output_delay() * from).div_ceil(to); + let steady = from.div_ceil(to); + (FixedInner::Sinc(sinc), fill + steady, steady) + }; + let out_max = match &inner { + FixedInner::Fft(r) => r.output_frames_max(), + FixedInner::Sinc(r) => r.output_frames_max(), + }; Ok(Self { inner, + input_period, + steady_period, channels, in_planar: vec![vec![0.0; chunk_size]; channels], out_planar: vec![vec![0.0; out_max]; channels], @@ -142,6 +224,24 @@ impl FixedRateResampler { self.out_max } + /// Output-rate frames between a sample entering and leaving the filter. + pub fn delay_frames(&self) -> usize { + match &self.inner { + FixedInner::Fft(r) => r.output_delay(), + FixedInner::Sinc(r) => r.output_delay(), + } + } + + /// Input frames the resampler may take before it emits anything. + pub fn input_period(&self) -> usize { + self.input_period + } + + /// Input frames it may take between two outputs once running. + pub fn steady_period(&self) -> usize { + self.steady_period + } + pub fn process_chunk_into( &mut self, interleaved_in: &[f32], @@ -159,10 +259,16 @@ impl FixedRateResampler { self.in_planar[c][i] = frame[c]; } } - let (_, produced) = self - .inner - .process_into_buffer(&self.in_planar, &mut self.out_planar, Some(&self.active)) - .map_err(|e| AppError::Stream(format!("fixed resampler process: {e}")))?; + let active = Some(&self.active[..]); + let (_, produced) = match &mut self.inner { + FixedInner::Fft(r) => { + r.process_into_buffer(&self.in_planar, &mut self.out_planar, active) + } + FixedInner::Sinc(r) => { + r.process_into_buffer(&self.in_planar, &mut self.out_planar, active) + } + } + .map_err(|e| AppError::Stream(format!("fixed resampler process: {e}")))?; for i in 0..produced { for c in 0..self.channels { output[i * self.channels + c] = if c < active_channels { @@ -204,6 +310,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 +409,67 @@ 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_never_waits_long_at_an_odd_rate() { + // One hertz off a standard rate the two share no period shorter + // than a second; once its filter has filled, the resampler must + // still emit on every block. + for (from, to) in [(48_000, 48_001), (48_001, 48_000), (44_100, 47_999)] { + let mut r = FixedRateResampler::new(from, to, 64, 2).unwrap(); + assert!( + r.input_period() <= 256, + "{from}->{to}: waits {}", + r.input_period() + ); + let input = vec![0.25_f32; 64 * 2]; + let mut out = vec![0.0_f32; r.out_max() * 2]; + let mut tail = Vec::new(); + for i in 0..200 { + let n = r.process_chunk_into(&input, 2, &mut out).unwrap(); + if i * 64 >= r.input_period() { + assert!(n > 0, "{from}->{to}: block {i} produced nothing"); + } + tail = out[..n].to_vec(); + } + assert!( + tail.iter().all(|s| (s - 0.25).abs() < 1e-3), + "{from}->{to}: level off" + ); + } + } + #[test] fn fixed_rate_resampler_leaves_trailing_channels_silent() { const CHANNELS: usize = 16; diff --git a/src-tauri/src/audio/rt_guard.rs b/src-tauri/src/audio/rt_guard.rs new file mode 100644 index 0000000..d96d827 --- /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); + } +} diff --git a/src-tauri/src/audio/signaling.rs b/src-tauri/src/audio/signaling.rs index 70b457d..39409b0 100644 --- a/src-tauri/src/audio/signaling.rs +++ b/src-tauri/src/audio/signaling.rs @@ -12,6 +12,8 @@ use crate::audio::webrtc; use crate::error::{AppError, AppResult}; const SIG_BASE: &str = "wss://sig.splitwave.app"; +/// Longest a join attempt waits to reach the signaling server and hear the +/// host's offer, and then for the peer connection to come up. const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); const JOIN_ATTEMPTS: u32 = 3; @@ -69,7 +71,10 @@ pub async fn host_loop( ) -> AppResult<()> { let url = format!("{SIG_BASE}/ws/{room_code}?role=host&passwordHash={password_hash}"); loop { - let (ws, _) = connect_async(&url).await.map_err(sig_err)?; + let (ws, _) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(&url)) + .await + .map_err(|_| sig_err("signaling server did not answer"))? + .map_err(sig_err)?; host_session(ws, &node_id, opus_bitrate, opus_application).await; info!(room = %room_code, "signaling reconnect"); tokio::time::sleep(Duration::from_secs(2)).await; @@ -196,10 +201,17 @@ async fn guest_attempt( opus_application: OpusApplication, ) -> AppResult { let url = format!("{SIG_BASE}/ws/{room_code}?role=guest&passwordHash={password_hash}"); - let (mut ws, _) = connect_async(&url).await.map_err(sig_err)?; + let reach = tokio::time::Instant::now() + CONNECT_TIMEOUT; + let Ok(connected) = tokio::time::timeout_at(reach, connect_async(&url)).await else { + return Ok(false); + }; + let (mut ws, _) = connected.map_err(sig_err)?; let (connection_id, offer_sdp) = loop { - match ws.next().await { + let Ok(frame) = tokio::time::timeout_at(reach, ws.next()).await else { + return Ok(false); + }; + match frame { Some(Ok(frame)) => { let Some(m) = parse_msg(frame) else { continue }; match m.kind.as_str() { @@ -243,28 +255,33 @@ async fn guest_attempt( let mut candidates = answer.candidates; let pc = answer.pc; + let mut state = answer.state; let mut cands_done = false; let mut ws_done = false; - let deadline = tokio::time::Instant::now() + CONNECT_TIMEOUT; - let mut tick = tokio::time::interval(Duration::from_millis(250)); + let deadline = tokio::time::sleep(CONNECT_TIMEOUT); + tokio::pin!(deadline); loop { tokio::select! { - _ = tick.tick() => { + changed = state.changed() => { use ::webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState as S; - match pc.connection_state() { + if changed.is_err() { + return Ok(false); + } + let now = *state.borrow_and_update(); + match now { S::Connected => { let _ = ws.close(None).await; return Ok(true); } S::Failed | S::Closed => return Ok(false), - _ if tokio::time::Instant::now() >= deadline => { - let _ = pc.close().await; - return Ok(false); - } _ => {} } } + _ = &mut deadline => { + let _ = pc.close().await; + return Ok(false); + } c = candidates.recv(), if !cands_done => { match c { Some(c) => { diff --git a/src-tauri/src/audio/stream_recv.rs b/src-tauri/src/audio/stream_recv.rs index c4bacb2..6ff9e7d 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 @@ -22,63 +24,51 @@ use std::sync::{Arc, Mutex, Weak}; use rtrb::{Consumer, Producer, RingBuffer}; +use crate::audio::adaptive_depth::{DepthEstimator, DepthMemo, 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; /// 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 -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. -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 - -/// 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; - -/// 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; +/// Jitter-buffer depth the drift loop steers the fill toward and the buffer +/// primes to, in the source's own samples. Sized by `DepthEstimator` from the +/// arrival jitter actually measured, packet size included: a link that +/// delivers small packets steadily gets a shallow buffer, whatever the codec. +const TARGET_INIT_MS: f64 = 60.0; // until the first packets are seen +const TARGET_MAX_MS: f64 = 400.0; +/// Kept above the measured jitter, as the live input queues keep theirs. +const TARGET_SAFETY_MS: f64 = 2.0; +/// 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_MS: f64 = 100.0; +/// The depth primed from `TARGET_INIT_MS` is corrected once, when measured, +/// by one cut under a crossfade of up to `SPLICE_FADE_MS`. When the buffer +/// cannot hold the whole cut at once, the rest waits `SPLICE_EVERY_MS`. +const SPLICE_FADE_MS: f64 = 10.0; +const SPLICE_EVERY_MS: f64 = 21.0; +/// Time constant of the fill gauge. +const FILL_SMOOTHING_MS: f64 = 250.0; + +/// `ms` in samples at `rate`. +const fn frames_at(ms: f64, rate: u32) -> usize { + (ms * rate as f64 / 1000.0 + 0.5) as usize +} + +/// 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. @@ -92,6 +82,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)] @@ -110,6 +102,13 @@ fn extend_seq(seq: u16, near: u64) -> u64 { near.wrapping_add(delta as i64 as u64) } +/// A receiver's smoothed buffer fill, in samples at `rate`. +#[derive(Clone)] +pub struct FillGauge { + pub frames: Arc, + pub rate: Arc, +} + /// A consumer's per-channel playback taps, keyed by channel id. pub type TapMap = Arc>>; @@ -119,6 +118,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 @@ -135,6 +137,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); } @@ -179,6 +194,7 @@ pub struct PlaybackTap { last_ratio: f64, realtime: bool, drift: Arc, + clock: Arc, in_buf: Vec, pub scratch: Vec, pub valid: usize, @@ -191,27 +207,41 @@ pub struct PlaybackTap { popped: u64, 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. last_block: Vec, gap: bool, + /// This tap replaced one that was still playing: its first silent block + /// fades that one's last block out instead of cutting it off. + fade_out_pending: bool, + /// The audio before a startup cut, blended into the audio after it as + /// the next blocks are read; `xfade_pos` is how far that has got. + xfade: Vec, + xfade_pos: usize, } impl PlaybackTap { + #[allow(clippy::too_many_arguments)] fn new( group: u64, consumer: Consumer, rate: u32, + block_frames: usize, realtime: bool, 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; - let resampler = - MultiResamplerOut::new(in_sr, rate, OUT_BLOCK_FRAMES, 1).expect("mono resampler init"); + let resampler = MultiResamplerOut::for_drift(in_sr, rate, block_frames, 1) + .expect("mono resampler init"); + let in_buf = Vec::with_capacity(resampler.input_frames_max()); Self { group, consumer, @@ -223,16 +253,24 @@ impl PlaybackTap { last_ratio: base_ratio, realtime, drift, - in_buf: Vec::with_capacity(4096), - scratch: Vec::with_capacity(OUT_BLOCK_FRAMES), + clock, + in_buf, + 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], - gap: false, + 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], + // A stream's first audio fades in like audio after a gap. + gap: true, + fade_out_pending: false, + xfade: Vec::with_capacity(frames_at(SPLICE_FADE_MS, in_sr.max(SR))), + xfade_pos: 0, } } @@ -248,13 +286,14 @@ 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, /// 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; @@ -263,7 +302,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; } } @@ -283,22 +322,29 @@ impl PlaybackTap { } /// Emit silence for this block without touching the ring (the group is - /// priming). + /// priming), after fading out the stream this tap replaced. fn hold(&mut self) -> usize { + if std::mem::take(&mut self.fade_out_pending) { + return self.fade_out(); + } self.valid = 0; 0 } /// 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; 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::for_drift(in_sr, self.rate, self.last_block.len(), 1) + { + self.in_buf.clear(); + self.in_buf.reserve(r.input_frames_max()); self.resampler = r; } } @@ -316,7 +362,29 @@ 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 + }; + if splice > 0 && self.xfade_pos >= self.xfade.len() { + // What comes before the cut plays on, blended into what comes + // after it over the next blocks. + let fade = splice.min(self.xfade.capacity()); + self.xfade.clear(); + if let Ok(chunk) = self.consumer.read_chunk(fade) { + let (a, b) = chunk.as_slices(); + self.xfade.extend_from_slice(a); + self.xfade.extend_from_slice(b); + chunk.commit_all(); + } + if let Ok(chunk) = self.consumer.read_chunk(splice - fade) { + chunk.commit_all(); + } + self.xfade_pos = 0; + self.popped += splice as u64; + } self.in_buf.clear(); if let Ok(chunk) = self.consumer.read_chunk(need) { let (a, b) = chunk.as_slices(); @@ -325,6 +393,15 @@ impl PlaybackTap { chunk.commit_all(); self.popped += need as u64; } + let len = self.xfade.len(); + for s in self.in_buf.iter_mut() { + if self.xfade_pos >= len { + break; + } + let w = (self.xfade_pos + 1) as f32 / len as f32; + *s = self.xfade[self.xfade_pos] * (1.0 - w) + *s * w; + self.xfade_pos += 1; + } self.scratch.clear(); if self .resampler @@ -361,9 +438,15 @@ impl PlaybackTap { self.valid = 0; return 0; } + self.fade_out() + } + + /// The last real block played backwards while fading out: it starts on + /// the very sample the stream stopped at, so the fade has no step. + fn fade_out(&mut self) -> usize { self.gap = true; self.scratch.clear(); - self.scratch.extend_from_slice(&self.last_block); + self.scratch.extend(self.last_block.iter().rev()); let frames = self.scratch.len(); for (i, s) in self.scratch.iter_mut().enumerate() { *s *= 1.0 - (i as f32 + 1.0) / frames as f32; @@ -384,6 +467,7 @@ pub struct FanoutRegistry { struct ConsumerRef { rate: u32, + block_frames: usize, realtime: bool, drift: Arc, target: Arc, @@ -412,10 +496,15 @@ 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)); + let target = Arc::new(AtomicU32::new(frames_at(TARGET_INIT_MS, SR) as u32)); let mut consumers = self.consumers.lock().unwrap(); consumers.retain(|c| c.taps.strong_count() > 0); // A source's rings are created together under its push lock, so they all @@ -463,10 +552,12 @@ impl FanoutRegistry { gid, cons, output_sr, + block_frames, realtime, false, drift.clone(), bc.sample_rate.clone(), + bc.clock.clone(), ), ); } @@ -474,6 +565,7 @@ impl FanoutRegistry { drop(broadcasts); consumers.push(ConsumerRef { rate: output_sr, + block_frames, realtime, drift: drift.clone(), target: target.clone(), @@ -484,20 +576,34 @@ impl FanoutRegistry { drift, target, realtime, + block_frames, + output_rate: output_sr, } } /// New received channel, wired into every live consumer. `first_seq` is the /// packet about to be pushed: its distance from the siblings' position is /// what the fresh rings open with, so the channel joins in phase. - pub fn attach_channel(&self, key: String, first_seq: u16) -> ChannelBroadcast { + pub fn attach_channel( + &self, + key: String, + first_seq: u16, + sample_rate: u32, + ) -> ChannelBroadcast { let gid = group_id(&key); let sync = self.group_sync(gid); let bc: ChannelBroadcast = Arc::new(ChannelFeed { sync: sync.clone(), prods: Mutex::new(Vec::new()), state: Mutex::new(FeedState::default()), - sample_rate: Arc::new(AtomicU32::new(SR)), + // The taps below build their resamplers for this rate, so the + // audio thread never has to. + sample_rate: Arc::new(AtomicU32::new(if sample_rate > 0 { + sample_rate + } else { + SR + })), + clock: Arc::new(WriteClock::default()), }); let mut consumers = self.consumers.lock().unwrap(); consumers.retain(|c| c.taps.strong_count() > 0); @@ -542,18 +648,26 @@ impl FanoutRegistry { bulk_push_counted(&mut prod, &pad, &health::NET_RING_OVERRUN_SAMPLES); } bc.prods.lock().unwrap().push(prod); - taps.insert( - key.clone(), - PlaybackTap::new( - gid, - cons, - c.rate, - c.realtime, - primed, - c.drift.clone(), - bc.sample_rate.clone(), - ), + let mut tap = PlaybackTap::new( + gid, + cons, + c.rate, + c.block_frames, + c.realtime, + primed, + c.drift.clone(), + bc.sample_rate.clone(), + bc.clock.clone(), ); + // Replacing a stream still playing (its sender restarted): + // the new tap fades the old one out before it starts. + if let Some(old) = taps.get(&key) { + if !old.gap && old.last_block.len() == tap.last_block.len() { + tap.last_block.copy_from_slice(&old.last_block); + tap.fade_out_pending = true; + } + } + taps.insert(key.clone(), tap); } } self.broadcasts.lock().unwrap().insert(key, bc.clone()); @@ -574,15 +688,10 @@ impl FanoutRegistry { /// Forgets one channel. Its next packet re-attaches it, which is how a /// stream that broke for longer than concealment covers gets back in phase. + /// Its taps stay until then: they play out what they hold and fade, where + /// removing them would cut the stream off mid-waveform. pub fn drop_channel(&self, key: &str) { self.broadcasts.lock().unwrap().remove(key); - let mut consumers = self.consumers.lock().unwrap(); - consumers.retain(|c| c.taps.strong_count() > 0); - for c in consumers.iter() { - if let Some(map) = c.taps.upgrade() { - map.lock().unwrap().remove(key); - } - } } /// Drops channels whose key starts with `prefix`. @@ -608,55 +717,166 @@ 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, - starve_blocks: Cell, + block_frames: usize, + output_rate: u32, + /// Rate of the sources this consumer plays, which every count below is + /// in. Changing it starts the adaptive state afresh. + rate: Cell, + // Adaptive-jitter state; only the single audio thread touches these. + depth: std::cell::RefCell, + judge: std::cell::RefCell, + /// Source samples one block consumes. + need: Cell, + starving: Cell, + /// Largest delivery between two blocks while the depth is unmeasured, + /// and whether the first (possibly a backlog) has gone by. + packet: Cell, + seen_arrival: 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, + /// The startup depth correction has been decided; what is left of it. + settled: Cell, + owed: Cell, + cooldown: Cell, + cooldown_blocks: Cell, + 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>, + /// Smoothed fill of the emptiest playing source, in its samples: the + /// latency the receive buffer adds. A gauge for the latency report. + fill: Arc, + fill_rate: Arc, + fill_avg: Cell, + /// Share of the gap `fill_avg` closes per block, for `FILL_SMOOTHING_MS`. + fill_alpha: f64, // 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>, // Per-block scratch for the group decisions; reused so `mix_block` never // allocates on the DSP thread. plans: std::cell::RefCell>, + /// The depth measured so far, kept for a rate change and for the + /// receiver that replaces this one, so neither guesses and corrects. + memo: Arc, + /// Depth recalled from `memo`: the prior never drops below it. + recalled: Cell, } /// One source's buffer decision for this block, taken over all its channels. 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, } impl ChannelReceiver { - pub fn new(handle: ConsumerHandle) -> Self { - Self { + pub fn new(handle: ConsumerHandle, memo: Arc) -> Self { + let block_secs = handle.block_frames as f64 / handle.output_rate.max(1) as f64; + let receiver = Self { taps: handle.taps, drift: handle.drift, target: handle.target, realtime: handle.realtime, - min_backlog: Cell::new(usize::MAX), - window_blocks: Cell::new(0), - starve_blocks: Cell::new(0), + block_frames: handle.block_frames, + output_rate: handle.output_rate, + rate: Cell::new(SR), + depth: std::cell::RefCell::new(DepthEstimator::new(SR, 1, JITTER_WINDOW_MS, 0)), + judge: std::cell::RefCell::new(OutageJudge::new(SR)), + need: Cell::new(1), + starving: Cell::new(false), + packet: Cell::new(0), + seen_arrival: Cell::new(false), + start: Cell::new(0), ever_primed: Cell::new(false), - avg_backlog: Cell::new(-1.0), - last_mix: std::cell::RefCell::new(Vec::new()), + settled: Cell::new(false), + owed: Cell::new(0), + cooldown: Cell::new(0), + cooldown_blocks: Cell::new(1), + drift_loop: std::cell::RefCell::new(DriftLoop::new(SR, 1)), + arrival: std::cell::RefCell::new(ArrivalClock::new(SR)), + steered: Cell::new(None), + fill: Arc::new(AtomicU32::new(0)), + fill_rate: Arc::new(AtomicU32::new(SR)), + fill_avg: Cell::new(0.0), + fill_alpha: 1.0 - (-block_secs * 1000.0 / FILL_SMOOTHING_MS).exp(), + last_mix: std::cell::RefCell::new(vec![0.0; handle.block_frames * 2]), plans: std::cell::RefCell::new(Vec::with_capacity(8)), + memo, + recalled: Cell::new(0), + }; + receiver.retime(SR); + receiver + } + + /// Counts everything in `rate` samples from here on, starting the + /// adaptive state afresh in these units from the depth measured so far, + /// if any. RT-safe; none of the state allocates. + fn retime(&self, rate: u32) { + let need = (self.block_frames * rate as usize / self.output_rate.max(1) as usize).max(1); + let init = frames_at(TARGET_INIT_MS, rate); + self.rate.set(rate); + self.fill_rate.store(rate, Ordering::Relaxed); + self.need.set(need); + *self.depth.borrow_mut() = DepthEstimator::new(rate, need, JITTER_WINDOW_MS, init); + *self.judge.borrow_mut() = OutageJudge::new(rate); + *self.drift_loop.borrow_mut() = DriftLoop::new(rate, need); + self.cooldown_blocks + .set((frames_at(SPLICE_EVERY_MS, rate) / need).max(1)); + self.packet.set(0); + self.seen_arrival.set(false); + self.start.set(init); + self.target.store(init as u32, Ordering::Relaxed); + self.settled.set(false); + self.owed.set(0); + self.cooldown.set(0); + self.steered.set(None); + self.fill_avg.set(0.0); + self.recalled.set(0); + if let Some((dip, packet)) = self.memo.recall(rate) { + // Measured already: start at that depth, with nothing to correct. + self.recalled.set(dip); + self.depth.borrow_mut().set_prior(dip); + self.packet.set(packet); + self.settled.set(true); + let max = frames_at(TARGET_MAX_MS, rate); + let target = (need + dip + frames_at(TARGET_SAFETY_MS, rate)).min(max); + self.target.store(target as u32, Ordering::Relaxed); + self.start.set((target + dip).min(max)); } } /// 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() / 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. @@ -684,22 +904,40 @@ 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); p.primed &= tap.primed; } 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, }), } } + // Every count is in the sources' own samples; a source at another + // rate starts the adaptive state afresh in its units. + if let Some(p) = plans.iter().min_by_key(|p| p.min_backlog) { + if p.rate != 0 && p.rate != self.rate.get() { + self.retime(p.rate); + } + } + let rate = self.rate.get(); let target = self.target.load(Ordering::Relaxed) as usize; for p in plans.iter_mut() { if !p.primed { @@ -707,11 +945,12 @@ 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; } 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 @@ -723,7 +962,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 + frames_at(TARGET_EVENT_MAX_MS, rate) * 4); if p.min_backlog > hard_cap { p.trim = p.min_backlog - target; } @@ -742,11 +981,28 @@ 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.account(starving, live.map(|p| (p.min_backlog, p.need))); + || (self.ever_primed.get() && (plans.is_empty() || plans.iter().any(|p| p.hold))); + // 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); + let avg = self.fill_avg.get(); + let avg = avg + (p.min_backlog as f64 - avg) * self.fill_alpha; + 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), + Some(_) => {} + None => self.steered.set(None), } } if plans.iter().any(|p| !p.hold) { @@ -764,11 +1020,16 @@ impl ChannelReceiver { continue; }; if plan.hold { - tap.hold(); + let n = tap.hold().min(frames); + for (frame, &v) in mix.chunks_mut(width).zip(tap.scratch[..n].iter()) { + for s in frame.iter_mut() { + *s += v; + } + } 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 +1039,7 @@ impl ChannelReceiver { } tap.primed = true; tap.trim(plan.trim); - let n = tap.fill_block().min(OUT_BLOCK_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; @@ -793,71 +1054,116 @@ 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)>) { - if starving { - self.starve_blocks.set(self.starve_blocks.get() + 1); - self.min_backlog.set(usize::MAX); - self.window_blocks.set(0); - return; + /// 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.get()); + // 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.get()) { + if late > 0 { + depth.underrun(late); + } } - // 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); + // Until the jitter is measured, a whole delivery stands in for it: + // twice an ideal saw's dip, room for jitter not yet measured. + if !depth.is_measured() && arrived > 0 && self.seen_arrival.replace(true) { + let packet = self.packet.get(); + let cap = if packet > 0 { 2 * packet } else { usize::MAX }; + self.packet.set(packet.max(arrived.min(cap))); + depth.set_prior(self.packet.get().max(self.recalled.get())); + } + if starving { return; } + let need = match live { + Some((backlog, need)) => { + if depth.observe(backlog) && depth.is_measured() { + self.memo + .keep(depth.depth(), self.packet.get(), self.rate.get()); + } + need + } + None => self.need.get(), + }; + let dip = depth.depth(); + let rate = self.rate.get(); + let max = frames_at(TARGET_MAX_MS, rate); + let target = (need + dip + frames_at(TARGET_SAFETY_MS, rate)).min(max); + self.target.store(target as u32, Ordering::Relaxed); + self.start.set((target + dip).min(max)); + } - 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; + /// 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))); } - // 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); - self.target.store(target as u32, Ordering::Relaxed); + 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; + } + // As much as the buffer holds past this block, in one cut. + let n = self.owed.get().min(p.min_backlog.saturating_sub(p.need)); + if n == 0 { + return 0; + } + self.owed.set(self.owed.get() - n); + self.cooldown.set(self.cooldown_blocks.get()); + return n; } - self.min_backlog.set(usize::MAX); - self.window_blocks.set(0); + let u = drift.update(error); + let d = 1.0 / (1.0 + u); + self.drift.store((d as f32).to_bits(), Ordering::Relaxed); + 0 } - /// 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); - self.drift.store((d as f32).to_bits(), Ordering::Relaxed); + /// Smoothed receive-buffer fill and the rate it counts in, for the + /// latency report. + pub fn fill_gauge(&self) -> FillGauge { + FillGauge { + frames: self.fill.clone(), + rate: self.fill_rate.clone(), + } } /// Copy one channel's already-resampled scratch into `out`. @@ -869,7 +1175,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 +1190,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,21 +1208,362 @@ impl ChannelReceiver { mod tests { use super::*; + const TARGET_INIT: usize = frames_at(TARGET_INIT_MS, SR); + const TARGET_SAFETY: usize = frames_at(TARGET_SAFETY_MS, SR); + + 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); + let bc = reg.attach_channel(key.to_string(), seq, SR); broadcast_push(&bc, seq + 7, 8, &vec![0.5f32; frames]); } - let recv = ChannelReceiver::new(handle); + let recv = ChannelReceiver::new(handle, Arc::default()); (reg, recv) } + struct NetRun { + /// Output samples after settling that were not the pushed constant. + 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 + /// `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 { + 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 { + net_sim_packets(block, 960, seconds, settle, jitter, outage, quiet) + } + + /// As `net_sim_with`, with `packet_len`-sample packets. + fn net_sim_packets( + block: usize, + packet_len: usize, + seconds: f64, + settle: f64, + jitter: impl Fn(u32) -> f64, + outage: Option<(f64, f64)>, + quiet: &[(f64, f64)], + ) -> NetRun { + 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, SR); + let recv = ChannelReceiver::new(handle, Arc::default()); + let period = packet_len 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, + max_fill: 0, + }; + let (mut k, mut t) = (0u32, 0.0); + let mut mix = vec![0.0f32; block]; + let packet = vec![0.5f32; packet_len]; + 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) + || quiet.iter().any(|&(a, b)| sent >= a && sent < b); + if !dropped { + broadcast_push_at(&bc, k as u16, 1, &packet, SR, due); + } + k += 1; + continue; + } + 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 { + 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 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, SR); + let recv = ChannelReceiver::new(handle, Arc::default()); + 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 <= 2 * PACKET + TARGET_SAFETY, + "still {late_fill} samples buffered" + ); + } + + #[test] + fn a_clean_local_link_buffers_only_a_few_milliseconds() { + // PCM packets (300 samples, 6.25 ms) on a loopback link into a + // 32-frame speaker: nothing on the link needs more than one packet's + // saw and the safety margin, no fixed codec-sized floor. + let r = net_sim_packets(32, 300, 30.0, 10.0, |_| 0.0, None, &[]); + assert_eq!(r.gaps, 0, "gaps on a clean link"); + assert!( + r.target <= 300 + 32 + TARGET_SAFETY, + "buffers {} samples", + r.target + ); + // Scheduling jitter on the sending side, up to 1 ms per packet. + let r = net_sim_packets(32, 300, 30.0, 10.0, |k| noise(k) * 0.001, None, &[]); + assert_eq!(r.gaps, 0, "gaps with 1 ms of jitter"); + assert!(r.target < 480, "buffers {} samples", r.target); + } + + #[test] + fn small_packets_start_shallow_before_anything_is_measured() { + let reg = FanoutRegistry::default(); + let recv = ChannelReceiver::new(reg.register_consumer(48_000, 32, true), Arc::default()); + let bc = reg.attach_channel("0".into(), 0, SR); + let mut mix = vec![0.0f32; 32]; + let packet = vec![0.5f32; 32]; + for k in 0..100u16 { + let t = k as f64 * 32.0 / SR as f64; + broadcast_push_at(&bc, k, 1, &packet, SR, t); + recv.mix_block_at(&mut mix, t); + } + assert!(!recv.depth.borrow().is_measured()); + assert!( + recv.start.get() < TARGET_INIT / 8, + "starts at {} samples", + recv.start.get() + ); + } + + #[test] + fn the_buffer_holds_the_same_time_at_any_source_rate() { + let depth_ms = |rate: u32| { + let reg = FanoutRegistry::default(); + let recv = + ChannelReceiver::new(reg.register_consumer(48_000, 64, true), Arc::default()); + let bc = reg.attach_channel("0".into(), 0, SR); + let mut mix = vec![0.0f32; 64]; + // 5 ms packets for ten seconds, read every 64 output frames. + let packet = vec![0.5f32; rate as usize / 200]; + let (mut k, mut t) = (0u16, 0.0); + while t < 10.0 { + if k as f64 * 0.005 <= t { + broadcast_push_at(&bc, k, 1, &packet, rate, t); + k = k.wrapping_add(1); + continue; + } + recv.mix_block_at(&mut mix, t); + t += 64.0 / 48_000.0; + } + recv.target.load(Ordering::Relaxed) as f64 * 1000.0 / rate as f64 + }; + let (at48, at96) = (depth_ms(48_000), depth_ms(96_000)); + assert!((at48 - at96).abs() < 1.5, "{at48:.2} ms vs {at96:.2} ms"); + } + + #[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 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 >= 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))); + 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, SR); + let recv = ChannelReceiver::new(handle, Arc::default()); + 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 +1601,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}"); @@ -964,11 +1611,11 @@ mod tests { #[test] fn unprimed_consumer_streams_silence() { let reg = FanoutRegistry::default(); - let bc = reg.attach_channel("ch".into(), 0); + let bc = reg.attach_channel("ch".into(), 0, SR); // Nothing pushed: a realtime consumer must stream silence, not panic. - let handle = reg.register_consumer(48_000, true); - let recv = ChannelReceiver::new(handle); - let mut mix = vec![0.0f32; OUT_BLOCK_FRAMES * 2]; + let handle = reg.register_consumer(48_000, BLOCK, true); + let recv = ChannelReceiver::new(handle, Arc::default()); + let mut mix = vec![0.0f32; BLOCK * 2]; recv.mix_block(&mut mix); assert!(mix.iter().all(|s| *s == 0.0)); let _ = bc; @@ -977,14 +1624,14 @@ mod tests { #[test] 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 bc = reg.attach_channel("0".into(), 10, SR); + 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 recv = ChannelReceiver::new(handle, Arc::default()); + 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 +1640,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 recv = ChannelReceiver::new(handle); - let mut tap = vec![0.0f32; OUT_BLOCK_FRAMES]; + let handle = reg.register_consumer(48_000, BLOCK, true); + let recv = ChannelReceiver::new(handle, Arc::default()); + 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); @@ -1005,19 +1652,19 @@ mod tests { #[test] 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 bc = reg.attach_channel("0".into(), 1, SR); + 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); reg.drop_channel("0"); assert_eq!( taps.lock().unwrap().len(), - 0, - "dropped channel leaves the tap map" + 1, + "a dropped channel's tap plays out what it holds" ); - // Re-attach works after a drop. - let bc2 = reg.attach_channel("0".into(), 9); + // Re-attach replaces it. + let bc2 = reg.attach_channel("0".into(), 9, SR); broadcast_push(&bc2, 10, 1, &vec![0.5f32; 960]); assert_eq!(taps.lock().unwrap().len(), 1); // clear() forgets consumers and broadcasts; the tap map itself is @@ -1030,9 +1677,9 @@ mod tests { fn buffer_depth_reports_the_consumer_target() { let reg = FanoutRegistry::default(); assert_eq!(reg.buffer_depth(), None, "no consumers yet"); - let bc = reg.attach_channel("c".into(), 1); + let bc = reg.attach_channel("c".into(), 1, SR); 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/cpal_stream.rs b/src-tauri/src/audio/streams/cpal_stream.rs index 4f604b9..a614d53 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,40 +68,37 @@ 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, f32: cpal::FromSample, { - let mut staging: Vec = vec![0.0; 16384]; + let mut staging: Vec = vec![0.0; scratch_frames(config) * src_channels.max(1)]; let stream = device .build_input_stream::( - config, + *config, move |data, _| { bridge.apply_commands(); if src_channels == 0 || data.is_empty() { return; } - let needed = data.len(); - if staging.len() < needed { - staging.resize(needed, 0.0); - } - for (o, &s) in staging[..needed].iter_mut().zip(data) { - *o = s.to_sample::(); - } - if let Some(m) = &meter { - update_meter(m, &staging[..needed], src_channels); + for chunk in data.chunks(staging.len()) { + let staged = &mut staging[..chunk.len()]; + for (o, &s) in staged.iter_mut().zip(chunk) { + *o = s.to_sample::(); + } + if let Some(m) = &meter { + update_meter(m, staged, src_channels); + } + bridge.broadcast(staged); } - 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 +109,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,22 +146,16 @@ 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, F: FnMut(&mut [f32], usize) + Send + 'static, { - const DEFAULT_SCRATCH_FRAMES: usize = 1024; - let configured_frames = match config.buffer_size { - cpal::BufferSize::Fixed(frames) => frames as usize, - cpal::BufferSize::Default => DEFAULT_SCRATCH_FRAMES, - }; - let scratch_samples = configured_frames.max(1) * out_channels.max(1); - let mut buf: Vec = vec![0.0; scratch_samples]; + let mut buf: Vec = vec![0.0; scratch_frames(config) * out_channels.max(1)]; let stream = device .build_output_stream::( - config, + *config, move |data, _| { if out_channels == 0 || data.is_empty() { return; @@ -177,24 +169,55 @@ 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) } + +/// Frames converted at a time. A callback larger than asked for is handled +/// in pieces of this size, never by growing the buffer on the audio thread. +fn scratch_frames(config: &StreamConfig) -> usize { + match config.buffer_size { + cpal::BufferSize::Fixed(frames) => (frames as usize).max(1), + cpal::BufferSize::Default => crate::audio::graph::MAX_BUFFER_FRAMES, + } +} + +/// 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/audio/streams/mod.rs b/src-tauri/src/audio/streams/mod.rs index f2a6355..a25fe51 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,41 +15,12 @@ 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). -/// 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. @@ -59,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 } diff --git a/src-tauri/src/audio/virtual_device/macos.rs b/src-tauri/src/audio/virtual_device/macos.rs index 77d528d..9e73c9d 100644 --- a/src-tauri/src/audio/virtual_device/macos.rs +++ b/src-tauri/src/audio/virtual_device/macos.rs @@ -78,7 +78,10 @@ fn build_plist(devices: &[VirtualDeviceConfig]) -> String { xml_escape(&d.id), xml_escape(&d.name), d.channels.clamp(1, 256), - d.sample_rate.clamp(8_000, 384_000) + d.sample_rate.clamp( + crate::audio::graph::MIN_SAMPLE_RATE, + crate::audio::graph::MAX_SAMPLE_RATE + ) )); } plist.push_str("\n\n"); diff --git a/src-tauri/src/audio/virtual_device/mod.rs b/src-tauri/src/audio/virtual_device/mod.rs index 60bc2d5..1bb32a7 100644 --- a/src-tauri/src/audio/virtual_device/mod.rs +++ b/src-tauri/src/audio/virtual_device/mod.rs @@ -14,11 +14,11 @@ fn default_channels() -> u32 { } fn default_sample_rate() -> u32 { - 48_000 + crate::audio::graph::DEFAULT_SAMPLE_RATE } // Bump with any driver bundle change; keep in sync with Info.plist CFBundleVersion. -pub const DRIVER_VERSION: u32 = 5; +pub const DRIVER_VERSION: u32 = 6; #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/audio/wake.rs b/src-tauri/src/audio/wake.rs new file mode 100644 index 0000000..692d0f5 --- /dev/null +++ b/src-tauri/src/audio/wake.rs @@ -0,0 +1,58 @@ +//! Wakes a helper thread from the audio path without blocking it: the helper +//! sleeps until there is work, the audio path rings once it has left some. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::thread::{self, Thread}; +use std::time::Duration; + +#[derive(Default)] +pub struct Doorbell { + thread: OnceLock, + /// Set by a ring, taken by the next wait: a ring is never lost, even one + /// before anyone answers. + rung: AtomicBool, +} + +impl Doorbell { + /// Called once by the thread that waits, before it first waits. + pub fn answer_here(&self) { + let _ = self.thread.set(thread::current()); + } + + /// RT-safe: never blocks (see docs/CONCEPT.md, "RT audio path"). + pub fn ring(&self) { + self.rung.store(true, Ordering::Release); + if let Some(t) = self.thread.get() { + t.unpark(); + } + } + + /// Sleeps until rung or `timeout` passes; returns at once if rung since + /// the last wait. Call only from the thread that answered. + pub fn wait(&self, timeout: Duration) { + if !self.rung.swap(false, Ordering::Acquire) { + thread::park_timeout(timeout); + self.rung.store(false, Ordering::Relaxed); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + #[test] + fn a_ring_before_anyone_answers_is_kept() { + let bell = Doorbell::default(); + bell.ring(); + bell.answer_here(); + let started = Instant::now(); + bell.wait(Duration::from_secs(30)); + assert!( + started.elapsed() < Duration::from_secs(10), + "the early ring was lost" + ); + } +} diff --git a/src-tauri/src/audio/webrtc/channels.rs b/src-tauri/src/audio/webrtc/channels.rs index c2631dd..60ad5b7 100644 --- a/src-tauri/src/audio/webrtc/channels.rs +++ b/src-tauri/src/audio/webrtc/channels.rs @@ -183,16 +183,22 @@ pub async fn wire_data_channel( })); } +/// Returns every state the connection goes through, for whoever waits on it +/// to connect: a connection takes one state handler, and this is it. pub fn wire_peer_events( pc: Arc, connection_id: String, node_id: String, session: Arc, display_id: Arc>, -) { +) -> tokio::sync::watch::Receiver< + webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState, +> { use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; + let (state_tx, state_rx) = tokio::sync::watch::channel(RTCPeerConnectionState::New); let self_pc = pc.clone(); pc.on_peer_connection_state_change(Box::new(move |state: RTCPeerConnectionState| { + let _ = state_tx.send(state); let connection_id = connection_id.clone(); let node_id = node_id.clone(); let session = session.clone(); @@ -227,6 +233,7 @@ pub fn wire_peer_events( } }) })); + state_rx } #[cfg(test)] diff --git a/src-tauri/src/audio/webrtc/handshake.rs b/src-tauri/src/audio/webrtc/handshake.rs index ca80da1..5598226 100644 --- a/src-tauri/src/audio/webrtc/handshake.rs +++ b/src-tauri/src/audio/webrtc/handshake.rs @@ -30,8 +30,14 @@ pub struct TrickleAnswer { pub sdp: String, pub candidates: mpsc::UnboundedReceiver, pub pc: Arc, + /// Every state the connection goes through, to wait on it connecting. + pub state: PeerStates, } +type PeerStates = tokio::sync::watch::Receiver< + webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState, +>; + async fn new_host_peer( node_id: &str, session: &Arc, @@ -100,7 +106,7 @@ async fn new_host_peer( .lock() .await .insert(connection_id.clone(), peer); - wire_peer_events( + let _ = wire_peer_events( pc.clone(), connection_id.clone(), node_id.to_string(), @@ -116,7 +122,7 @@ async fn new_guest_peer( session: &Arc, connection_id: &str, remote_sdp: String, -) -> AppResult> { +) -> AppResult<(Arc, PeerStates)> { // Guest displays the host's connection_id as the remote peer label. let display_id = Arc::new(Mutex::new(connection_id.to_string())); @@ -189,7 +195,7 @@ async fn new_guest_peer( .lock() .await .insert(connection_id.to_string(), peer); - wire_peer_events( + let state = wire_peer_events( pc.clone(), connection_id.to_string(), node_id.to_string(), @@ -203,7 +209,7 @@ async fn new_guest_peer( .await .map_err(|e| AppError::Stream(format!("set remote description: {e}")))?; - Ok(pc) + Ok((pc, state)) } // Manual copy-paste exchange: all candidates gathered into the SDP, nothing to trickle. @@ -286,7 +292,8 @@ pub async fn accept_offer( let guest_peer_id = cuid2::create_id(); let session = get_or_create(&node_id, opus_bitrate, opus_application); - let pc = new_guest_peer(&node_id, &session, &connection_id, remote_sdp.to_string()).await?; + let (pc, _) = + new_guest_peer(&node_id, &session, &connection_id, remote_sdp.to_string()).await?; let answer = pc .create_answer(None) @@ -325,7 +332,7 @@ pub async fn accept_offer_trickle( let guest_peer_id = cuid2::create_id(); let session = get_or_create(&node_id, opus_bitrate, opus_application); - let pc = new_guest_peer(&node_id, &session, &connection_id, remote_sdp).await?; + let (pc, state) = new_guest_peer(&node_id, &session, &connection_id, remote_sdp).await?; let answer = pc .create_answer(None) @@ -343,6 +350,7 @@ pub async fn accept_offer_trickle( sdp, candidates, pc, + state, }) } diff --git a/src-tauri/src/audio/webrtc/session.rs b/src-tauri/src/audio/webrtc/session.rs index d85172d..931c8c7 100644 --- a/src-tauri/src/audio/webrtc/session.rs +++ b/src-tauri/src/audio/webrtc/session.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use rtrb::Consumer; @@ -25,6 +25,8 @@ pub struct WebRtcSession { /// Bumped whenever the send rings are replaced, so the encode task rebuilds /// every channel's encode state together. pub send_gen: AtomicU64, + /// Rung by the DAG after each block it pushes into the send rings. + pub send_bell: Arc, // Each output subgraph builds its own bridge, so received audio fans out to // per-bridge rings (keyed "peer:ch") rather than being drained once. pub fanout: FanoutRegistry, @@ -39,6 +41,8 @@ pub struct WebRtcSession { // DSP graph rate the bridge feeds/reads at; the async paths resample it to // 48 kHz for Opus. Defaults to 48 kHz until the bridge is instantiated. pub output_sr: Arc, + // The DAG's block at `output_sr`; one packet carries one block. + pub block_frames: AtomicUsize, // Guard so only one encode loop runs regardless of how many peers connect. pub encoder_started: AtomicBool, // "idle" | "hosting" | "joining". @@ -88,12 +92,14 @@ impl WebRtcSession { opus_application, send_consumers: Mutex::new(Vec::new()), send_gen: AtomicU64::new(0), + send_bell: Arc::default(), fanout: FanoutRegistry::default(), peers: tokio::sync::Mutex::new(HashMap::new()), local_name: Arc::new(Mutex::new(String::new())), local_channels: Arc::new(AtomicU32::new(1)), codec: AtomicU8::new(Format::Opus.to_byte()), output_sr: Arc::new(AtomicU32::new(OPUS_SR)), + block_frames: AtomicUsize::new(crate::audio::graph::MAX_BUFFER_FRAMES), encoder_started: AtomicBool::new(false), phase: Mutex::new("idle"), room_code: Mutex::new(None), @@ -110,20 +116,35 @@ impl WebRtcSession { self.codec.store(f.to_byte(), Ordering::Relaxed); } - pub fn set_send_consumers(&self, consumers: Vec>, output_sr: u32) { + pub fn set_send_consumers( + &self, + consumers: Vec>, + output_sr: u32, + block_frames: usize, + ) { *self.send_consumers.lock().unwrap() = consumers; - self.send_gen.fetch_add(1, Ordering::SeqCst); self.output_sr.store(output_sr, Ordering::Relaxed); + self.block_frames.store(block_frames, Ordering::Relaxed); + self.send_gen.fetch_add(1, Ordering::SeqCst); } - 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. pub fn attach_channel(&self, peer: String, channel: u8, first_seq: u16) -> ChannelBroadcast { - self.fanout - .attach_channel(format!("{peer}:{channel}"), first_seq) + self.fanout.attach_channel( + format!("{peer}:{channel}"), + first_seq, + crate::audio::stream_recv::SR, + ) } /// Drops a disconnected peer's channels so new bridges don't wire to them. diff --git a/src-tauri/src/audio/webrtc/tasks.rs b/src-tauri/src/audio/webrtc/tasks.rs index 30ff14f..64ba950 100644 --- a/src-tauri/src/audio/webrtc/tasks.rs +++ b/src-tauri/src/audio/webrtc/tasks.rs @@ -7,10 +7,11 @@ use tracing::warn; use webrtc::data_channel::RTCDataChannel; -use crate::audio::graph::OpusApplication; -use crate::audio::netaudio::codec::{ChannelDecoder, ChannelEncoder}; +use crate::audio::netaudio::codec::{ + opus_application, opus_application_byte, ChannelDecoder, ChannelEncoder, +}; use crate::audio::netaudio::packet::{self, Format}; -use crate::audio::netaudio::timeline::SeqStep; +use crate::audio::netaudio::timeline::{max_gap_packets, SeqStep}; use crate::audio::resample::MultiResampler; use crate::audio::stream_recv::broadcast_push; @@ -29,26 +30,35 @@ struct ChannelEnc { format: Format, bitrate: u32, application: opus::Application, + /// One DAG block, in 48 kHz frames. + block_frames: usize, } impl ChannelEnc { - fn new(format: Format, bitrate: u32, application: opus::Application) -> Self { + fn new( + format: Format, + bitrate: u32, + application: opus::Application, + block_frames: usize, + ) -> Self { Self { resampler: None, resampler_sr: 0, in_acc: Vec::new(), out_acc: Vec::new(), - encoder: ChannelEncoder::new(format, bitrate, application), + encoder: ChannelEncoder::new(format, bitrate, application, block_frames), format, bitrate, application, + block_frames, } } fn ensure_format(&mut self, format: Format) { if format != self.format { self.format = format; - self.encoder = ChannelEncoder::new(format, self.bitrate, self.application); + self.encoder = + ChannelEncoder::new(format, self.bitrate, self.application, self.block_frames); } } @@ -94,88 +104,109 @@ impl ChannelEnc { } } +/// Longest the encode thread sleeps without being rung. +const IDLE_WAKE: Duration = Duration::from_millis(20); + +/// One encode thread per session. The DAG rings it after each block it pushes, +/// so audio goes out as soon as it exists; it ends with the session. pub fn spawn_encode_task(session: Arc) { let bitrate = session.opus_bitrate; - let application = match session.opus_application { - OpusApplication::Voip => opus::Application::Voip, - OpusApplication::Audio => opus::Application::Audio, - OpusApplication::LowDelay => opus::Application::LowDelay, - }; - - tauri::async_runtime::spawn(async move { - let mut encs: Vec = Vec::new(); - let mut seqs: Vec = Vec::new(); - let mut seen_gen = u64::MAX; - let mut interval = tokio::time::interval(Duration::from_millis(20)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - - loop { - interval.tick().await; - let sr = session.output_sr.load(Ordering::Relaxed); - let format = - Format::from_byte(session.codec.load(Ordering::Relaxed)).unwrap_or(Format::Opus); - - // Drain each channel's send ring under the lock, then release it - // before the async resample/encode/send work. - { - let mut cons = session.send_consumers.lock().unwrap(); - // Each encoder holds a partial Opus frame and a resampler tail; - // keeping them across a ring swap would leave a channel added - // now offset from its siblings by whatever they had buffered. - let gen = session.send_gen.load(Ordering::SeqCst); - if gen != seen_gen { - seen_gen = gen; - encs.clear(); - seqs.clear(); - } - while encs.len() < cons.len() { - encs.push(ChannelEnc::new(format, bitrate, application)); - seqs.push(0); - } - encs.truncate(cons.len()); - seqs.truncate(cons.len()); - for (i, c) in cons.iter_mut().enumerate() { - let take = c.slots(); - if take > 0 { - if let Ok(chunk) = c.read_chunk(take) { - let (a, b) = chunk.as_slices(); - encs[i].in_acc.extend_from_slice(a); - encs[i].in_acc.extend_from_slice(b); - chunk.commit_all(); + let application = opus_application(session.opus_application); + let application_byte = opus_application_byte(session.opus_application); + let bell = session.send_bell.clone(); + let session = Arc::downgrade(&session); + + let spawned = std::thread::Builder::new() + .name("webrtc-send".into()) + .spawn(move || { + bell.answer_here(); + // Each block the graph makes leaves from here: an encode that + // waits behind ordinary threads is a gap at every peer. + let _rt = session.upgrade().map(|s| { + let sr = s.output_sr.load(Ordering::Relaxed).max(1); + let block = s.block_frames.load(Ordering::Relaxed).max(1); + crate::audio::pipeline::RtThread::promote("webrtc-send", block as u32, sr) + }); + let mut encs: Vec = Vec::new(); + let mut seqs: Vec = Vec::new(); + let mut seen_gen = u64::MAX; + + loop { + bell.wait(IDLE_WAKE); + let Some(session) = session.upgrade() else { + return; + }; + let sr = session.output_sr.load(Ordering::Relaxed); + let block_frames = (session.block_frames.load(Ordering::Relaxed) as u64 + * OPUS_SR as u64) + .div_ceil(sr.max(1) as u64) as usize; + let format = Format::from_byte(session.codec.load(Ordering::Relaxed)) + .unwrap_or(Format::Opus); + + // Drain each channel's send ring under the lock, then release it + // before the async resample/encode/send work. + { + let mut cons = session.send_consumers.lock().unwrap(); + // Each encoder holds a partial Opus frame and a resampler tail; + // keeping them across a ring swap would leave a channel added + // now offset from its siblings by whatever they had buffered. + let gen = session.send_gen.load(Ordering::SeqCst); + if gen != seen_gen { + seen_gen = gen; + encs.clear(); + seqs.clear(); + } + while encs.len() < cons.len() { + encs.push(ChannelEnc::new(format, bitrate, application, block_frames)); + seqs.push(0); + } + encs.truncate(cons.len()); + seqs.truncate(cons.len()); + for (i, c) in cons.iter_mut().enumerate() { + let take = c.slots(); + if take > 0 { + if let Ok(chunk) = c.read_chunk(take) { + let (a, b) = chunk.as_slices(); + encs[i].in_acc.extend_from_slice(a); + encs[i].in_acc.extend_from_slice(b); + chunk.commit_all(); + } } } } - } - for (i, enc) in encs.iter_mut().enumerate() { - enc.ensure_format(format); - enc.ensure_resampler(sr); - enc.resample(); - let channel = i as u8; - let seq = &mut seqs[i]; - let mut frames: Vec = Vec::new(); - enc.encoder.push(&enc.out_acc, |payload| { - let mut d = Vec::with_capacity(packet::HEADER_LEN_V2_OPUS + payload.len()); - packet::write_header( - &mut d, - format, - channel, - *seq, - OPUS_SR, - (bitrate / 1000) as u16, - 1, - ); - *seq = seq.wrapping_add(1); - d.extend_from_slice(payload); - frames.push(Bytes::copy_from_slice(&d)); - }); - enc.out_acc.clear(); - for b in frames { - send_to_peers(&b, &session).await; + for (i, enc) in encs.iter_mut().enumerate() { + enc.ensure_format(format); + enc.ensure_resampler(sr); + enc.resample(); + let channel = i as u8; + let seq = &mut seqs[i]; + let mut frames: Vec = Vec::new(); + enc.encoder.push(&enc.out_acc, |payload| { + let mut d = Vec::with_capacity(packet::HEADER_LEN_V2_OPUS + payload.len()); + packet::write_header( + &mut d, + format, + channel, + *seq, + OPUS_SR, + (bitrate / 1000) as u16, + application_byte, + ); + *seq = seq.wrapping_add(1); + d.extend_from_slice(payload); + frames.push(Bytes::copy_from_slice(&d)); + }); + enc.out_acc.clear(); + for b in frames { + tauri::async_runtime::block_on(send_to_peers(&b, &session)); + } } } - } - }); + }); + if let Err(e) = spawned { + warn!(error = %e, "webrtc encode thread failed to start"); + } } async fn send_to_peers(data: &Bytes, session: &Arc) { @@ -209,6 +240,7 @@ pub async fn decode_and_write(data: Bytes, session: &Arc, peer_id let format = pkt.format; let channel = pkt.channel; let seq = pkt.seq; + let sample_rate = pkt.sample_rate; let header_len = data.len() - pkt.payload.len(); let payload = data.slice(header_len..); @@ -240,7 +272,8 @@ pub async fn decode_and_write(data: Bytes, session: &Arc, peer_id // The timeline advances even while muted, so unmuting resumes in step with // the peer's other channels instead of counting the mute as one huge loss. - let step = ch.timeline.lock().unwrap().step(seq); + let max_gap = max_gap_packets(sample_rate, ch.decoder.lock().unwrap().packet_samples()); + let step = ch.timeline.lock().unwrap().step(seq, max_gap); match step { SeqStep::Drop => return, // The break is longer than concealment covers, so this channel no @@ -302,8 +335,11 @@ mod tests { } async fn session_with_peer(node: &str) -> (Arc, String, Arc) { - let session = - crate::audio::webrtc::registry::get_or_create(node, 96_000, OpusApplication::Audio); + let session = crate::audio::webrtc::registry::get_or_create( + node, + 96_000, + crate::audio::graph::OpusApplication::Audio, + ); let peer_id = format!("peer-{}", cuid2::create_id()); let peer = Arc::new(PeerState { peer_id: peer_id.clone(), diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 476dc28..0ba51ff 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/error.rs b/src-tauri/src/error.rs index f8076c3..1951222 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), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e5e5ae..542e3d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ mod error; mod logs; mod native_crash; mod state; +#[doc(hidden)] +pub mod testkit; use std::path::PathBuf; use std::sync::OnceLock; @@ -331,7 +333,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-tauri/src/testkit.rs b/src-tauri/src/testkit.rs new file mode 100644 index 0000000..ef21e52 --- /dev/null +++ b/src-tauri/src/testkit.rs @@ -0,0 +1,138 @@ +//! Runs whole pipelines on virtual devices: the engine the app runs, end to +//! end, on machines with no sound hardware (CI). For the tests in `tests/`; +//! nothing in the app calls it. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use crate::audio::graph::{GraphSpec, RecordingFormat}; +use crate::audio::pipeline::{ActivePipeline, Host}; + +pub use crate::audio::pipeline::{ + take_host_lateness, LatencyReport, OutputHealth, SourceHealth, VirtualDevices, VirtualInput, + VirtualSpeaker, +}; + +type Events = Arc>>; + +/// A pipeline running on virtual devices, and what it told the UI. +pub struct Rig { + pipeline: Option, + devices: Arc, + host: Host, + events: Events, +} + +impl Rig { + pub fn new(devices: VirtualDevices) -> Self { + let devices = Arc::new(devices); + let events: Events = Arc::default(); + let sink = events.clone(); + let host = Host::with_virtual_devices(devices.clone(), move |event, payload| { + sink.lock().unwrap().push((event.to_string(), payload)); + }); + Self { + pipeline: None, + devices, + host, + events, + } + } + + /// Starts `graph`, or moves the running pipeline to it, as the editor + /// does. `graph` is the payload the frontend sends. + pub fn apply(&mut self, graph: serde_json::Value) -> Result<(), String> { + let spec: GraphSpec = serde_json::from_value(graph).map_err(|e| e.to_string())?; + let valid = spec.validate().map_err(|e| e.to_string())?; + self.pipeline + .get_or_insert_with(ActivePipeline::new) + .reconcile(&valid, self.host.clone()) + .map_err(|e| e.to_string()) + } + + /// Stops everything, as the stop button does. + pub fn stop(&mut self) { + self.pipeline = None; + } + + /// What speaker `id` played, interleaved. + pub fn played(&self, id: &str) -> Vec { + self.devices.played(id) + } + + pub fn clear_played(&self) { + self.devices.clear_played(); + } + + pub fn health(&self) -> (Vec, Vec) { + self.pipeline + .as_ref() + .map(ActivePipeline::health) + .unwrap_or_default() + } + + pub fn latency(&mut self) -> Option { + self.pipeline.as_mut().map(ActivePipeline::latency_report) + } + + /// Payloads of every `event` sent so far. + pub fn events(&self, event: &str) -> Vec { + self.events + .lock() + .unwrap() + .iter() + .filter(|(e, _)| e == event) + .map(|(_, p)| p.clone()) + .collect() + } + + pub fn update_effect(&self, node_id: &str, data: serde_json::Value) { + if let Some(p) = &self.pipeline { + p.update_effect(node_id, &data); + } + } + + pub fn seek_file(&self, node_id: &str, frame: i64) { + if let Some(p) = &self.pipeline { + p.seek_audio_file(node_id, frame); + } + } + + pub fn set_file_paused(&self, node_id: &str, paused: bool) { + if let Some(p) = &self.pipeline { + p.set_audio_file_paused(node_id, paused); + } + } +} + +impl Drop for Rig { + fn drop(&mut self) { + self.stop(); + } +} + +/// Writes `seconds` of a sine at `hz` to `path` in `format` (the JSON the +/// frontend stores for a recording format), with the app's own encoders. +pub fn write_tone_file( + path: &Path, + format: serde_json::Value, + sample_rate: u32, + channels: u16, + seconds: f32, + hz: f32, + amplitude: f32, +) -> Result<(), String> { + let format: RecordingFormat = serde_json::from_value(format).map_err(|e| e.to_string())?; + let mut encoder = + crate::audio::encoders::build_encoder(path, sample_rate, channels, format, false) + .map_err(|e| e.to_string())?; + let frames = (seconds * sample_rate as f32) as usize; + let step = std::f32::consts::TAU * hz / sample_rate as f32; + let samples: Vec = (0..frames) + .flat_map(|f| std::iter::repeat_n(amplitude * (f as f32 * step).sin(), channels as usize)) + .collect(); + encoder + .write_interleaved(&samples) + .map_err(|e| e.to_string())?; + encoder.finalize().map_err(|e| e.to_string()) +} diff --git a/src-tauri/tests/common/mod.rs b/src-tauri/tests/common/mod.rs new file mode 100644 index 0000000..d01a419 --- /dev/null +++ b/src-tauri/tests/common/mod.rs @@ -0,0 +1,370 @@ +//! Builds graphs the way the editor sends them, runs them on virtual devices +//! through the app's own engine, and checks what came out. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use splitwave_lib::testkit::{ + take_host_lateness, OutputHealth, Rig, SourceHealth, VirtualDevices, VirtualInput, + VirtualSpeaker, +}; + +pub const RATE: u32 = 48_000; +pub const TONE_HZ: f32 = 440.0; +pub const TONE_AMP: f32 = 0.25; + +/// Scenarios run in real time; one at a time, so they never compete for the +/// CPU with each other on a small CI runner. +static SERIAL: Mutex<()> = Mutex::new(()); + +pub fn serial() -> MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Every device a scenario may name. Inputs play a 440 Hz tone. +pub fn devices() -> VirtualDevices { + let d = VirtualDevices::default(); + let tone = |sample_rate, channels| VirtualInput { + sample_rate, + channels, + tone_hz: TONE_HZ, + amplitude: TONE_AMP, + ..Default::default() + }; + d.add_input("mic", tone(RATE, 1)); + d.add_input("mic-44k", tone(44_100, 1)); + d.add_input("mic-96k", tone(96_000, 2)); + d.add_input("interface", tone(RATE, 4)); + d.add_input("system", tone(RATE, 2)); + d.add_input("app:com.example.player", tone(RATE, 2)); + // A built-in microphone as CoreAudio delivers it: its own clock, and + // whole IO buffers of its own size. + d.add_input( + "real-mic", + VirtualInput { + burst_frames: Some(512), + clock_ppm: 80.0, + ..tone(RATE, 1) + }, + ); + let speaker = |sample_rate, channels| VirtualSpeaker { + sample_rate, + channels, + }; + d.add_speaker("out", speaker(RATE, 2)); + d.add_speaker("out-2", speaker(RATE, 2)); + d.add_speaker("out-44k", speaker(44_100, 2)); + d.add_speaker("out-6ch", speaker(RATE, 6)); + d +} + +/// A graph as the editor sends it. +pub struct Graph { + nodes: Vec, + edges: Vec, + buffer: u32, +} + +impl Graph { + pub fn new(buffer: u32) -> Self { + Self { + nodes: Vec::new(), + edges: Vec::new(), + buffer, + } + } + + pub fn node(mut self, id: &str, kind: &str, data: Value) -> Self { + self.nodes + .push(json!({ "id": id, "kind": kind, "data": data })); + self + } + + pub fn edge(self, from: &str, to: &str) -> Self { + self.edge_handles(from, None, to, None) + } + + pub fn edge_handles( + mut self, + from: &str, + source_handle: Option<&str>, + to: &str, + target_handle: Option<&str>, + ) -> Self { + let id = format!("e{}", self.edges.len()); + self.edges.push(json!({ + "id": id, + "source": from, + "sourceHandle": source_handle, + "target": to, + "targetHandle": target_handle, + })); + self + } + + pub fn chain(mut self, ids: &[&str]) -> Self { + for pair in ids.windows(2) { + self = self.edge(pair[0], pair[1]); + } + self + } + + pub fn json(&self) -> Value { + json!({ + "nodes": self.nodes, + "edges": self.edges, + "sampleRate": RATE, + "bufferFrames": self.buffer, + }) + } +} + +pub fn mic(device: &str) -> Value { + json!({ "deviceId": device }) +} + +pub fn speaker(device: &str) -> Value { + json!({ "deviceId": device }) +} + +pub fn audio_file(path: &std::path::Path) -> Value { + json!({ "filePath": path.to_string_lossy(), "loopEnabled": true, "volume": 1.0, "autoStart": true }) +} + +/// Default data for every effect kind, as the editor creates them. +pub fn effect(kind: &str) -> Value { + match kind { + "gain" => json!({ "gainDb": 0 }), + "mute" => json!({ "muted": false }), + "channelBalance" => json!({ "leftGainDb": 0, "rightGainDb": 0 }), + "saturator" => json!({ "thresholdDb": -0.3, "driveDb": 0 }), + "eq" => json!({ "gainsDb": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }), + "levelMeter" => json!({}), + "lufsMeter" => json!({ "target": -14 }), + "waveform" => json!({ "segs": 4 }), + "spectrum" => json!({ "smoothing": 0.5 }), + "limiter" => json!({ "ceilingDb": -0.3, "lookaheadMs": 5, "releaseMs": 50 }), + "compressor" => json!({ "thresholdDb": -18, "ratio": 3, "attackMs": 10, + "releaseMs": 100, "kneeDb": 6, "makeupDb": 0 }), + "noiseGate" => json!({ "thresholdDb": -40, "rangeDb": -40, "attackMs": 1, + "holdMs": 50, "releaseMs": 200 }), + "delay" => json!({ "timeMs": 250, "feedback": 0.4, "mix": 0.35 }), + "reverb" => json!({ "roomSize": 0.5, "damping": 0.5, "width": 1, "mix": 0.33 }), + "noiseSuppressor" => json!({ "attenuationLimitDb": 100, "postFilterBeta": 0, + "minThreshDb": -10, "maxErbThreshDb": 30, + "maxDfThreshDb": 20 }), + "declick" => json!({ "sensitivity": 0.5, "maxWidthMs": 2 }), + "deEsser" => json!({ "frequency": 6500, "thresholdDb": -30, "ratio": 4 }), + other => panic!("no default data for {other}"), + } +} + +/// A fresh scratch path for this process. +pub fn temp_path(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("splitwave-scenarios-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir.join(name) +} + +/// A looping test file: 2 s of the tone, stereo, in `format`. +pub fn tone_file(name: &str, format: Value, sample_rate: u32) -> PathBuf { + let path = temp_path(name); + if !path.exists() { + splitwave_lib::testkit::write_tone_file( + &path, + format, + sample_rate, + 2, + 2.0, + TONE_HZ, + TONE_AMP, + ) + .expect("write tone file"); + } + path +} + +pub fn wav() -> Value { + json!({ "kind": "wav", "bitDepth": "f32" }) +} + +pub fn flac() -> Value { + json!({ "kind": "flac", "bitDepth": "i24", "compression": "default" }) +} + +pub fn mp3() -> Value { + json!({ "kind": "mp3", "bitrateKbps": 192 }) +} + +/// What a scenario produced over its measured window. +pub struct Measured { + pub played: HashMap, usize)>, + pub sources: Vec<(SourceHealth, SourceHealth)>, + pub outputs: Vec<(OutputHealth, OutputHealth)>, + pub window: Duration, + /// Since measuring began, the warm-up and any window taken again. + pub ran: Duration, + /// Latest the host let a virtual device tick during the window. + pub host_late: Duration, + pub rig: Rig, +} + +pub const WARMUP: Duration = Duration::from_millis(500); +pub const WINDOW: Duration = Duration::from_millis(700); + +/// A device the host holds up past this drops out at the smallest buffers +/// (32 frames is 0.7 ms) on any engine: a window +/// in which one was is taken again rather than judged. Shared CI machines +/// (macOS runners above all) pause threads for milliseconds at a time. +const HOST_LATE_LIMIT: Duration = Duration::from_millis(1); +const MAX_WINDOWS: usize = 6; +/// What a hold-up leaves behind settles before the next window. +const RESETTLE: Duration = Duration::from_millis(300); + +/// Starts `graph`, lets it settle, then measures it for `WINDOW`. +pub fn run(graph: &Graph, speakers: &[(&str, usize)]) -> Measured { + let mut rig = Rig::new(devices()); + rig.apply(graph.json()).expect("graph starts"); + measure(rig, speakers) +} + +/// Measures an already running rig over a window in which the host kept +/// every virtual device on time; the last of `MAX_WINDOWS` if none did. +pub fn measure(rig: Rig, speakers: &[(&str, usize)]) -> Measured { + let began = Instant::now(); + std::thread::sleep(WARMUP); + let mut attempt = 0; + loop { + attempt += 1; + let (src_before, out_before) = rig.health(); + rig.clear_played(); + take_host_lateness(); + let started = Instant::now(); + std::thread::sleep(WINDOW); + let window = started.elapsed(); + let host_late = take_host_lateness(); + let (src_after, out_after) = rig.health(); + if host_late > HOST_LATE_LIMIT && attempt < MAX_WINDOWS { + std::thread::sleep(RESETTLE); + continue; + } + let played = speakers + .iter() + .map(|(id, ch)| (id.to_string(), (rig.played(id), *ch))) + .collect(); + return Measured { + played, + sources: pair(src_before, src_after, |s| { + format!("{}@{}", s.label, s.output_id) + }), + outputs: pair(out_before, out_after, |o| o.label.clone()), + window, + ran: began.elapsed(), + host_late, + rig, + }; + } +} + +fn pair(before: Vec, after: Vec, key: impl Fn(&T) -> String) -> Vec<(T, T)> { + after + .into_iter() + .filter_map(|a| { + before + .iter() + .find(|b| key(b) == key(&a)) + .map(|b| (b.clone(), a)) + }) + .collect() +} + +impl Measured { + /// Channel `ch` of speaker `id`. + pub fn channel(&self, id: &str, ch: usize) -> Vec { + let (samples, channels) = &self.played[id]; + samples + .iter() + .skip(ch) + .step_by(*channels) + .copied() + .collect() + } + + /// The speaker kept playing in real time, the tone never broke, and it + /// came out at about `level` RMS. + pub fn assert_tone(&self, id: &str, ch: usize, min_rms: f32) { + let x = self.channel(id, ch); + let expected = (self.window.as_secs_f64() * RATE as f64) as usize; + assert!( + x.len() as f64 > expected as f64 * 0.7, + "{id}: played {} frames in {:?}", + x.len(), + self.window + ); + assert!(x.iter().all(|s| s.is_finite()), "{id}: non-finite output"); + let mut run = 0; + let mut worst = 0; + for s in &x { + if s.abs() < 1e-4 { + run += 1; + worst = worst.max(run); + } else { + run = 0; + } + } + // A 440 Hz tone is never this quiet for a millisecond on end. + assert!( + worst < RATE as usize / 1000, + "{id} ch{ch}: {worst} frames of silence (host up to {:?} late)", + self.host_late + ); + let rms = (x.iter().map(|s| (*s as f64).powi(2)).sum::() / x.len() as f64).sqrt(); + assert!( + rms as f32 >= min_rms, + "{id} ch{ch}: RMS {rms:.4} below {min_rms}" + ); + } + + /// Every worker, speakers and monitor alike, kept up with real time. + pub fn assert_real_time(&self) { + for (before, after) in &self.outputs { + let frames = (after.blocks - before.blocks) as f64 * after.block_frames as f64; + let rate = frames / self.window.as_secs_f64(); + let ratio = rate / after.sample_rate as f64; + assert!( + (0.85..1.15).contains(&ratio), + "{} ran at {:.0}% of real time (host up to {:?} late)", + after.label, + ratio * 100.0, + self.host_late + ); + } + } + + /// No source ran dry or stalled while measured. + pub fn assert_no_dropouts(&self) { + for (before, after) in &self.sources { + let xrun = after.xrun - before.xrun; + let stalled = after.stalled - before.stalled; + assert!( + xrun == 0 && stalled == 0, + "{} in {}: {xrun} samples ran dry, {stalled} stalled (host up to {:?} late)", + after.label, + after.output_id, + self.host_late + ); + } + } + + /// The everyday expectations: real time, no dropouts, the tone intact. + pub fn assert_clean(&self, id: &str, min_rms: f32) { + self.assert_real_time(); + self.assert_no_dropouts(); + self.assert_tone(id, 0, min_rms); + } +} diff --git a/src-tauri/tests/pipelines.rs b/src-tauri/tests/pipelines.rs new file mode 100644 index 0000000..8e5d8f9 --- /dev/null +++ b/src-tauri/tests/pipelines.rs @@ -0,0 +1,578 @@ +//! Whole pipelines, run end to end on virtual devices: inputs, effects, +//! analyzers and outputs combined as people wire them in the editor. Each +//! scenario runs in real time through the app's own engine and checks that +//! every worker kept up, that no source ran dry and that the sound came out +//! unbroken. No sound hardware is needed. + +mod common; + +use common::*; +use serde_json::json; + +/// The kinds of input a graph can start from. +#[derive(Clone, Copy)] +enum Input { + WavFile, + Wav44File, + FlacFile, + Mp3File, + Mic, + Mic44, + Mic96, + Interface, + System, + App, +} + +/// Adds `input` to `graph` as node `id`. +fn with_input(graph: Graph, id: &str, input: Input) -> Graph { + match input { + Input::WavFile => graph.node( + id, + "audioFile", + audio_file(&tone_file("tone.wav", wav(), RATE)), + ), + Input::Wav44File => graph.node( + id, + "audioFile", + audio_file(&tone_file("tone-44k.wav", wav(), 44_100)), + ), + Input::FlacFile => graph.node( + id, + "audioFile", + audio_file(&tone_file("tone.flac", flac(), RATE)), + ), + Input::Mp3File => graph.node( + id, + "audioFile", + audio_file(&tone_file("tone.mp3", mp3(), RATE)), + ), + Input::Mic => graph.node(id, "microphone", mic("mic")), + Input::Mic44 => graph.node(id, "microphone", mic("mic-44k")), + Input::Mic96 => graph.node(id, "microphone", mic("mic-96k")), + Input::Interface => graph.node(id, "microphone", mic("interface")), + Input::System => graph.node( + id, + "systemAudio", + json!({ "excludeCurrentApp": true, "volume": 1.0 }), + ), + Input::App => graph.node( + id, + "appAudio", + json!({ "bundleId": "com.example.player", "volume": 1.0 }), + ), + } +} + +/// Input straight into a speaker. +fn direct(input: Input, buffer: u32) { + let graph = with_input(Graph::new(buffer), "in", input) + .node("s", "speaker", speaker("out")) + .edge("in", "s"); + run(&graph, &[("out", 2)]).assert_clean("out", 0.05); +} + +/// Input through a short chain, with a meter reading the input on the side. +fn chain_with_meter(input: Input) { + let graph = with_input(Graph::new(64), "in", input) + .node("g", "gain", effect("gain")) + .node("eq", "eq", effect("eq")) + .node("l", "limiter", effect("limiter")) + .node("m", "levelMeter", effect("levelMeter")) + .node("s", "speaker", speaker("out")) + .chain(&["in", "g", "eq", "l", "s"]) + .edge("in", "m"); + run(&graph, &[("out", 2)]).assert_clean("out", 0.05); +} + +/// Input played on two speakers at once. +fn two_speakers(input: Input) { + let graph = with_input(Graph::new(128), "in", input) + .node("g", "gain", effect("gain")) + .node("s1", "speaker", speaker("out")) + .node("s2", "speaker", speaker("out-2")) + .chain(&["in", "g", "s1"]) + .edge("g", "s2"); + let m = run(&graph, &[("out", 2), ("out-2", 2)]); + m.assert_clean("out", 0.05); + m.assert_tone("out-2", 0, 0.05); +} + +/// Input on a speaker running at another rate than the pipeline. +fn other_rate_speaker(input: Input) { + let graph = with_input(Graph::new(256), "in", input) + .node("s", "speaker", speaker("out-44k")) + .edge("in", "s"); + run(&graph, &[("out-44k", 2)]).assert_clean("out-44k", 0.05); +} + +macro_rules! per_input { + ($($input:ident => $direct256:ident, $direct32:ident, $chain:ident, $two:ident, $rate:ident;)*) => { + $( + #[test] + fn $direct256() { + let _g = serial(); + direct(Input::$input, 256); + } + + #[test] + fn $direct32() { + let _g = serial(); + direct(Input::$input, 32); + } + + #[test] + fn $chain() { + let _g = serial(); + chain_with_meter(Input::$input); + } + + #[test] + fn $two() { + let _g = serial(); + two_speakers(Input::$input); + } + + #[test] + fn $rate() { + let _g = serial(); + other_rate_speaker(Input::$input); + } + )* + }; +} + +per_input! { + WavFile => wav_file_to_speaker, wav_file_to_speaker_at_32, wav_file_through_chain_with_meter, + wav_file_on_two_speakers, wav_file_on_44k_speaker; + Wav44File => wav_44k_file_to_speaker, wav_44k_file_to_speaker_at_32, + wav_44k_file_through_chain_with_meter, wav_44k_file_on_two_speakers, + wav_44k_file_on_44k_speaker; + FlacFile => flac_file_to_speaker, flac_file_to_speaker_at_32, flac_file_through_chain_with_meter, + flac_file_on_two_speakers, flac_file_on_44k_speaker; + Mp3File => mp3_file_to_speaker, mp3_file_to_speaker_at_32, mp3_file_through_chain_with_meter, + mp3_file_on_two_speakers, mp3_file_on_44k_speaker; + Mic => mic_to_speaker, mic_to_speaker_at_32, mic_through_chain_with_meter, + mic_on_two_speakers, mic_on_44k_speaker; + Mic44 => mic_44k_to_speaker, mic_44k_to_speaker_at_32, mic_44k_through_chain_with_meter, + mic_44k_on_two_speakers, mic_44k_on_44k_speaker; + Mic96 => mic_96k_to_speaker, mic_96k_to_speaker_at_32, mic_96k_through_chain_with_meter, + mic_96k_on_two_speakers, mic_96k_on_44k_speaker; + Interface => interface_to_speaker, interface_to_speaker_at_32, + interface_through_chain_with_meter, interface_on_two_speakers, interface_on_44k_speaker; + System => system_audio_to_speaker, system_audio_to_speaker_at_32, + system_audio_through_chain_with_meter, system_audio_on_two_speakers, + system_audio_on_44k_speaker; + App => app_audio_to_speaker, app_audio_to_speaker_at_32, app_audio_through_chain_with_meter, + app_audio_on_two_speakers, app_audio_on_44k_speaker; +} + +/// One effect between a microphone and a speaker, at its default settings. +fn single_effect(kind: &str) { + let graph = Graph::new(64) + .node("m", "microphone", mic("mic")) + .node("fx", kind, effect(kind)) + .node("s", "speaker", speaker("out")) + .chain(&["m", "fx", "s"]); + let m = run(&graph, &[("out", 2)]); + m.assert_real_time(); + m.assert_no_dropouts(); + // A steady tone is exactly what a noise suppressor removes. + if kind != "noiseSuppressor" { + m.assert_tone("out", 0, 0.03); + } +} + +macro_rules! per_effect { + ($($name:ident => $kind:literal;)*) => { + $( + #[test] + fn $name() { + let _g = serial(); + single_effect($kind); + } + )* + }; +} + +per_effect! { + effect_gain => "gain"; + effect_mute => "mute"; + effect_channel_balance => "channelBalance"; + effect_saturator => "saturator"; + effect_eq => "eq"; + effect_level_meter => "levelMeter"; + effect_lufs_meter => "lufsMeter"; + effect_waveform => "waveform"; + effect_spectrum => "spectrum"; + effect_limiter => "limiter"; + effect_compressor => "compressor"; + effect_noise_gate => "noiseGate"; + effect_delay => "delay"; + effect_reverb => "reverb"; + effect_noise_suppressor => "noiseSuppressor"; + effect_declick => "declick"; + effect_de_esser => "deEsser"; +} + +/// A file through an effect to a speaker, with an analyzer reading the +/// effect's output: the analyzer runs on the monitor, the speaker on its +/// device, both from one file. +fn file_effect_analyzer(effect_kind: &str, analyzer: &str) { + let graph = with_input(Graph::new(64), "f", Input::WavFile) + .node("fx", effect_kind, effect(effect_kind)) + .node("an", analyzer, effect(analyzer)) + .node("s", "speaker", speaker("out")) + .chain(&["f", "fx", "s"]) + .edge("fx", "an"); + run(&graph, &[("out", 2)]).assert_clean("out", 0.03); +} + +macro_rules! per_effect_analyzer { + ($($name:ident => $fx:literal, $an:literal;)*) => { + $( + #[test] + fn $name() { + let _g = serial(); + file_effect_analyzer($fx, $an); + } + )* + }; +} + +per_effect_analyzer! { + file_gain_waveform => "gain", "waveform"; + file_gain_spectrum => "gain", "spectrum"; + file_gain_lufs => "gain", "lufsMeter"; + file_eq_waveform => "eq", "waveform"; + file_eq_spectrum => "eq", "spectrum"; + file_eq_lufs => "eq", "lufsMeter"; + file_compressor_waveform => "compressor", "waveform"; + file_compressor_spectrum => "compressor", "spectrum"; + file_compressor_lufs => "compressor", "lufsMeter"; + file_reverb_waveform => "reverb", "waveform"; + file_reverb_spectrum => "reverb", "spectrum"; + file_reverb_lufs => "reverb", "lufsMeter"; + file_delay_waveform => "delay", "waveform"; + file_delay_spectrum => "delay", "spectrum"; + file_delay_lufs => "delay", "lufsMeter"; +} + +/// What a recording wrote, as frames of its first channel. +fn read_wav(path: &std::path::Path) -> (Vec, u32) { + let mut reader = hound::WavReader::open(path).expect("recording opens"); + let spec = reader.spec(); + let channels = spec.channels as usize; + let samples: Vec = reader + .samples::() + .map(|s| s.expect("sample")) + .collect(); + ( + samples.into_iter().step_by(channels).collect(), + spec.sample_rate, + ) +} + +fn recording(name: &str, input: Input, also_speaker: bool) { + let path = temp_path(name); + let _ = std::fs::remove_file(&path); + let mut graph = with_input(Graph::new(256), "in", input) + .node( + "rec", + "fileRecording", + json!({ + "filePath": path.to_string_lossy(), + "format": wav(), + "mode": "overwrite", + "channels": 2, + "sampleRate": RATE, + }), + ) + .edge("in", "rec"); + if also_speaker { + graph = graph.node("s", "speaker", speaker("out")).edge("in", "s"); + } + let speakers: &[(&str, usize)] = if also_speaker { &[("out", 2)] } else { &[] }; + let mut m = run(&graph, speakers); + m.assert_real_time(); + m.assert_no_dropouts(); + if also_speaker { + m.assert_tone("out", 0, 0.05); + } + m.rig.stop(); + let (frames, rate) = read_wav(&path); + let seconds = frames.len() as f64 / rate as f64; + let ran = m.ran.as_secs_f64(); + assert!( + seconds > ran * 0.7 && seconds < ran * 1.3, + "recorded {seconds:.2} s of {ran:.2} s" + ); + // Past the opening, the tone is whole: no run of silence. + let body = &frames[frames.len() / 4..frames.len() * 3 / 4]; + let mut run = 0; + let mut worst = 0; + for s in body { + run = if s.abs() < 1e-4 { run + 1 } else { 0 }; + worst = worst.max(run); + } + assert!( + worst < rate as usize / 1000, + "recording holds {worst} frames of silence" + ); +} + +#[test] +fn record_a_denoised_mic_and_an_app_without_losing_any() { + // A recorder runs heavy DSP and file IO; neither may cost it time. + let _g = serial(); + let path = temp_path("denoised-and-app.wav"); + let _ = std::fs::remove_file(&path); + let graph = Graph::new(32) + .node("m", "microphone", mic("real-mic")) + .node("ns", "noiseSuppressor", effect("noiseSuppressor")) + .node("lm", "levelMeter", effect("levelMeter")) + .node( + "a", + "appAudio", + json!({ "bundleId": "com.example.player", "volume": 1.0 }), + ) + .node( + "rec", + "fileRecording", + json!({ + "filePath": path.to_string_lossy(), + "format": wav(), + "mode": "overwrite", + "channels": 2, + "sampleRate": RATE, + }), + ) + .chain(&["m", "ns", "lm", "rec"]) + .edge("a", "rec"); + let mut m = run(&graph, &[]); + m.assert_real_time(); + m.assert_no_dropouts(); + m.rig.stop(); + let (frames, rate) = read_wav(&path); + let seconds = frames.len() as f64 / rate as f64; + let ran = m.ran.as_secs_f64(); + assert!( + seconds > ran * 0.85 && seconds < ran * 1.15, + "recorded {seconds:.2} s of {ran:.2} s" + ); +} + +#[test] +fn record_mic() { + let _g = serial(); + recording("mic.wav", Input::Mic, false); +} + +#[test] +fn record_file() { + let _g = serial(); + recording("file.wav", Input::FlacFile, false); +} + +#[test] +fn record_mic_while_playing_it() { + let _g = serial(); + recording("mic-and-speaker.wav", Input::Mic, true); +} + +#[test] +fn record_system_audio_while_playing_it() { + let _g = serial(); + recording("system-and-speaker.wav", Input::System, true); +} + +/// A microphone sent over the network to this machine and played back. +fn network_loopback(port: u16, codec: &str) { + let graph = Graph::new(128) + .node("m", "microphone", mic("mic")) + .node( + "tx", + "netSender", + json!({ + "targetIp": "127.0.0.1", + "port": port, + "channels": 1, + "codec": codec, + "opusBitrate": 96000, + "opusApplication": "audio", + "sampleRate": null, + }), + ) + .node("rx", "netReceiver", json!({ "port": port, "channels": 1 })) + .node("s", "speaker", speaker("out")) + .edge_handles("m", None, "tx", Some("ch1")) + .edge_handles("rx", Some("ch1"), "s", None); + let mut rig = splitwave_lib::testkit::Rig::new(devices()); + rig.apply(graph.json()).expect("graph starts"); + // The receiver primes from the first packets before playing. + std::thread::sleep(std::time::Duration::from_millis(700)); + let m = measure(rig, &[("out", 2)]); + m.assert_real_time(); + // Opus is lossy; the tone still arrives whole and near its level. + m.assert_tone("out", 0, 0.03); +} + +#[test] +fn network_pcm_loopback() { + let _g = serial(); + network_loopback(47_311, "pcm-f32"); +} + +#[test] +fn network_opus_loopback() { + let _g = serial(); + network_loopback(47_312, "opus"); +} + +/// Starts on `from`, moves to `to` while playing, then measures `to`. +fn edit_while_playing(from: Graph, to: Graph) { + let mut rig = splitwave_lib::testkit::Rig::new(devices()); + rig.apply(from.json()).expect("first graph starts"); + std::thread::sleep(WARMUP); + rig.apply(to.json()).expect("edit applies"); + measure(rig, &[("out", 2)]).assert_clean("out", 0.03); +} + +fn mic_to_speaker_graph() -> Graph { + Graph::new(64) + .node("m", "microphone", mic("mic")) + .node("s", "speaker", speaker("out")) + .edge("m", "s") +} + +#[test] +fn edit_adds_an_effect() { + let _g = serial(); + let to = Graph::new(64) + .node("m", "microphone", mic("mic")) + .node("g", "gain", effect("gain")) + .node("s", "speaker", speaker("out")) + .chain(&["m", "g", "s"]); + edit_while_playing(mic_to_speaker_graph(), to); +} + +#[test] +fn edit_removes_an_effect() { + let _g = serial(); + let from = Graph::new(64) + .node("m", "microphone", mic("mic")) + .node("c", "compressor", effect("compressor")) + .node("s", "speaker", speaker("out")) + .chain(&["m", "c", "s"]); + edit_while_playing(from, mic_to_speaker_graph()); +} + +#[test] +fn edit_adds_an_analyzer() { + let _g = serial(); + let to = mic_to_speaker_graph() + .node("w", "waveform", effect("waveform")) + .edge("m", "w"); + edit_while_playing(mic_to_speaker_graph(), to); +} + +#[test] +fn edit_adds_an_analyzer_to_a_playing_file() { + let _g = serial(); + let from = with_input(Graph::new(32), "f", Input::WavFile) + .node("s", "speaker", speaker("out")) + .edge("f", "s"); + let to = with_input(Graph::new(32), "f", Input::WavFile) + .node("s", "speaker", speaker("out")) + .node("sp", "spectrum", effect("spectrum")) + .edge("f", "s") + .edge("f", "sp"); + edit_while_playing(from, to); +} + +#[test] +fn edit_adds_a_second_speaker() { + let _g = serial(); + let to = mic_to_speaker_graph() + .node("s2", "speaker", speaker("out-2")) + .edge("m", "s2"); + edit_while_playing(mic_to_speaker_graph(), to); +} + +#[test] +fn edit_changes_the_buffer() { + let _g = serial(); + let to = Graph::new(256) + .node("m", "microphone", mic("mic")) + .node("s", "speaker", speaker("out")) + .edge("m", "s"); + edit_while_playing(mic_to_speaker_graph(), to); +} + +#[test] +fn mic_and_file_mixed() { + let _g = serial(); + let graph = with_input(Graph::new(64), "f", Input::WavFile) + .node("m", "microphone", mic("mic")) + .node("s", "speaker", speaker("out")) + .edge("f", "s") + .edge("m", "s"); + run(&graph, &[("out", 2)]).assert_clean("out", 0.05); +} + +#[test] +fn system_and_app_mixed_through_a_compressor() { + let _g = serial(); + let graph = Graph::new(128) + .node( + "sys", + "systemAudio", + json!({ "excludeCurrentApp": true, "volume": 1.0 }), + ) + .node( + "app", + "appAudio", + json!({ "bundleId": "com.example.player", "volume": 1.0 }), + ) + .node("c", "compressor", effect("compressor")) + .node("s", "speaker", speaker("out")) + .edge("sys", "c") + .edge("app", "c") + .edge("c", "s"); + run(&graph, &[("out", 2)]).assert_clean("out", 0.05); +} + +#[test] +fn stereo_file_on_a_six_channel_speaker() { + let _g = serial(); + let graph = with_input(Graph::new(128), "f", Input::WavFile) + .node("s", "speaker", speaker("out-6ch")) + .edge("f", "s"); + run(&graph, &[("out-6ch", 6)]).assert_clean("out-6ch", 0.05); +} + +#[test] +fn four_channel_interface_through_reverb() { + let _g = serial(); + let graph = Graph::new(128) + .node("i", "microphone", mic("interface")) + .node("r", "reverb", effect("reverb")) + .node("s", "speaker", speaker("out")) + .chain(&["i", "r", "s"]); + run(&graph, &[("out", 2)]).assert_clean("out", 0.05); +} + +#[test] +fn analyzers_only_run_in_real_time() { + let _g = serial(); + // No speaker at all: the monitor alone drives the graph. + let graph = Graph::new(32) + .node("m", "microphone", mic("mic")) + .node("lm", "levelMeter", effect("levelMeter")) + .node("w", "waveform", effect("waveform")) + .edge("m", "lm") + .edge("m", "w"); + let m = run(&graph, &[]); + m.assert_real_time(); + m.assert_no_dropouts(); +} diff --git a/src-tauri/tests/transitions.rs b/src-tauri/tests/transitions.rs new file mode 100644 index 0000000..748f805 --- /dev/null +++ b/src-tauri/tests/transitions.rs @@ -0,0 +1,347 @@ +//! Edits made while audio plays, heard through a speaker. An edit that does +//! not touch what the speaker plays is inaudible there. One that does may dip +//! under a fade, but never steps the waveform, and leaves nothing to correct +//! afterwards. The microphone is `real-mic`: its own clock and whole IO +//! buffers, as CoreAudio delivers one. + +mod common; + +use std::time::Duration; + +use common::*; +use serde_json::{json, Value}; + +const MIC: &str = "real-mic"; + +/// What the speaker's first channel did around an edit, times in ms after it. +struct Heard { + /// Largest departure from the tone's own continuation, sample to sample. + worst_step: f32, + /// Where it departed by more than a fade's bend. + breaks: Vec, + /// 5 ms windows whose level is off the tone's by more than 10%. + off: Vec, +} + +impl Heard { + /// Nothing at all changed in what the speaker played. + fn assert_unheard(&self, what: &str) { + assert!( + self.breaks.is_empty() && self.off.is_empty(), + "{what}: heard at {:?} ms (breaks) / {:?} ms (level)", + self.breaks, + self.off + ); + } + + /// At most a dip under a fade, right after the edit. + fn assert_smooth(&self, what: &str) { + assert!( + self.worst_step < 0.03, + "{what}: the waveform stepped by {}", + self.worst_step + ); + let late: Vec = self + .breaks + .iter() + .chain(&self.off) + .copied() + .filter(|t| *t > 400.0) + .collect(); + assert!(late.is_empty(), "{what}: still moving at {late:?} ms"); + } +} + +fn listen(x: &[f32], edit_at: usize) -> Heard { + let ms = |i: usize| (i as f64 - edit_at as f64) * 1000.0 / RATE as f64; + // A pure tone continues as x[n+1] = 2 cos(w) x[n] - x[n-1]. + let k = 2.0 * (std::f32::consts::TAU * TONE_HZ / RATE as f32).cos(); + let mut heard = Heard { + worst_step: 0.0, + breaks: Vec::new(), + off: Vec::new(), + }; + for i in 1..x.len() - 1 { + let r = (x[i + 1] - k * x[i] + x[i - 1]).abs(); + heard.worst_step = heard.worst_step.max(r); + if r > 0.01 { + heard.breaks.push(ms(i)); + } + } + let nominal = TONE_AMP / std::f32::consts::SQRT_2; + let win = RATE as usize / 200; + for (n, c) in x.chunks_exact(win).enumerate() { + let rms = (c.iter().map(|s| s * s).sum::() / win as f32).sqrt(); + if (rms / nominal - 1.0).abs() > 0.1 { + heard.off.push(ms(n * win)); + } + } + heard +} + +/// A device the host holds up past this drops out at the smallest buffers +/// (32 frames is 0.7 ms) on any engine: a run in +/// which one was is taken again rather than judged. Shared CI machines +/// (macOS runners above all) pause threads for milliseconds at a time. +const HOST_LATE_LIMIT: Duration = Duration::from_millis(1); +const MAX_RUNS: usize = 4; + +/// `listen` over what `play` returns, from a run in which the host kept every +/// virtual device on time; the last of `MAX_RUNS` if none did. +fn on_time(mut play: impl FnMut() -> Heard) -> Heard { + for run in 1.. { + splitwave_lib::testkit::take_host_lateness(); + let heard = play(); + if splitwave_lib::testkit::take_host_lateness() <= HOST_LATE_LIMIT || run == MAX_RUNS { + return heard; + } + } + unreachable!() +} + +/// Plays `from` until it has settled, edits it into `to`, and listens to +/// speaker `out` from just before the edit. +fn edit(from: Graph, to: Graph, settle: Duration) -> Heard { + on_time(|| { + let mut rig = splitwave_lib::testkit::Rig::new(devices()); + rig.apply(from.json()).expect("first graph starts"); + std::thread::sleep(settle); + rig.clear_played(); + std::thread::sleep(Duration::from_millis(200)); + let edit_at = rig.played("out").len() / 2; + rig.apply(to.json()).expect("edit applies"); + std::thread::sleep(Duration::from_millis(1200)); + let played = rig.played("out"); + let ch0: Vec = played.iter().step_by(2).copied().collect(); + listen(&ch0, edit_at) + }) +} + +/// Past a captured source's one startup correction. +const SETTLED: Duration = Duration::from_millis(1600); +/// Past a network receiver's, which measures over longer windows. +const SETTLED_NET: Duration = Duration::from_millis(2600); + +fn mic_speaker() -> Graph { + Graph::new(64) + .node("m", "microphone", mic(MIC)) + .node("s", "speaker", speaker("out")) + .edge("m", "s") +} + +fn sender(port: u16, channels: u32, sample_rate: Value, codec: &str) -> Value { + json!({ + "targetIp": "127.0.0.1", + "port": port, + "channels": channels, + "codec": codec, + "opusBitrate": 96000, + "opusApplication": "audio", + "sampleRate": sample_rate, + }) +} + +fn with_sender(sample_rate: Value, codec: &str) -> Graph { + mic_speaker() + .node("tx", "netSender", sender(47_410, 2, sample_rate, codec)) + .edge("m", "tx") +} + +/// The microphone sent over the network to this machine and played. +fn loopback(port: u16, sample_rate: Value, codec: &str) -> Graph { + Graph::new(128) + .node("m", "microphone", mic(MIC)) + .node("tx", "netSender", sender(port, 1, sample_rate, codec)) + .node("rx", "netReceiver", json!({ "port": port, "channels": 1 })) + .node("s", "speaker", speaker("out")) + .edge_handles("m", None, "tx", Some("ch1")) + .edge_handles("rx", Some("ch1"), "s", None) +} + +#[test] +fn a_sender_changing_its_rate_is_not_heard_on_the_speaker() { + let _g = serial(); + edit( + with_sender(Value::Null, "pcm-f32"), + with_sender(json!(44_100), "pcm-f32"), + SETTLED, + ) + .assert_unheard("sender rate"); +} + +#[test] +fn a_sender_changing_its_codec_is_not_heard_on_the_speaker() { + let _g = serial(); + edit( + with_sender(Value::Null, "pcm-f32"), + with_sender(Value::Null, "opus"), + SETTLED, + ) + .assert_unheard("sender codec"); +} + +#[test] +fn adding_and_removing_a_sender_is_not_heard() { + let _g = serial(); + edit(mic_speaker(), with_sender(Value::Null, "pcm-f32"), SETTLED).assert_unheard("add sender"); + edit(with_sender(Value::Null, "pcm-f32"), mic_speaker(), SETTLED) + .assert_unheard("remove sender"); +} + +#[test] +fn adding_an_analyzer_is_not_heard() { + let _g = serial(); + let to = mic_speaker() + .node("w", "waveform", effect("waveform")) + .edge("m", "w"); + edit(mic_speaker(), to, SETTLED).assert_unheard("add analyzer"); +} + +#[test] +fn adding_a_second_speaker_is_not_heard_on_the_first() { + let _g = serial(); + let to = mic_speaker() + .node("s2", "speaker", speaker("out-2")) + .edge("m", "s2"); + edit(mic_speaker(), to, SETTLED).assert_unheard("add second speaker"); +} + +#[test] +fn starting_a_recording_is_not_heard() { + let _g = serial(); + let path = temp_path("transition-rec.wav"); + let to = mic_speaker() + .node( + "r", + "fileRecording", + json!({ "filePath": path.to_string_lossy(), "format": wav(), "sampleRate": RATE }), + ) + .edge("m", "r"); + edit(mic_speaker(), to, SETTLED).assert_unheard("add recording"); +} + +#[test] +fn an_effect_on_another_speaker_is_not_heard() { + let _g = serial(); + let from = mic_speaker() + .node("s2", "speaker", speaker("out-2")) + .edge("m", "s2"); + let to = mic_speaker() + .node("r", "reverb", effect("reverb")) + .node("s2", "speaker", speaker("out-2")) + .chain(&["m", "r", "s2"]); + edit(from, to, SETTLED).assert_unheard("reverb on another speaker"); +} + +#[test] +fn a_volume_change_neither_rebuilds_nor_steps() { + let _g = serial(); + let vol = |v: f32| { + Graph::new(64) + .node("m", "microphone", json!({ "deviceId": MIC, "volume": v })) + .node("s", "speaker", speaker("out")) + .edge("m", "s") + }; + edit(vol(1.0), vol(0.95), SETTLED).assert_unheard("volume"); +} + +#[test] +fn adding_an_effect_dips_without_a_step() { + let _g = serial(); + let to = Graph::new(64) + .node("m", "microphone", mic(MIC)) + .node("g", "gain", effect("gain")) + .node("s", "speaker", speaker("out")) + .chain(&["m", "g", "s"]); + edit(mic_speaker(), to, SETTLED).assert_smooth("add gain"); +} + +#[test] +fn a_new_lookahead_fades_in_once_its_delay_has_filled() { + let _g = serial(); + let limiter = |lookahead: f32| { + Graph::new(64) + .node("m", "microphone", mic(MIC)) + .node( + "l", + "limiter", + json!({ "ceilingDb": -0.3, "lookaheadMs": lookahead, "releaseMs": 50 }), + ) + .node("s", "speaker", speaker("out")) + .chain(&["m", "l", "s"]) + }; + edit(limiter(5.0), limiter(2.0), SETTLED).assert_smooth("limiter lookahead"); +} + +#[test] +fn a_new_buffer_reopens_the_speaker_without_correcting_afterwards() { + let _g = serial(); + let to = Graph::new(256) + .node("m", "microphone", mic(MIC)) + .node("s", "speaker", speaker("out")) + .edge("m", "s"); + edit(mic_speaker(), to, SETTLED).assert_smooth("buffer 64 -> 256"); +} + +#[test] +fn an_app_source_rebuilt_behind_an_effect_dips_without_a_step() { + let _g = serial(); + let app = |gain: bool| { + let g = Graph::new(64) + .node( + "a", + "appAudio", + json!({ "bundleId": "com.example.player", "volume": 1.0 }), + ) + .node("s", "speaker", speaker("out")); + if gain { + g.node("g", "gain", effect("gain")).chain(&["a", "g", "s"]) + } else { + g.edge("a", "s") + } + }; + edit(app(false), app(true), SETTLED).assert_smooth("app audio add gain"); +} + +#[test] +fn a_receiver_rides_out_its_sender_changing_rate() { + let _g = serial(); + edit( + loopback(47_411, Value::Null, "pcm-f32"), + loopback(47_411, json!(44_100), "pcm-f32"), + SETTLED_NET, + ) + .assert_smooth("received rate"); +} + +#[test] +fn a_receiver_rides_out_its_sender_changing_codec() { + let _g = serial(); + edit( + loopback(47_412, Value::Null, "pcm-f32"), + loopback(47_412, Value::Null, "opus"), + SETTLED_NET, + ) + .assert_smooth("received codec"); +} + +#[test] +fn the_first_start_corrects_its_depth_in_one_soft_cut() { + let _g = serial(); + let heard = on_time(|| { + let mut rig = splitwave_lib::testkit::Rig::new(devices()); + rig.apply(mic_speaker().json()).expect("graph starts"); + std::thread::sleep(Duration::from_millis(2500)); + let played = rig.played("out"); + let ch0: Vec = played.iter().step_by(2).copied().collect(); + let first = ch0.iter().position(|s| s.abs() > 1e-3).expect("it plays"); + // The fade-in of the first sound is the only bend at the start. + listen(&ch0[first + RATE as usize / 50..], 0) + }); + assert!(heard.breaks.is_empty(), "breaks at {:?} ms", heard.breaks); + assert!( + heard.off.len() <= 3, + "level off at {:?} ms: more than one cut", + heard.off + ); +} diff --git a/src/lib/components/format.ts b/src/lib/components/format.ts index 4c4ae6d..edf9093 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 0000000..c7e2637 --- /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 899d64c..0a01f1a 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/components/waveform_scope.svelte b/src/lib/components/waveform_scope.svelte index 9756b73..65ac4df 100644 --- a/src/lib/components/waveform_scope.svelte +++ b/src/lib/components/waveform_scope.svelte @@ -61,8 +61,9 @@ // browsable history is the binned scope stream, kept per node id so it // survives remounts. Cleared when a new session rewrites the timeline. const liveHistories = new Map; low: number }>(); - // ~3.5 h per node at 48 kHz; trimmed from the front once exceeded. - const LIVE_HISTORY_CAP = 200_000; + // Seconds of history kept per node, at any rate; trimmed from the front + // once exceeded. One bin covers SEG_FRAMES frames. + const LIVE_HISTORY_SECONDS = 270; const SCALE_LEVELS: [number, string][] = [ [1.0, '1.0'], [0.5, '0.5'], @@ -156,7 +157,8 @@ } liveStore.bins.set(seg, arr); if (liveStore.low < 0) liveStore.low = seg; - while (liveStore.bins.size > LIVE_HISTORY_CAP && liveStore.low <= seg) { + const cap = (LIVE_HISTORY_SECONDS * sampleRate) / SEG_FRAMES; + while (liveStore.bins.size > cap && liveStore.low <= seg) { liveStore.bins.delete(liveStore.low); liveStore.low++; } diff --git a/src/lib/modules/audio/device_info.svelte.ts b/src/lib/modules/audio/device_info.svelte.ts index 5edd657..0682133 100644 --- a/src/lib/modules/audio/device_info.svelte.ts +++ b/src/lib/modules/audio/device_info.svelte.ts @@ -1,4 +1,5 @@ import { onDestroy, onMount } from 'svelte'; +import { DEFAULT_SAMPLE_RATE } from '$lib/modules/pipeline/generated/engine'; import { methods } from './methods'; import { audioStore } from './stores.svelte'; import { appSettings } from '$lib/modules/settings/stores.svelte'; @@ -130,7 +131,7 @@ export function useDeviceInfo(target: DeviceInfoTarget): DeviceInfoState { if (timer) clearInterval(timer); }); - const sampleRate = $derived(info?.sampleRate ?? 48_000); + const sampleRate = $derived(info?.sampleRate ?? DEFAULT_SAMPLE_RATE); const channels = $derived(info?.channels ?? 2); const sampleFormat = $derived(info?.sampleFormat ?? 'f32'); const specText = $derived(`${formatHz(sampleRate)} · ${channels} ch · ${sampleFormat}`); diff --git a/src/lib/modules/audio/latency.svelte.ts b/src/lib/modules/audio/latency.svelte.ts new file mode 100644 index 0000000..30522ea --- /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 827ce13..6937d54 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,12 @@ 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'), - startPipeline: (graph: StartPipelinePayload): Promise => - invoke('start_pipeline', { graph: { sampleRate: appSettings.pipelineSampleRate, ...graph } }), + getLatencyReport: (): Promise => invoke('latency_report'), + startPipeline: (graph: StartPipelinePayload): Promise => 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 } }), + reconcilePipeline: (graph: StartPipelinePayload): Promise => 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/stores.svelte.ts b/src/lib/modules/audio/stores.svelte.ts index 2c53fbb..ed2ef8f 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/lib/modules/audio/ui/latency_badge.svelte b/src/lib/modules/audio/ui/latency_badge.svelte index 8d5ab36..382e52c 100644 --- a/src/lib/modules/audio/ui/latency_badge.svelte +++ b/src/lib/modules/audio/ui/latency_badge.svelte @@ -1,34 +1,61 @@ -{#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/editor.svelte b/src/lib/modules/flow/ui/editor.svelte index f22ccf3..72a7e3e 100644 --- a/src/lib/modules/flow/ui/editor.svelte +++ b/src/lib/modules/flow/ui/editor.svelte @@ -119,7 +119,10 @@ } function addNode(kind: NodeKind, position?: { x: number; y: number }) { - addNodeWithData(kind, defaultDataFor(kind), position); + const data = defaultDataFor(kind); + // A new recording pins the rate the pipeline runs at, not a fixed one. + if (kind === 'fileRecording') Object.assign(data, { sampleRate: appSettings.pipelineSampleRate }); + addNodeWithData(kind, data, position); } function addNodeWithData(kind: NodeKind, data: Record, position?: { x: number; y: number }) { @@ -495,12 +498,10 @@ lastSnapshotAt = Date.now() - SNAPSHOT_MIN_SPACING_MS - 1; }); - // Every node's data (minus canvas geometry) and every edge with its handles. - // The backend reconcile classifies the resend, so a live-param-only change is - // a no-op there and needs no field-by-field gate here. + // Which nodes exist and how they connect, minus canvas geometry. function routingSignature(): string { return JSON.stringify({ - nodes: nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), + nodes: nodes.map((n) => ({ id: n.id, type: n.type })), edges: edges.map((e) => ({ id: e.id, source: e.source, @@ -511,28 +512,65 @@ }); } + // Every node's settings. The backend reconcile classifies the resend, so a + // live-param-only change is a no-op there and needs no field-by-field gate + // here. + function dataSignature(): string { + return JSON.stringify(nodes.map((n) => n.data)); + } + + // A slider sends its live value on its own; the graph follows once it + // rests, so a drag neither floods reconcile nor rebuilds a node (a + // declick's width, a limiter's lookahead) on every step. + const DATA_SETTLE_MS = 250; + let lastRoutingSig = untrack(routingSignature); - let restartTimer: ReturnType | undefined; - // No teardown on re-run: node measurement re-fires this effect constantly and - // would cancel the pending reconcile before it ever reaches the backend. + let lastDataSig = untrack(dataSignature); + let settleTimer: ReturnType | undefined; + // Edits made while a reconcile is still running collapse into one, sent + // with the graph as it is by then. + let restartInFlight = false; + let restartQueued = false; + async function pushRestart() { + if (restartInFlight) { + restartQueued = true; + return; + } + restartInFlight = true; + try { + do { + restartQueued = false; + await audioStore.restartPipeline({ + nodes: fromXyNodes(nodes), + edges: fromXyEdges(edges) + }); + } while (restartQueued && audioStore.isRunning); + } catch (e) { + audioStore.reportError(e); + } finally { + restartInFlight = false; + } + } + // No teardown on re-run: node measurement re-fires this effect constantly. + // A new node or connection goes to the backend at once. $effect(() => { - const sig = routingSignature(); - if (sig === lastRoutingSig) return; - lastRoutingSig = sig; + const routing = routingSignature(); + const data = dataSignature(); + const rerouted = routing !== lastRoutingSig; + if (!rerouted && data === lastDataSig) return; + lastRoutingSig = routing; + lastDataSig = data; if (!audioStore.isRunning) return; - clearTimeout(restartTimer); - restartTimer = setTimeout(() => { - untrack(async () => { - try { - await audioStore.restartPipeline({ - nodes: fromXyNodes(nodes), - edges: fromXyEdges(edges) - }); - } catch (e) { - audioStore.reportError(e); - } - }); - }, 400); + clearTimeout(settleTimer); + settleTimer = undefined; + if (rerouted) { + untrack(() => void pushRestart()); + return; + } + settleTimer = setTimeout(() => { + settleTimer = undefined; + untrack(() => void pushRestart()); + }, DATA_SETTLE_MS); }); // The Tauri WebView (and historic browser behavior) treats Backspace outside @@ -620,7 +658,7 @@ onDestroy(() => { flushPendingCommit(); - clearTimeout(restartTimer); + clearTimeout(settleTimer); unlistenAudioFile?.(); if (pipelineStore.editorActions?.getSnapshot === getSnapshot) { pipelineStore.editorActions = null; diff --git a/src/lib/modules/flow/ui/effect/delay.svelte b/src/lib/modules/flow/ui/effect/delay.svelte index 91088b5..7c2d006 100644 --- a/src/lib/modules/flow/ui/effect/delay.svelte +++ b/src/lib/modules/flow/ui/effect/delay.svelte @@ -1,4 +1,5 @@