From 3721ef3dc9ff48cfa23b3077070eab91080832b6 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:46:35 +0300 Subject: [PATCH 1/9] feat(audio): configurable pipeline and virtual device sample rates (#32) - Add pipeline sample rate setting in Settings (presets: 44.1k, 48k default, 88.2k, 96k, 176.4k, 192k + 1 Hz custom stepper). - Add sample rate configuration per virtual device in Virtual Devices UI and HAL driver. - Ensure bit-transparent signal path without resampling when device and pipeline rates match. - Add input normalizer and output rate adaptation to eliminate Issue #32 capture startup mismatch crashes. - Bump virtual driver version to 5. --- src-tauri/native/CATapCapture.swift | 5 + src-tauri/native/virtual_driver/Info.plist | 2 +- .../virtual_driver/SplitAudioDriver.cpp | 25 ++- src-tauri/src/audio/capture/macos_backend.rs | 8 + src-tauri/src/audio/capture/macos_tap.rs | 29 ++++ src-tauri/src/audio/capture/mod.rs | 2 +- src-tauri/src/audio/clock.rs | 33 ++-- src-tauri/src/audio/graph.rs | 56 ++++++ src-tauri/src/audio/pipeline/dag.rs | 1 + src-tauri/src/audio/pipeline/input/macos.rs | 14 +- src-tauri/src/audio/pipeline/input/mod.rs | 164 +++++++++++++++++- src-tauri/src/audio/pipeline/meter.rs | 7 +- src-tauri/src/audio/pipeline/mod.rs | 83 +++++---- src-tauri/src/audio/pipeline/output/linux.rs | 10 +- src-tauri/src/audio/pipeline/output/macos.rs | 10 +- src-tauri/src/audio/pipeline/output/mod.rs | 81 +++++++-- .../src/audio/pipeline/output/windows.rs | 10 +- src-tauri/src/audio/resample.rs | 83 +++++++-- src-tauri/src/audio/virtual_device/linux.rs | 11 +- src-tauri/src/audio/virtual_device/macos.rs | 5 +- src-tauri/src/audio/virtual_device/mod.rs | 31 +++- src/lib/modules/audio/methods.ts | 7 +- src/lib/modules/audio/types.ts | 2 + src/lib/modules/settings/stores.svelte.ts | 28 ++- src/routes/settings/+page.svelte | 73 +++++++- src/routes/virtual-devices/+page.svelte | 43 ++++- 26 files changed, 698 insertions(+), 125 deletions(-) diff --git a/src-tauri/native/CATapCapture.swift b/src-tauri/native/CATapCapture.swift index 2a25fd3e..8337fe53 100644 --- a/src-tauri/native/CATapCapture.swift +++ b/src-tauri/native/CATapCapture.swift @@ -227,6 +227,11 @@ private final class Tap { var format: (rate: Double, channels: Int) { lock.lock() defer { lock.unlock() } + if aggregateID != 0, + let rate = readValue(aggregateID, kAudioDevicePropertyNominalSampleRate, Double(0)), + rate > 0 { + sampleRate = rate + } return (sampleRate, tapChannels) } diff --git a/src-tauri/native/virtual_driver/Info.plist b/src-tauri/native/virtual_driver/Info.plist index 1fbbbc5b..fdea67d4 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 - 4 + 5 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 ec321fce..29356bdc 100644 --- a/src-tauri/native/virtual_driver/SplitAudioDriver.cpp +++ b/src-tauri/native/virtual_driver/SplitAudioDriver.cpp @@ -76,7 +76,7 @@ class SplitIOHandler : public aspl::IORequestHandler, } }; -struct DeviceConfig { std::string id; std::string name; uint32_t channels; }; +struct DeviceConfig { std::string id; std::string name; uint32_t channels; uint32_t sampleRate; }; static std::string CFStr(CFStringRef s) { if (!s) return {}; @@ -126,7 +126,14 @@ static std::vector ReadConfig() { CFNumberGetValue(ch, kCFNumberIntType, &v); if (v >= 1 && v <= 256) channels = (uint32_t)v; } - if (!id.empty() && !name.empty()) out.push_back({id, name, channels}); + uint32_t sampleRate = 48000; + CFNumberRef sr = (CFNumberRef)CFDictionaryGetValue(d, CFSTR("sampleRate")); + if (sr && CFGetTypeID(sr) == CFNumberGetTypeID()) { + int v = 0; + CFNumberGetValue(sr, kCFNumberIntType, &v); + if (v >= 8000 && v <= 384000) sampleRate = (uint32_t)v; + } + if (!id.empty() && !name.empty()) out.push_back({id, name, channels, sampleRate}); } CFRelease(plist); return out; @@ -136,6 +143,7 @@ struct DeviceEntry { std::shared_ptr device; std::shared_ptr handler; uint32_t channels; + uint32_t sampleRate; }; static std::shared_ptr gContext; @@ -144,9 +152,9 @@ static std::map gDevices; static std::mutex gDevicesMutex; // libASPL streams default to 16-bit int; our IO is float. -static AudioStreamBasicDescription FloatFormat(UInt32 channels) { +static AudioStreamBasicDescription FloatFormat(UInt32 channels, Float64 sampleRate) { AudioStreamBasicDescription f = {}; - f.mSampleRate = 48000; + f.mSampleRate = sampleRate; f.mFormatID = kAudioFormatLinearPCM; f.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked; @@ -167,7 +175,7 @@ static DeviceEntry BuildDevice(const DeviceConfig& cfg) { params.Manufacturer = "Splitwave"; params.DeviceUID = "com.horuse.splitwave.audio." + cfg.id; params.ModelUID = "com.horuse.splitwave.audio.model"; - params.SampleRate = 48000; + params.SampleRate = cfg.sampleRate; params.ChannelCount = cfg.channels; params.EnableMixing = true; @@ -177,15 +185,15 @@ static DeviceEntry BuildDevice(const DeviceConfig& cfg) { aspl::StreamParameters outStream; outStream.Direction = aspl::Direction::Output; - outStream.Format = FloatFormat(params.ChannelCount); + outStream.Format = FloatFormat(params.ChannelCount, params.SampleRate); device->AddStreamWithControlsAsync(outStream); aspl::StreamParameters inStream; inStream.Direction = aspl::Direction::Input; - inStream.Format = FloatFormat(params.ChannelCount); + inStream.Format = FloatFormat(params.ChannelCount, params.SampleRate); device->AddStreamWithControlsAsync(inStream); - return {device, handler, cfg.channels}; + return {device, handler, cfg.channels, cfg.sampleRate}; } // Reconciles the live device set against the config file. A channel count change @@ -200,6 +208,7 @@ static void SyncDevices() { return nullptr; }(); if (!cfg || cfg->channels != it->second.channels + || cfg->sampleRate != it->second.sampleRate || cfg->name != it->second.device->GetName()) { gPlugin->RemoveDevice(it->second.device); it = gDevices.erase(it); diff --git a/src-tauri/src/audio/capture/macos_backend.rs b/src-tauri/src/audio/capture/macos_backend.rs index be66d656..6fd6877a 100644 --- a/src-tauri/src/audio/capture/macos_backend.rs +++ b/src-tauri/src/audio/capture/macos_backend.rs @@ -111,10 +111,18 @@ impl Capture { } } + #[allow(dead_code)] pub fn sample_rate(&self) -> u32 { match self { Capture::Tap(tap) => tap.sample_rate(), Capture::Sck(_) => SCK_RATE, } } + + pub fn tap_rate_probe(&self) -> Option { + match self { + Capture::Tap(tap) => Some(tap.rate_probe()), + Capture::Sck(_) => None, + } + } } diff --git a/src-tauri/src/audio/capture/macos_tap.rs b/src-tauri/src/audio/capture/macos_tap.rs index 16128d64..35bb5411 100644 --- a/src-tauri/src/audio/capture/macos_tap.rs +++ b/src-tauri/src/audio/capture/macos_tap.rs @@ -96,6 +96,7 @@ pub struct TapCapture { handle: *mut c_void, state: Arc, channels: u32, + #[allow(dead_code)] sample_rate: u32, } @@ -224,9 +225,37 @@ impl TapCapture { self.channels } + #[allow(dead_code)] pub fn sample_rate(&self) -> u32 { self.sample_rate } + + pub fn rate_probe(&self) -> TapRateProbe { + TapRateProbe { + handle: self.handle, + } + } +} + +/// Non-owning format probe used only by the normalizer thread. `TapCapture` +/// outlives that thread (NormalizedInput joins it before dropping capture), so +/// the native handle remains valid for every query. +#[derive(Clone, Copy)] +pub struct TapRateProbe { + handle: *mut c_void, +} + +unsafe impl Send for TapRateProbe {} + +impl TapRateProbe { + pub fn sample_rate(&self) -> Option { + let mut sample_rate = 0.0f64; + let mut channels = 0i32; + let rc = ResultCode::from_raw(unsafe { + ba_tap_format(self.handle, &mut sample_rate, &mut channels) + }); + (rc == ResultCode::Ok && sample_rate > 0.0 && channels > 0).then_some(sample_rate as u32) + } } impl Drop for TapCapture { diff --git a/src-tauri/src/audio/capture/mod.rs b/src-tauri/src/audio/capture/mod.rs index 2f9d1832..78a45619 100644 --- a/src-tauri/src/audio/capture/mod.rs +++ b/src-tauri/src/audio/capture/mod.rs @@ -3,7 +3,7 @@ mod macos; #[cfg(target_os = "macos")] mod macos_backend; #[cfg(target_os = "macos")] -mod macos_tap; +pub(crate) mod macos_tap; #[cfg(target_os = "macos")] pub use macos_backend::{capture_rate, uses_taps, Capture}; diff --git a/src-tauri/src/audio/clock.rs b/src-tauri/src/audio/clock.rs index 96b80efc..9fac344a 100644 --- a/src-tauri/src/audio/clock.rs +++ b/src-tauri/src/audio/clock.rs @@ -1,6 +1,6 @@ //! Pacing source for the DSP worker. -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; @@ -99,8 +99,9 @@ const FILL_CLOCK_MAX_SLEEP: Duration = Duration::from_millis(5); /// 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 { - sample_rate: u32, - block_frames: usize, + 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 @@ -113,14 +114,16 @@ pub struct DeviceFillClock { impl DeviceFillClock { pub fn new( - sample_rate: u32, - block_frames: usize, + pipeline_sample_rate: u32, + device_sample_rate: Arc, + engine_block_frames: usize, level: Arc, target: Arc, ) -> Self { Self { - sample_rate, - block_frames, + pipeline_sample_rate, + device_sample_rate, + engine_block_frames, level, target, primed: false, @@ -136,24 +139,26 @@ impl ClockSource for DeviceFillClock { } let target_frames = self.target.load(Ordering::Relaxed).max(0) as usize; let queued = self.level.load(Ordering::Relaxed).max(0) as usize; - if queued + self.block_frames <= target_frames { + 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 queued + block_frames <= target_frames { // Less than one block of headroom left in the ring -- the // worker isn't staying ahead of the device. - if self.primed && queued < self.block_frames { + if self.primed && queued < block_frames { health::bump(&health::CLOCK_LATE_BLOCKS, 1); } return true; } self.primed = true; - let overshoot = queued + self.block_frames - target_frames; - let drain = Duration::from_nanos( - (overshoot as u64 * 1_000_000_000) / self.sample_rate.max(1) as u64, - ); + 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.sample_rate + self.device_sample_rate.load(Ordering::Relaxed) } } diff --git a/src-tauri/src/audio/graph.rs b/src-tauri/src/audio/graph.rs index 2c860186..21793fbd 100644 --- a/src-tauri/src/audio/graph.rs +++ b/src-tauri/src/audio/graph.rs @@ -10,6 +10,8 @@ use crate::error::{AppError, AppResult}; pub struct GraphSpec { pub nodes: Vec, pub edges: Vec, + #[serde(default)] + pub sample_rate: Option, } #[derive(Debug, Deserialize)] @@ -708,6 +710,7 @@ pub struct ValidGraph { pub outputs: Vec, pub effects: Vec, pub edges: Vec, + pub sample_rate: u32, } /// One node of the expanded graph. A dual-role UI node appears once per role it @@ -893,11 +896,22 @@ impl GraphSpec { }) .collect(); + let sample_rate = match self.sample_rate { + Some(sr) if !(8_000..=384_000).contains(&sr) => { + return Err(AppError::Validation(format!( + "pipeline sample rate {sr} out of bounds (8000..=384000)" + ))); + } + Some(sr) => sr, + None => 48_000, + }; + Ok(ValidGraph { inputs, outputs, effects, edges, + sample_rate, }) } } @@ -1329,6 +1343,7 @@ mod tests { #[test] fn send_only_collaborator_is_an_output() { let g = GraphSpec { + sample_rate: None, nodes: vec![mic("m"), collab("w")], edges: vec![edge("e", "m", None, "w", Some("ch1"))], }; @@ -1351,6 +1366,7 @@ mod tests { #[test] fn recv_only_collaborator_is_an_input() { let g = GraphSpec { + sample_rate: None, nodes: vec![collab("w"), speaker("s")], edges: vec![edge("e", "w", Some("peer:p:0"), "s", None)], }; @@ -1371,6 +1387,7 @@ mod tests { #[test] fn duplex_collaborator_is_both() { let g = GraphSpec { + sample_rate: None, nodes: vec![mic("m"), collab("w"), speaker("s")], edges: vec![ edge("e1", "m", None, "w", Some("ch1")), @@ -1387,6 +1404,7 @@ mod tests { #[test] fn unwired_collaborator_is_not_a_routing_error() { let g = GraphSpec { + sample_rate: None, nodes: vec![collab("w")], edges: vec![], }; @@ -1400,6 +1418,7 @@ mod tests { #[test] fn unrouted_output_is_valid_and_streams_silence() { let g = GraphSpec { + sample_rate: None, nodes: vec![speaker("s")], edges: vec![], }; @@ -1416,6 +1435,7 @@ mod tests { } let g = GraphSpec { + sample_rate: None, nodes: vec![gain("g"), speaker("s")], edges: vec![edge("e", "g", None, "s", None)], }; @@ -1429,4 +1449,40 @@ mod tests { assert_eq!(v.outputs[0].id, "s"); assert_eq!(v.edges.len(), 1); } + + #[test] + fn default_sample_rate_is_48000() { + let g = GraphSpec { + sample_rate: None, + nodes: vec![speaker("s")], + edges: vec![], + }; + let v = g.validate().expect("graph valid"); + assert_eq!(v.sample_rate, 48_000); + } + + #[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), + nodes: vec![speaker("s")], + edges: vec![], + }; + let v = g.validate().expect("graph valid"); + assert_eq!(v.sample_rate, sr); + } + } + + #[test] + fn out_of_bounds_sample_rate_is_rejected() { + for sr in [0, 4_000, 7_999, 384_001, 1_000_000] { + let g = GraphSpec { + sample_rate: Some(sr), + nodes: vec![speaker("s")], + edges: vec![], + }; + assert!(g.validate().is_err()); + } + } } diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 78cc90d3..962b28b1 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -1883,6 +1883,7 @@ pub(super) fn reachable_backward(output_id: &str, valid: &ValidGraph) -> HashSet seen } +#[allow(dead_code)] pub(super) fn inputs_feeding_output<'a>(output_id: &str, valid: &'a ValidGraph) -> Vec<&'a str> { let reachable = reachable_backward(output_id, valid); valid diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs index 68f9fedf..6fa48960 100644 --- a/src-tauri/src/audio/pipeline/input/macos.rs +++ b/src-tauri/src/audio/pipeline/input/macos.rs @@ -22,14 +22,10 @@ use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; /// mistimed audio. const CAPTURE_CHANNELS: u32 = 2; -fn check_capture_format( - capture: &crate::audio::capture::Capture, - expected_rate: u32, -) -> AppResult<()> { - if capture.sample_rate() != expected_rate || capture.channels() != CAPTURE_CHANNELS { +fn check_capture_format(capture: &crate::audio::capture::Capture) -> AppResult<()> { + if capture.channels() != CAPTURE_CHANNELS { return Err(AppError::Stream(format!( - "capture format changed while starting: expected {expected_rate} Hz / {CAPTURE_CHANNELS} ch, got {} Hz / {} ch", - capture.sample_rate(), + "capture channel layout changed while starting: expected {CAPTURE_CHANNELS} ch, got {} ch", capture.channels() ))); } @@ -118,7 +114,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( sample_rate, bridge, )?; - check_capture_format(&capture, sample_rate)?; + check_capture_format(&capture)?; Ok(InputHandle::Capture(capture)) } ResolvedInput::AppAudio { @@ -127,7 +123,7 @@ pub(in crate::audio::pipeline) fn start_input_stream( } => { let capture = crate::audio::capture::Capture::start_app(&bundle_id, sample_rate, bridge)?; - check_capture_format(&capture, sample_rate)?; + check_capture_format(&capture)?; Ok(InputHandle::Capture(capture)) } ResolvedInput::AudioFile { path, .. } => { diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index e45a04ee..bac23e76 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -1,16 +1,22 @@ use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; #[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::input_bridge::BroadcastRx; -use crate::error::AppResult; +use crate::audio::effects::{update_meter, MeterHandle}; +use crate::audio::input_bridge::{broadcast_channel, BroadcastRx}; +use crate::audio::resample::MultiResampler; +use crate::error::{AppError, AppResult}; +use super::dag::{RESAMPLE_CHUNK, RING_CAPACITY_FRAMES}; use super::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader}; #[cfg(target_os = "macos")] @@ -26,7 +32,8 @@ mod windows; #[cfg(target_os = "windows")] use windows as platform; -pub(super) use platform::{resolve_input, start_input_stream}; +pub(super) use platform::resolve_input; +use platform::start_input_stream as start_native_input_stream; /// ScreenCaptureKit (macOS) and PipeWire (Linux) both deliver 48 kHz, matching /// the device side so no resampling happens on capture delivery. @@ -41,6 +48,41 @@ pub(super) enum InputHandle { Cpal(cpal::Stream), Capture(crate::audio::capture::Capture), AudioFile(AudioFileReader), + Normalized(NormalizedInput), +} + +pub(super) struct NormalizedInput { + _input: Box, + stop: Arc, + join: Option>, +} + +impl Drop for NormalizedInput { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +impl InputHandle { + pub fn audio_file_reader(&self) -> Option<&AudioFileReader> { + match self { + InputHandle::AudioFile(r) => Some(r), + InputHandle::Normalized(n) => n._input.audio_file_reader(), + _ => None, + } + } + + #[cfg(target_os = "macos")] + fn tap_rate_probe(&self) -> Option { + match self { + InputHandle::Capture(capture) => capture.tap_rate_probe(), + InputHandle::Normalized(n) => n._input.tap_rate_probe(), + _ => None, + } + } } // cpal's coreaudio backend never stops a non-default device's AudioUnit just @@ -148,3 +190,119 @@ pub(super) fn start_audio_file( )?; Ok(InputHandle::AudioFile(reader)) } + +/// Capture callbacks only enqueue native-rate samples. A dedicated worker +/// normalizes each input once before the dynamic fan-out reaches the DSP graph. +/// If `sample_rate == target_sample_rate`, NO RESAMPLING is performed (resampler is None), +/// providing bit-transparent 1:1 passthrough. +pub(super) fn start_input_stream( + node_id: &str, + resolved: ResolvedInput, + bridge: BroadcastRx, + target_sample_rate: u32, + paused: Option>, + meter: Option, + app: &AppHandle, +) -> AppResult { + let sample_rate = resolved.sample_rate(); + let channels = resolved.native_channels() as usize; + let (raw_producer, mut raw_consumer) = + RingBuffer::::new(RING_CAPACITY_FRAMES * channels.max(1)); + let (mut raw_tx, raw_rx) = broadcast_channel(); + raw_tx.add(raw_producer)?; + let input = start_native_input_stream(node_id, resolved, raw_rx, paused, None, app)?; + #[cfg(target_os = "macos")] + let rate_probe = input.tap_rate_probe(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let label = node_id.to_string(); + let join = thread::Builder::new() + .name(format!("normalize:{label}")) + .spawn(move || { + let mut bridge = bridge; + let mut input_buf = vec![0.0; RESAMPLE_CHUNK * channels]; + let mut native_rate = sample_rate; + let mut resampler = if native_rate == target_sample_rate { + None + } else { + match MultiResampler::new(native_rate, target_sample_rate, RESAMPLE_CHUNK, channels) + { + Ok(resampler) => Some(resampler), + Err(_) => return, + } + }; + let mut output_buf = Vec::with_capacity( + resampler + .as_ref() + .map(|r| r.out_max() * channels) + .unwrap_or(input_buf.len()), + ); + while !stop_thread.load(std::sync::atomic::Ordering::Relaxed) { + bridge.apply_commands(); + #[cfg(target_os = "macos")] + if let Some(rate) = rate_probe.and_then(|probe| probe.sample_rate()) { + if rate == native_rate { + // Nothing to do; avoid perturbing the sinc state. + } else { + // Raw samples on either side of a device-rate transition + // cannot share a sinc state. Drop only this input's raw + // backlog, then rebuild the non-RT normalizer; the tap and + // every engine/output worker keep running. + let pending = raw_consumer.slots(); + if pending > 0 { + if let Ok(chunk) = raw_consumer.read_chunk(pending) { + chunk.commit_all(); + } + } + native_rate = rate; + resampler = if rate == target_sample_rate { + None + } else { + MultiResampler::new(rate, target_sample_rate, RESAMPLE_CHUNK, channels) + .ok() + }; + output_buf = Vec::with_capacity( + resampler + .as_ref() + .map(|r| r.out_max() * channels) + .unwrap_or(input_buf.len()), + ); + } + } + 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 { + 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); + chunk.commit_all(); + let normalized = if let Some(resampler) = &mut resampler { + output_buf.clear(); + if resampler + .process_chunk(&input_buf, &mut output_buf) + .is_err() + { + break; + } + output_buf.as_slice() + } else { + input_buf.as_slice() + }; + if let Some(meter) = &meter { + update_meter(meter, normalized, channels); + } + bridge.broadcast(normalized); + } + }) + .map_err(|e| AppError::Stream(format!("spawn input normalizer: {e}")))?; + Ok(InputHandle::Normalized(NormalizedInput { + _input: Box::new(input), + stop, + join: Some(join), + })) +} diff --git a/src-tauri/src/audio/pipeline/meter.rs b/src-tauri/src/audio/pipeline/meter.rs index a3534d56..7e814703 100644 --- a/src-tauri/src/audio/pipeline/meter.rs +++ b/src-tauri/src/audio/pipeline/meter.rs @@ -250,8 +250,11 @@ pub(super) fn spawn_xrun_thread( (requested_delta, read_delta, callbacks_delta) }); let io_off_rate = io.is_some_and(|(requested_delta, _, callbacks_delta)| { - let expected_samples = - o.sample_rate as f64 * o.channels as f64 * elapsed_secs; + 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; // The device's own buffer size, measured rather than // assumed: cpal opens with `BufferSize::Default`. let quantum = if callbacks_delta > 0 { diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index 8847cb51..f68bd403 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -41,10 +41,7 @@ pub(crate) use output::RtThread; mod sig; mod worker; -use dag::{ - build_output_graph, inputs_feeding_output, OutputGraph, OutputMeta, SourceMeta, - RING_CAPACITY_FRAMES, -}; +use dag::{build_output_graph, OutputGraph, OutputMeta, SourceMeta, RING_CAPACITY_FRAMES}; use input::{resolve_input, start_input_stream, InputHandle, ResolvedInput}; use meter::{spawn_meter_thread, spawn_xrun_thread, MeterTickThread, XrunTickThread}; use output::{ @@ -236,7 +233,7 @@ impl ActivePipeline { /// 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 InputHandle::AudioFile(reader) = &state._handle { + if let Some(reader) = state._handle.audio_file_reader() { reader.seek_to().store(frame.max(0), Ordering::SeqCst); } if let Some(d) = &state.drain { @@ -250,7 +247,7 @@ impl ActivePipeline { /// stopped. pub fn set_audio_file_loop(&self, node_id: &str, enabled: bool) { if let Some(state) = self.inputs.get(node_id) { - if let InputHandle::AudioFile(reader) = &state._handle { + if let Some(reader) = state._handle.audio_file_reader() { reader.loop_enabled().store(enabled, Ordering::SeqCst); } } @@ -369,12 +366,20 @@ impl ActivePipeline { GraphSwap, Drop, } + let sample_rate_changed = self + .current + .as_ref() + .map_or(false, |c| c.sample_rate != new_graph.sample_rate); let mut cats: HashMap = HashMap::new(); for (id, new_sig) in &new_sigs { - let cat = match self.current_output_sig(id) { - Some(old) if old == new_sig => Cat::Full, - Some(old) if old.output_spec == new_sig.output_spec => Cat::GraphSwap, - _ => Cat::Drop, + let cat = if sample_rate_changed { + Cat::Drop + } else { + match self.current_output_sig(id) { + Some(old) if old == new_sig => Cat::Full, + Some(old) if old.output_spec == new_sig.output_spec => Cat::GraphSwap, + _ => Cat::Drop, + } }; cats.insert(id.clone(), cat); } @@ -468,6 +473,9 @@ impl ActivePipeline { .inputs .keys() .filter(|id| { + if sample_rate_changed { + return true; + } match ( old_input_specs.get(id.as_str()), new_input_specs.get(id.as_str()), @@ -494,6 +502,9 @@ impl ActivePipeline { let Some(current) = &self.current else { return false; }; + if current.sample_rate != graph.sample_rate { + return false; + } let cur_inputs: HashMap<&str, &InputSpec> = current .inputs .iter() @@ -589,6 +600,7 @@ impl ActivePipeline { /// state -- the caller is responsible for calling `teardown`. fn apply_full(&mut self, graph: &ValidGraph, app: AppHandle) -> AppResult<()> { let monitor_mode = monitor_mode(graph); + let pipeline_sr = graph.sample_rate; let mut input_native_sr: HashMap = HashMap::new(); let mut input_native_channels: HashMap = HashMap::new(); @@ -607,7 +619,7 @@ impl ActivePipeline { input_native_channels.insert(inp.id.clone(), state.channels); } else { let resolved = resolve_input(inp)?; - input_native_sr.insert(inp.id.clone(), resolved.sample_rate()); + input_native_sr.insert(inp.id.clone(), pipeline_sr); input_native_channels.insert(inp.id.clone(), resolved.native_channels()); input_runtime.insert(inp.id.clone(), resolved); } @@ -730,20 +742,11 @@ impl ActivePipeline { OutputSpec::FileRecording { format: RecordingFormat::Aac { .. }, .. - } => { - let max_in = inputs_feeding_output(out.id.as_str(), graph) - .into_iter() - .filter_map(|input_id| input_native_sr.get(input_id).copied()) - .max(); - match max_in { - Some(sr @ (32_000 | 44_100 | 48_000)) => Some(sr), - _ => Some(48_000), - } - } - OutputSpec::FileRecording { .. } => inputs_feeding_output(out.id.as_str(), graph) - .into_iter() - .filter_map(|input_id| input_native_sr.get(input_id).copied()) - .max(), + } => match pipeline_sr { + sr @ (32_000 | 44_100 | 48_000) => Some(sr), + _ => Some(48_000), + }, + OutputSpec::FileRecording { .. } => Some(pipeline_sr), _ => None, }; let resolved = resolve_output(out, file_sr_hint)?; @@ -770,10 +773,14 @@ impl ActivePipeline { if !output_runtime.contains_key(&out.id) { continue; } - let output_sr = output_runtime - .get(&out.id) - .map(|o| o.sample_rate()) - .ok_or_else(|| AppError::Validation("missing output runtime".into()))?; + let output_sr = match &out.spec { + OutputSpec::Speaker { .. } => pipeline_sr, + OutputSpec::FileRecording { .. } => output_runtime + .get(&out.id) + .map(|o| o.sample_rate()) + .unwrap_or(pipeline_sr), + OutputSpec::NetSender { .. } | OutputSpec::WebRtcSend { .. } => pipeline_sr, + }; let mut my_pairs: Vec<(String, Producer)> = Vec::new(); let cut_leaves = pending_cuts.remove(&out.id).unwrap_or_default(); let mut built = build_output_graph( @@ -847,7 +854,7 @@ impl ActivePipeline { let needs_build = monitor_forced || self.monitor.as_ref().map_or(true, |m| m.sig != new_sig); if needs_build { - let monitor_sr = input_native_sr.values().copied().max().unwrap_or(48_000); + let monitor_sr = pipeline_sr; let mut my_pairs: Vec<(String, Producer)> = Vec::new(); // Realtime: the monitor consumes live sources forever, so it must // drop backlog like any other live path. Without this its ring @@ -938,7 +945,7 @@ impl ActivePipeline { let resolved = input_runtime.remove(&input_id).ok_or_else(|| { AppError::Validation(format!("input runtime missing for {input_id}")) })?; - let sample_rate = resolved.sample_rate(); + let sample_rate = pipeline_sr; let channels = resolved.native_channels(); let meter = new_input_meters .remove(&input_id) @@ -957,8 +964,15 @@ impl ActivePipeline { captured.push((input_id.clone(), out_id.clone(), capture)); bridges_by_output.entry(out_id).or_default().push(slot); } - let handle = - start_input_stream(&input_id, resolved, bridge_rx, paused.clone(), None, &app)?; + let handle = start_input_stream( + &input_id, + resolved, + bridge_rx, + pipeline_sr, + paused.clone(), + None, + &app, + )?; self.inputs.insert( input_id, InputState { @@ -997,7 +1011,7 @@ impl ActivePipeline { if matches!(resolved, ResolvedInput::AudioFile { .. }) { continue; } - let sample_rate = resolved.sample_rate(); + let sample_rate = pipeline_sr; let channels = resolved.native_channels(); let meter = new_input_meters .remove(&input_id) @@ -1013,6 +1027,7 @@ impl ActivePipeline { &input_id, resolved, bridge_rx, + pipeline_sr, paused.clone(), Some(meter), &app, diff --git a/src-tauri/src/audio/pipeline/output/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs index c77fcca7..8d0b1824 100644 --- a/src-tauri/src/audio/pipeline/output/linux.rs +++ b/src-tauri/src/audio/pipeline/output/linux.rs @@ -41,8 +41,12 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( info!(node = %spec.node_id, sample_rate = spec.sample_rate, "opening speaker stream (PipeWire)"); let dead = Arc::new(AtomicBool::new(false)); - let (producer, mut fill, level, target, io) = - speaker_ring(spec.out_channels, graph.latency_frames()); + let (producer, mut fill, level, target, io) = speaker_ring( + spec.out_channels, + graph.sample_rate(), + spec.sample_rate, + graph.latency_frames(), + ); let fill_pw = move |out: &mut [f32]| { fill(out, 0); out.len() @@ -53,7 +57,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( producer, level, target, - spec.sample_rate, + io.sample_rate.clone(), spec.out_channels, graph, meter, diff --git a/src-tauri/src/audio/pipeline/output/macos.rs b/src-tauri/src/audio/pipeline/output/macos.rs index aab2f84b..f646b2cd 100644 --- a/src-tauri/src/audio/pipeline/output/macos.rs +++ b/src-tauri/src/audio/pipeline/output/macos.rs @@ -115,8 +115,12 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( let mut io_holder: Option = None; let mut stream_holder: Option = None; for attempt in 1..=SPEAKER_MAX_ATTEMPTS { - let (producer, fill, level, target, io) = - speaker_ring(spec.out_channels, graph.latency_frames()); + 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 dead_cb = dead.clone(); let node_id_cb = node_id.to_string(); @@ -168,7 +172,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( producer, level, target, - spec.sample_rate, + io.sample_rate.clone(), spec.out_channels, graph, meter, diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index 9ceded0c..c7c70602 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; @@ -16,10 +16,11 @@ use crate::audio::clock::{ClockSource, DeviceFillClock, SystemClockTicker}; use crate::audio::effects::{update_meter, MeterHandle, WaveformHandle}; use crate::audio::encoders::{build_encoder, validate_append_target, AudioEncoder}; use crate::audio::graph::{OutputSpec, RecordingFormat, RecordingMode, ValidOutput}; +use crate::audio::resample::MultiResampler; use crate::audio::streams; use crate::error::{AppError, AppResult}; -use super::dag::{OutputGraph, DSP_BLOCK_FRAMES}; +use super::dag::{OutputGraph, DSP_BLOCK_FRAMES, RESAMPLE_CHUNK}; use super::worker::{dsp_worker, WorkerCtrl}; #[cfg(target_os = "macos")] @@ -56,6 +57,12 @@ pub(super) const SPEAKER_TARGET_FILL_BLOCKS: usize = 3; // sits exactly empty when the next callback lands. const SPEAKER_TARGET_MARGIN_BLOCKS: usize = 2; +#[inline] +fn pipeline_frames_to_device_frames(frames: usize, pipeline_rate: u32, device_rate: u32) -> usize { + ((frames as u64 * device_rate.max(1) as u64 + pipeline_rate.max(1) as u64 / 2) + / pipeline_rate.max(1) as u64) as usize +} + pub(super) enum ResolvedOutput { Speaker(SpeakerResolved), File { @@ -201,6 +208,9 @@ impl Drop for RecorderWorker { // `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. @@ -217,8 +227,9 @@ pub(super) struct SpeakerIo { } impl SpeakerIo { - fn new(target_frames: Arc, graph_latency_frames: usize) -> Self { + 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)), @@ -256,6 +267,8 @@ impl Drop for StreamGuard { // 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, @@ -268,11 +281,13 @@ pub(super) fn speaker_ring( RingBuffer::::new(SPEAKER_RING_CAPACITY_FRAMES * out_channels); let level = Arc::new(AtomicI64::new(0)); let level_cb = level.clone(); - let target = Arc::new(AtomicI64::new( - (SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES) as i64, - )); + 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(target.clone(), graph_latency_frames); + let io = SpeakerIo::new(device_rate, target.clone(), graph_latency_frames); let io_cb = io.clone(); let fill = move |out: &mut [f32], _frames: usize| { let read = streams::bulk_pop(&mut consumer, out); @@ -282,8 +297,16 @@ pub(super) fn speaker_ring( // 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 = SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES; - let margin = SPEAKER_TARGET_MARGIN_BLOCKS * DSP_BLOCK_FRAMES; + 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 = out.len() / out_channels; let max = SPEAKER_RING_CAPACITY_FRAMES .saturating_sub(dev_frames + margin) @@ -336,29 +359,59 @@ pub(super) fn spawn_speaker_worker( mut producer: Producer, level: Arc, target: Arc, - sample_rate: u32, + 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( - sample_rate, + 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 = if initial_device_rate == pipeline_rate { + None + } else { + Some(MultiResampler::new( + pipeline_rate, + initial_device_rate, + RESAMPLE_CHUNK, + channels, + )?) + }; + let mut resampled = vec![ + 0.0_f32; + resampler + .as_ref() + .map(|r| r.out_max() * channels * (DSP_BLOCK_FRAMES / RESAMPLE_CHUNK)) + .unwrap_or(DSP_BLOCK_FRAMES * channels) + ]; let join = thread::Builder::new() - .name(format!("speaker:{sample_rate}")) + .name(format!("speaker:{initial_device_rate}")) .spawn(move || { - let _rt = RtThread::promote("speaker", sample_rate); + let _rt = RtThread::promote("speaker", initial_device_rate); worker.run(stop_thread, clock, |block| { update_meter(&meter, block, channels); + let device_block = if let Some(resampler) = &mut resampler { + let mut written = 0; + for chunk in block.chunks_exact(RESAMPLE_CHUNK * channels) { + written += + resampler.process_chunk_into(chunk, &mut resampled[written..])?; + } + &resampled[..written] + } else { + block + }; let written = streams::bulk_push_counted( &mut producer, - block, + device_block, &crate::audio::health::SPEAKER_RING_OVERRUN_SAMPLES, ); level.fetch_add((written / channels) as i64, Ordering::Relaxed); diff --git a/src-tauri/src/audio/pipeline/output/windows.rs b/src-tauri/src/audio/pipeline/output/windows.rs index cde36a13..e018b017 100644 --- a/src-tauri/src/audio/pipeline/output/windows.rs +++ b/src-tauri/src/audio/pipeline/output/windows.rs @@ -73,8 +73,12 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( let dead = Arc::new(AtomicBool::new(false)); - let (producer, fill, level, target, io) = - speaker_ring(spec.out_channels, graph.latency_frames()); + 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 dead_cb = dead.clone(); let node_id_cb = node_id.to_string(); @@ -103,7 +107,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( producer, level, target, - spec.sample_rate, + io.sample_rate.clone(), spec.out_channels, graph, meter, diff --git a/src-tauri/src/audio/resample.rs b/src-tauri/src/audio/resample.rs index 951803a1..d71cde56 100644 --- a/src-tauri/src/audio/resample.rs +++ b/src-tauri/src/audio/resample.rs @@ -111,7 +111,7 @@ impl MultiResampler { let out_max = inner.output_frames_max(); let in_planar = vec![vec![0.0_f32; chunk_size]; channels]; - let out_planar = vec![Vec::with_capacity(out_max); channels]; + let out_planar = vec![vec![0.0_f32; out_max]; channels]; Ok(Self { inner, @@ -130,8 +130,16 @@ impl MultiResampler { self.out_max } - pub fn process_chunk(&mut self, interleaved_in: &[f32], dst: &mut Vec) -> AppResult<()> { + /// Resample one fixed input chunk into a caller-owned, preallocated + /// interleaved buffer. Returns the number of written samples. This is the + /// RT-safe form used by workers: it never grows a `Vec` in the DSP path. + pub fn process_chunk_into( + &mut self, + interleaved_in: &[f32], + output: &mut [f32], + ) -> AppResult { debug_assert_eq!(interleaved_in.len(), self.chunk_in * self.channels); + debug_assert!(output.len() >= self.out_max * self.channels); for (i, frame) in interleaved_in.chunks_exact(self.channels).enumerate() { for c in 0..self.channels { @@ -139,25 +147,76 @@ impl MultiResampler { } } - // Rubato 0.16 writes INTO existing slots (`AsMut<[T]>`) and uses - // `len()` to know the available space. Reserving capacity isn't enough - // -- we must resize so `len >= out_max`. - for v in &mut self.out_planar { - v.resize(self.out_max, 0.0); - } - let (_in_used, produced) = self .inner .process_into_buffer(&self.in_planar, &mut self.out_planar, None) .map_err(|e| AppError::Stream(format!("resampler process: {e}")))?; - // `produced` is the number of valid output frames per channel; the rest - // of out_planar may be unwritten zero-padding. for i in 0..produced { for c in 0..self.channels { - dst.push(self.out_planar[c][i]); + output[i * self.channels + c] = self.out_planar[c][i]; } } + Ok(produced * self.channels) + } + + pub fn process_chunk(&mut self, interleaved_in: &[f32], dst: &mut Vec) -> AppResult<()> { + let start = dst.len(); + dst.resize(start + self.out_max * self.channels, 0.0); + let written = self.process_chunk_into(interleaved_in, &mut dst[start..])?; + dst.truncate(start + written); Ok(()) } } + +#[cfg(test)] +mod tests { + use super::MultiResampler; + + fn produced_frames(from_rate: u32, to_rate: u32) -> usize { + const CHANNELS: usize = 2; + const CHUNKS: usize = 32; + let mut resampler = MultiResampler::new(from_rate, to_rate, 256, CHANNELS).unwrap(); + let input = vec![0.25_f32; 256 * CHANNELS]; + let mut output = Vec::with_capacity(resampler.out_max() * CHANNELS); + let mut frames = 0; + for _ in 0..CHUNKS { + output.clear(); + resampler.process_chunk(&input, &mut output).unwrap(); + assert_eq!(output.len() % CHANNELS, 0); + frames += output.len() / CHANNELS; + } + frames + } + + #[test] + fn converts_48k_to_44k1() { + let frames = produced_frames(48_000, 44_100); + let expected = 32 * 256 * 44_100 / 48_000; + assert!((frames as isize - expected as isize).unsigned_abs() <= 256); + } + + #[test] + fn converts_44k1_to_48k() { + let frames = produced_frames(44_100, 48_000); + let expected = 32 * 256 * 48_000 / 44_100; + assert!((frames as isize - expected as isize).unsigned_abs() <= 256); + } + + #[test] + fn converts_48k_to_96k() { + let frames = produced_frames(48_000, 96_000); + let expected = 32 * 256 * 96_000 / 48_000; + assert!((frames as isize - expected as isize).unsigned_abs() <= 512); + } + + #[test] + fn process_chunk_into_matches_expected() { + let mut resampler = MultiResampler::new(48_000, 44_100, 256, 2).unwrap(); + let input = vec![0.1_f32; 256 * 2]; + let mut out = vec![0.0_f32; resampler.out_max() * 2]; + let written = resampler.process_chunk_into(&input, &mut out).unwrap(); + assert!(written > 0); + assert_eq!(written % 2, 0); + } +} diff --git a/src-tauri/src/audio/virtual_device/linux.rs b/src-tauri/src/audio/virtual_device/linux.rs index cb2ffefd..3aa26324 100644 --- a/src-tauri/src/audio/virtual_device/linux.rs +++ b/src-tauri/src/audio/virtual_device/linux.rs @@ -76,7 +76,7 @@ pub fn apply_virtual_devices(devices: Vec) -> Result<(), St std::fs::write(&conf, conf_contents(&devices)).map_err(|e| format!("write conf: {e}"))?; for d in &devices { - create_runtime_sink(&d.id, &clean_label(&d.name), d.channels)?; + create_runtime_sink(&d.id, &clean_label(&d.name), d.channels, d.sample_rate)?; } Ok(()) } @@ -100,6 +100,7 @@ fn conf_contents(devices: &[VirtualDeviceConfig]) -> String { " audio.position = [ {} ]\n", positions(d.channels) )); + out.push_str(&format!(" audio.rate = {}\n", d.sample_rate)); out.push_str(" object.linger = true\n"); out.push_str(" }\n"); out.push_str(" }\n"); @@ -144,7 +145,12 @@ fn roundtrip(core: &pw::core::CoreRc, mainloop: &pw::main_loop::MainLoopRc) -> R // Create the sink in the running session so it shows up immediately. The .conf // only takes effect on the next PipeWire start; object.linger keeps the node // alive after we disconnect. -fn create_runtime_sink(id: &str, label: &str, channels: u32) -> Result<(), String> { +fn create_runtime_sink( + id: &str, + label: &str, + channels: u32, + sample_rate: u32, +) -> Result<(), String> { with_session(|core, mainloop| { let mut props = pw::properties::properties! { *pw::keys::FACTORY_NAME => "support.null-audio-sink", @@ -154,6 +160,7 @@ fn create_runtime_sink(id: &str, label: &str, channels: u32) -> Result<(), Strin props.insert(*pw::keys::NODE_NAME, format!("{NODE_PREFIX}.{id}")); props.insert(*pw::keys::NODE_DESCRIPTION, label.to_string()); props.insert("audio.position", positions(channels)); + props.insert("audio.rate", sample_rate.to_string()); let _node: pw::node::Node = core .create_object("adapter", &props) .map_err(|e| format!("create null sink: {e}"))?; diff --git a/src-tauri/src/audio/virtual_device/macos.rs b/src-tauri/src/audio/virtual_device/macos.rs index 2c6a5dff..77d528d7 100644 --- a/src-tauri/src/audio/virtual_device/macos.rs +++ b/src-tauri/src/audio/virtual_device/macos.rs @@ -74,10 +74,11 @@ fn build_plist(devices: &[VirtualDeviceConfig]) -> String { ); for d in devices { plist.push_str(&format!( - "\t\n\t\tid{}\n\t\tname{}\n\t\tchannels{}\n\t\n", + "\t\n\t\tid{}\n\t\tname{}\n\t\tchannels{}\n\t\tsampleRate{}\n\t\n", xml_escape(&d.id), xml_escape(&d.name), - d.channels.clamp(1, 256) + d.channels.clamp(1, 256), + d.sample_rate.clamp(8_000, 384_000) )); } 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 5ee1b3c9..1c4f1e5c 100644 --- a/src-tauri/src/audio/virtual_device/mod.rs +++ b/src-tauri/src/audio/virtual_device/mod.rs @@ -1,17 +1,24 @@ -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] pub struct VirtualDeviceConfig { pub id: String, pub name: String, #[serde(default = "default_channels")] pub channels: u32, + #[serde(default = "default_sample_rate")] + pub sample_rate: u32, } fn default_channels() -> u32 { 2 } +fn default_sample_rate() -> u32 { + 48_000 +} + // Bump with any driver bundle change; keep in sync with Info.plist CFBundleVersion. -pub const DRIVER_VERSION: u32 = 4; +pub const DRIVER_VERSION: u32 = 5; #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -42,3 +49,23 @@ pub use linux::{apply_virtual_devices, install, status, uninstall}; mod windows; #[cfg(target_os = "windows")] pub use windows::{apply_virtual_devices, install, status, uninstall}; + +#[cfg(test)] +mod tests { + use super::VirtualDeviceConfig; + + #[test] + fn deserializes_with_default_sample_rate() { + let json = r#"{"id":"v1","name":"Virtual Mic","channels":2}"#; + let cfg: VirtualDeviceConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.sample_rate, 48_000); + assert_eq!(cfg.channels, 2); + } + + #[test] + fn deserializes_with_custom_sample_rate() { + let json = r#"{"id":"v1","name":"Virtual Mic","channels":2,"sampleRate":96000}"#; + let cfg: VirtualDeviceConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.sample_rate, 96_000); + } +} diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts index a89a62bd..94734884 100644 --- a/src/lib/modules/audio/methods.ts +++ b/src/lib/modules/audio/methods.ts @@ -1,5 +1,6 @@ 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 { AudioApplication, AudioDevice, @@ -53,11 +54,13 @@ export const methods = { }> => 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 }), + startPipeline: (graph: StartPipelinePayload): Promise => + invoke('start_pipeline', { graph: { sampleRate: appSettings.pipelineSampleRate, ...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 }), + reconcilePipeline: (graph: StartPipelinePayload): Promise => + invoke('reconcile_pipeline', { graph: { sampleRate: appSettings.pipelineSampleRate, ...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/types.ts b/src/lib/modules/audio/types.ts index 90a3adc2..80d21ccd 100644 --- a/src/lib/modules/audio/types.ts +++ b/src/lib/modules/audio/types.ts @@ -80,6 +80,7 @@ export interface VirtualDeviceConfig { id: string; name: string; channels: number; + sampleRate?: number; } export type WindowsVirtualCableState = @@ -117,4 +118,5 @@ export type AudioStateEvent = { kind: 'started' } | { kind: 'stopped' } | { kind export interface StartPipelinePayload { nodes: PipelineNode[]; edges: PipelineEdge[]; + sampleRate?: number; } diff --git a/src/lib/modules/settings/stores.svelte.ts b/src/lib/modules/settings/stores.svelte.ts index 3c3feba5..ed397b29 100644 --- a/src/lib/modules/settings/stores.svelte.ts +++ b/src/lib/modules/settings/stores.svelte.ts @@ -11,6 +11,7 @@ interface Stored { launchOnStartup: boolean; confirmOverwriteChanges: boolean; keepRunningOnDisconnect: boolean; + pipelineSampleRate: number; } const DEFAULTS: Stored = { @@ -20,11 +21,13 @@ const DEFAULTS: Stored = { gridSize: 20, launchOnStartup: false, confirmOverwriteChanges: true, - keepRunningOnDisconnect: true + keepRunningOnDisconnect: true, + pipelineSampleRate: 48_000 }; export const SNAPSHOT_LIMITS = [10, 20, 50, 100] as const; export const GRID_SIZES = [10, 20, 40] as const; +export const PIPELINE_SAMPLE_RATE_PRESETS = [44100, 48000, 88200, 96000, 176400, 192000] as const; function load(): Stored { if (!browser) return DEFAULTS; @@ -44,13 +47,32 @@ class AppSettings { launchOnStartup = $state(this.#initial.launchOnStartup); confirmOverwriteChanges = $state(this.#initial.confirmOverwriteChanges); keepRunningOnDisconnect = $state(this.#initial.keepRunningOnDisconnect); + pipelineSampleRate = $state(this.#initial.pipelineSampleRate ?? 48_000); persist(): void { if (!browser) return; - const { checkUpdatesOnLaunch, maxSnapshots, snapToGrid, gridSize, launchOnStartup, confirmOverwriteChanges, keepRunningOnDisconnect } = this; + const { + checkUpdatesOnLaunch, + maxSnapshots, + snapToGrid, + gridSize, + launchOnStartup, + confirmOverwriteChanges, + keepRunningOnDisconnect, + pipelineSampleRate + } = this; window.localStorage.setItem( KEY, - JSON.stringify({ checkUpdatesOnLaunch, maxSnapshots, snapToGrid, gridSize, launchOnStartup, confirmOverwriteChanges, keepRunningOnDisconnect }) + JSON.stringify({ + checkUpdatesOnLaunch, + maxSnapshots, + snapToGrid, + gridSize, + launchOnStartup, + confirmOverwriteChanges, + keepRunningOnDisconnect, + pipelineSampleRate + }) ); } diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index a5f66963..ced6a28b 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -7,7 +7,8 @@ import EdgeShapeIcon from '$lib/modules/flow/ui/_edge_shape_icon.svelte'; import Toggle from '$lib/components/toggle.svelte'; import { themeStore, type ThemePref } from '$lib/modules/theme/stores'; - import { appSettings, GRID_SIZES, SNAPSHOT_LIMITS } from '$lib/modules/settings/stores.svelte'; + import { appSettings, GRID_SIZES, SNAPSHOT_LIMITS, PIPELINE_SAMPLE_RATE_PRESETS } from '$lib/modules/settings/stores.svelte'; + import NumberStepper from '$lib/components/number_stepper.svelte'; import PresetsSection from './_presets_section.svelte'; const SHAPES: { value: EdgeShape; label: string; hint: string }[] = [ @@ -39,10 +40,18 @@ void disableAutostart(); } - function setApp( - key: K, - value: (typeof appSettings)[K] - ) { + let customRateSelected = $state(false); + + function setApp< + K extends + | 'checkUpdatesOnLaunch' + | 'maxSnapshots' + | 'snapToGrid' + | 'gridSize' + | 'confirmOverwriteChanges' + | 'keepRunningOnDisconnect' + | 'pipelineSampleRate' + >(key: K, value: (typeof appSettings)[K]) { appSettings[key] = value; appSettings.persist(); } @@ -214,6 +223,60 @@ onChange={() => setApp('confirmOverwriteChanges', !appSettings.confirmOverwriteChanges)} /> +
+
+

Pipeline sample rate

+

+ Working sample rate for mixing and DSP. Matching this rate across your input and output devices ensures bit-transparent audio with zero + resampling artifacts, preserving low-level details and preventing intersample clipping. +

+
+ +
+ {#each PIPELINE_SAMPLE_RATE_PRESETS as rate (rate)} + + {/each} + +
+ + {#if customRateSelected || !PIPELINE_SAMPLE_RATE_PRESETS.includes(appSettings.pipelineSampleRate as any)} +
+ Custom frequency + setApp('pipelineSampleRate', Math.min(Math.max(Math.round(v) || 48000, 8000), 384000))} /> + Hz (step: 1 Hz) +
+ {/if} +
+

Device disconnection

diff --git a/src/routes/virtual-devices/+page.svelte b/src/routes/virtual-devices/+page.svelte index bcf1a9d2..cc0b4cac 100644 --- a/src/routes/virtual-devices/+page.svelte +++ b/src/routes/virtual-devices/+page.svelte @@ -30,7 +30,13 @@ function sameDevices(a: VirtualDeviceConfig[], b: VirtualDeviceConfig[]): boolean { if (a.length !== b.length) return false; - return a.every((d, i) => d.id === b[i].id && d.name === b[i].name && (d.channels ?? 2) === (b[i].channels ?? 2)); + return a.every( + (d, i) => + d.id === b[i].id && + d.name === b[i].name && + (d.channels ?? 2) === (b[i].channels ?? 2) && + (d.sampleRate ?? 48_000) === (b[i].sampleRate ?? 48_000) + ); } let dirty = $derived(!sameDevices(devices, appliedDevices)); @@ -43,7 +49,7 @@ } function addDevice() { - devices = [...devices, { id: createId(), name: `Device ${devices.length + 1}`, channels: 2 }]; + devices = [...devices, { id: createId(), name: `Device ${devices.length + 1}`, channels: 2, sampleRate: 48_000 }]; } function removeDevice(id: string) { @@ -59,6 +65,11 @@ devices = devices.map((d) => (d.id === id ? { ...d, channels: clamped } : d)); } + function setSampleRate(id: string, sampleRate: number) { + const clamped = Math.min(Math.max(Math.round(sampleRate) || 48_000, 8_000), 384_000); + devices = devices.map((d) => (d.id === id ? { ...d, sampleRate: clamped } : d)); + } + async function apply() { error = null; applying = true; @@ -214,6 +225,34 @@
Appears as input + output · up to 256 channels +
+
+ Sample rate + setSampleRate(d.id, v)} /> + Hz +
+
+ {#each [44100, 48000, 88200, 96000, 192000] as preset (preset)} + + {/each} +
+ Matches project rate to avoid resampling +
{/each} From ffeb5b1877563b04a876fea4cea8a306ad8798ff Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:39:13 +0300 Subject: [PATCH 2/9] feat(flow): show minimalist resample indicator and pipeline sample rate in header --- src-tauri/src/audio/pipeline/mod.rs | 3 ++- src/lib/components/icons/arrow_swap.svelte | 8 ++++++++ src/lib/components/icons/index.ts | 1 + src/lib/modules/audio/ui/index.ts | 1 + .../audio/ui/pipeline_rate_badge.svelte | 17 +++++++++++++++++ .../flow/ui/effect/noise_suppressor.svelte | 19 ++++++++++++++++++- .../flow/ui/effect/webrtc_collaborator.svelte | 9 ++++++++- .../modules/flow/ui/input/app_audio.svelte | 9 ++++++++- .../modules/flow/ui/input/audio_file.svelte | 7 +++++++ .../modules/flow/ui/input/microphone.svelte | 8 +++++++- .../modules/flow/ui/input/net_receiver.svelte | 7 +++++++ .../modules/flow/ui/input/system_audio.svelte | 9 ++++++++- src/lib/modules/flow/ui/node.svelte | 11 +++++++++++ .../flow/ui/output/file_recording.svelte | 10 +++++++++- .../modules/flow/ui/output/net_sender.svelte | 8 +++++++- src/lib/modules/flow/ui/output/speaker.svelte | 8 +++++++- src/routes/pipelines/[id]/+page.svelte | 3 ++- 17 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 src/lib/components/icons/arrow_swap.svelte create mode 100644 src/lib/modules/audio/ui/pipeline_rate_badge.svelte diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index f68bd403..5fc3aa59 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -779,7 +779,8 @@ impl ActivePipeline { .get(&out.id) .map(|o| o.sample_rate()) .unwrap_or(pipeline_sr), - OutputSpec::NetSender { .. } | OutputSpec::WebRtcSend { .. } => pipeline_sr, + OutputSpec::NetSender { .. } => crate::audio::netaudio::SR, + OutputSpec::WebRtcSend { .. } => pipeline_sr, }; let mut my_pairs: Vec<(String, Producer)> = Vec::new(); let cut_leaves = pending_cuts.remove(&out.id).unwrap_or_default(); diff --git a/src/lib/components/icons/arrow_swap.svelte b/src/lib/components/icons/arrow_swap.svelte new file mode 100644 index 00000000..caec7ed8 --- /dev/null +++ b/src/lib/components/icons/arrow_swap.svelte @@ -0,0 +1,8 @@ + + + diff --git a/src/lib/components/icons/index.ts b/src/lib/components/icons/index.ts index bd42380e..899d64c8 100644 --- a/src/lib/components/icons/index.ts +++ b/src/lib/components/icons/index.ts @@ -53,3 +53,4 @@ export { default as Delay } from './delay.svelte'; export { default as Reverb } from './reverb.svelte'; export { default as Balance } from './balance.svelte'; export { default as WindowOff } from './window_off.svelte'; +export { default as ArrowSwap } from './arrow_swap.svelte'; diff --git a/src/lib/modules/audio/ui/index.ts b/src/lib/modules/audio/ui/index.ts index 9740aaa8..d398ea2f 100644 --- a/src/lib/modules/audio/ui/index.ts +++ b/src/lib/modules/audio/ui/index.ts @@ -1,4 +1,5 @@ export { default as ActivationButton } from './activation_button.svelte'; export { default as RunningTimer } from './running_timer.svelte'; export { default as LatencyBadge } from './latency_badge.svelte'; +export { default as PipelineRateBadge } from './pipeline_rate_badge.svelte'; export { default as DriverUpdateBanner } from './driver_update_banner.svelte'; diff --git a/src/lib/modules/audio/ui/pipeline_rate_badge.svelte b/src/lib/modules/audio/ui/pipeline_rate_badge.svelte new file mode 100644 index 00000000..7afb5ce7 --- /dev/null +++ b/src/lib/modules/audio/ui/pipeline_rate_badge.svelte @@ -0,0 +1,17 @@ + + + + + {rateFormatted} + diff --git a/src/lib/modules/flow/ui/effect/noise_suppressor.svelte b/src/lib/modules/flow/ui/effect/noise_suppressor.svelte index 07f3e27e..ec3ddd21 100644 --- a/src/lib/modules/flow/ui/effect/noise_suppressor.svelte +++ b/src/lib/modules/flow/ui/effect/noise_suppressor.svelte @@ -7,6 +7,7 @@ import { PresetBar } from '$lib/modules/preset/ui'; import type { PresetData } from '$lib/modules/preset'; import Slider from './_slider.svelte'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; type NoiseSuppressorNodeType = Node; let { id, data }: NodeProps = $props(); @@ -39,9 +40,25 @@ function threshFmt(v: number): string { return `${Math.round(v)} dB`; } + + let srcTooltip = $derived.by(() => { + if (appSettings.pipelineSampleRate === 48_000) return undefined; + const targetK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; + return `Internal model runs at 48 kHz (resampled from ${targetK} and back)`; + }); - +
diff --git a/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte b/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte index 086f08c9..d2afd45e 100644 --- a/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte +++ b/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte @@ -14,12 +14,19 @@ import { PeopleTeam } from '$lib/components/icons'; import SegmentedButtons from '$lib/components/segmented_buttons.svelte'; import { channelColor, channelLabel, handleEdgeStyle, parseHandle } from '$lib/modules/flow/utils'; + import { formatRate } from '$lib/components/format'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; type WebRtcNodeType = Node; let { id, data }: NodeProps = $props(); const flow = useSvelteFlow(); + let srcTooltip = $derived.by(() => { + if (appSettings.pipelineSampleRate === 48_000) return undefined; + return `WebRTC audio operates at 48 kHz (resampled from ${formatRate(appSettings.pipelineSampleRate)} and back)`; + }); + const MAX_CHANNELS = 255; const wired = useNodeConnections({ id: untrack(() => id), handleType: 'target' }); let channelCount = $derived( @@ -269,7 +276,7 @@ }); - +
diff --git a/src/lib/modules/flow/ui/input/app_audio.svelte b/src/lib/modules/flow/ui/input/app_audio.svelte index 96d7509d..c7166b50 100644 --- a/src/lib/modules/flow/ui/input/app_audio.svelte +++ b/src/lib/modules/flow/ui/input/app_audio.svelte @@ -10,6 +10,7 @@ import { Apps } from '$lib/components/icons'; import { onNodeAction } from '$lib/modules/flow/utils'; import { onDestroy, onMount } from 'svelte'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; type AppAudioNodeType = Node; let { id, data }: NodeProps = $props(); @@ -54,9 +55,15 @@ // App Audio capture is stereo; expose one output handle per channel. const channelCount = 2; + + let srcTooltip = $derived.by(() => { + if (appSettings.pipelineSampleRate === 48_000) return undefined; + const targetK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; + return `Resampling: 48 kHz → ${targetK}`; + }); - +
; let { id, data }: NodeProps = $props(); @@ -190,12 +191,18 @@ } let volumePct = $derived((data.volume ?? 1) * 100); + + let srcTooltip = $derived.by(() => { + if (sampleRate <= 0 || sampleRate === appSettings.pipelineSampleRate) return undefined; + return `Resampling: ${formatRate(sampleRate)} → ${formatRate(appSettings.pipelineSampleRate)}`; + }); { + if (!info || info.sampleRate === appSettings.pipelineSampleRate) return undefined; + return `Resampling: ${formatRate(info.sampleRate)} → ${formatRate(appSettings.pipelineSampleRate)}`; + }); - +
refresh()}> {#snippet footer(close)} diff --git a/src/lib/modules/flow/ui/input/net_receiver.svelte b/src/lib/modules/flow/ui/input/net_receiver.svelte index df318b33..3268dd8a 100644 --- a/src/lib/modules/flow/ui/input/net_receiver.svelte +++ b/src/lib/modules/flow/ui/input/net_receiver.svelte @@ -8,12 +8,18 @@ import Wrapper from '../node.svelte'; import { ArrowDownload } from '$lib/components/icons'; import { parseHandle } from '$lib/modules/flow/utils'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; type NetReceiverNodeType = Node; let { id, data }: NodeProps = $props(); const flow = useSvelteFlow(); + let srcTooltip = $derived.by(() => { + if (appSettings.pipelineSampleRate === 48_000) return undefined; + return `Resampling: 48 kHz → ${formatRate(appSettings.pipelineSampleRate)}`; + }); + let loss = $state(null); let rate = $state(0); // bytes/sec // Jitter buffer depth: the latency this node adds, which the receive path @@ -83,6 +89,7 @@ label="Net Receiver" icon={ArrowDownload} accent="network" + {srcTooltip} hasOutput channelIo nodeId={id} diff --git a/src/lib/modules/flow/ui/input/system_audio.svelte b/src/lib/modules/flow/ui/input/system_audio.svelte index 898f1871..7c0f5912 100644 --- a/src/lib/modules/flow/ui/input/system_audio.svelte +++ b/src/lib/modules/flow/ui/input/system_audio.svelte @@ -12,6 +12,7 @@ import Slider from '../effect/_slider.svelte'; import { SoundWave } from '$lib/components/icons'; import { platform } from '@tauri-apps/plugin-os'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; // Self-exclusion is macOS-only; Linux (PipeWire) and Windows (WASAPI // loopback) need it neither. @@ -70,9 +71,15 @@ // System Audio capture is stereo; expose one output handle per channel. const channelCount = 2; + + let srcTooltip = $derived.by(() => { + if (appSettings.pipelineSampleRate === 48_000) return undefined; + const targetK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; + return `Resampling: 48 kHz → ${targetK}`; + }); - +
{#if showBanner}
; badge?: Snippet; + srcTooltip?: string; hasInput?: boolean; hasOutput?: boolean; inputs?: InputHandleConfig[]; @@ -43,6 +46,7 @@ accent = 'effect', icon: NodeIcon, badge, + srcTooltip, hasInput = false, hasOutput = false, inputs, @@ -81,6 +85,13 @@ {/if} {label} + {#if srcTooltip} + + + + + + {/if}
{#if badge}{@render badge()}{/if} diff --git a/src/lib/modules/flow/ui/output/file_recording.svelte b/src/lib/modules/flow/ui/output/file_recording.svelte index ac0ae015..2942023f 100644 --- a/src/lib/modules/flow/ui/output/file_recording.svelte +++ b/src/lib/modules/flow/ui/output/file_recording.svelte @@ -658,9 +658,17 @@ function toggleWaveform() { flow.updateNodeData(id, { waveformHidden: !(data.waveformHidden ?? false) }); } + + let targetSampleRate = $derived(data.format.kind === 'opus' || data.format.kind === 'mp3' ? 48_000 : (data.sampleRate ?? 48_000)); + let srcTooltip = $derived.by(() => { + if (targetSampleRate === appSettings.pipelineSampleRate) return undefined; + const pK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; + const tK = targetSampleRate >= 1000 ? `${targetSampleRate / 1000} kHz` : `${targetSampleRate} Hz`; + return `Resampling: ${pK} → ${tK}`; + }); - +
{basename(data.filePath)} diff --git a/src/lib/modules/flow/ui/output/net_sender.svelte b/src/lib/modules/flow/ui/output/net_sender.svelte index 54cb3438..670d0830 100644 --- a/src/lib/modules/flow/ui/output/net_sender.svelte +++ b/src/lib/modules/flow/ui/output/net_sender.svelte @@ -8,12 +8,18 @@ import Wrapper from '../node.svelte'; import { ArrowUpload } from '$lib/components/icons'; import SegmentedButtons from '$lib/components/segmented_buttons.svelte'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; type NetSenderNodeType = Node; let { id, data }: NodeProps = $props(); const flow = useSvelteFlow(); + let srcTooltip = $derived.by(() => { + if (appSettings.pipelineSampleRate === 48_000) return undefined; + return `Resampling: ${formatRate(appSettings.pipelineSampleRate)} → 48 kHz`; + }); + let rate = $state(0); // bytes/sec let prevBytes = 0; let prevAt = 0; @@ -91,7 +97,7 @@ } - +
diff --git a/src/lib/modules/flow/ui/output/speaker.svelte b/src/lib/modules/flow/ui/output/speaker.svelte index 550dfd7c..fb91de44 100644 --- a/src/lib/modules/flow/ui/output/speaker.svelte +++ b/src/lib/modules/flow/ui/output/speaker.svelte @@ -14,6 +14,7 @@ import { onNodeAction } from '$lib/modules/flow/utils'; import { onDestroy, onMount } from 'svelte'; import { platform } from '@tauri-apps/plugin-os'; + import { appSettings } from '$lib/modules/settings/stores.svelte'; const isWindows = platform() === 'windows'; const virtualDevicesLabel = isWindows ? 'Use virtual microphone' : 'Add virtual device'; @@ -87,9 +88,14 @@ let meterOffsetDb = $derived(volume.db ?? 0); let channelCount = $derived(Math.max(info?.channels ?? 2, 1)); + + let srcTooltip = $derived.by(() => { + if (!info || info.sampleRate === appSettings.pipelineSampleRate) return undefined; + return `Resampling: ${formatRate(appSettings.pipelineSampleRate)} → ${formatRate(info.sampleRate)}`; + }); - +
refresh()}> {#snippet footer(close)} diff --git a/src/routes/pipelines/[id]/+page.svelte b/src/routes/pipelines/[id]/+page.svelte index f021bc45..c322bfca 100644 --- a/src/routes/pipelines/[id]/+page.svelte +++ b/src/routes/pipelines/[id]/+page.svelte @@ -4,7 +4,7 @@ import { methods as pipelineMethods } from '$lib/modules/pipeline/methods'; import { pipelineStore } from '$lib/modules/pipeline/stores.svelte'; import { audioStore } from '$lib/modules/audio/stores.svelte'; - import { ActivationButton, LatencyBadge, RunningTimer } from '$lib/modules/audio/ui'; + import { ActivationButton, LatencyBadge, PipelineRateBadge, RunningTimer } from '$lib/modules/audio/ui'; import Header from '$lib/components/layout/header.svelte'; import Flow from '$lib/modules/flow'; import { SnapshotHistory, SavedIndicator, UndoRedo } from '$lib/modules/flow/ui'; @@ -105,6 +105,7 @@ {/if} + {#if pipeline} From 50d1c2d788860c7752282f9a91a17a8262c162fb Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:47:18 +0300 Subject: [PATCH 3/9] feat(netaudio): configurable sample rate, format detection, and node info styling --- src-tauri/src/audio/graph.rs | 4 + src-tauri/src/audio/netaudio/packet.rs | 70 +++++++++++++-- src-tauri/src/audio/netaudio/receiver.rs | 90 +++++++++++++++---- src-tauri/src/audio/netaudio/sender.rs | 36 +++++++- src-tauri/src/audio/pipeline/dag.rs | 1 + src-tauri/src/audio/pipeline/mod.rs | 34 ++++++- src-tauri/src/audio/pipeline/output/mod.rs | 24 +++-- src-tauri/src/audio/stream_recv.rs | 64 +++++++++++-- src-tauri/src/audio/webrtc/tasks.rs | 15 +++- src-tauri/src/commands.rs | 37 ++++++-- src/lib/components/format.ts | 7 ++ src/lib/modules/audio/methods.ts | 4 + .../audio/ui/pipeline_rate_badge.svelte | 7 +- .../flow/ui/effect/webrtc_collaborator.svelte | 4 +- .../modules/flow/ui/input/app_audio.svelte | 14 ++- .../modules/flow/ui/input/microphone.svelte | 10 +-- .../modules/flow/ui/input/net_receiver.svelte | 70 +++++++++++++-- .../modules/flow/ui/input/system_audio.svelte | 6 +- .../flow/ui/output/file_recording.svelte | 4 +- .../modules/flow/ui/output/net_sender.svelte | 80 ++++++++++++++++- src/lib/modules/flow/ui/output/speaker.svelte | 10 +-- src/lib/modules/pipeline/defaults.ts | 3 +- .../pipeline/generated/NetSenderData.ts | 2 +- 23 files changed, 500 insertions(+), 96 deletions(-) diff --git a/src-tauri/src/audio/graph.rs b/src-tauri/src/audio/graph.rs index 21793fbd..5e3f17c6 100644 --- a/src-tauri/src/audio/graph.rs +++ b/src-tauri/src/audio/graph.rs @@ -518,6 +518,8 @@ pub struct NetSenderData { pub codec: NetCodec, pub opus_bitrate: u32, pub opus_application: OpusApplication, + #[serde(default)] + pub sample_rate: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, TS)] @@ -598,6 +600,7 @@ pub enum OutputSpec { codec: NetCodec, opus_bitrate: u32, opus_application: OpusApplication, + sample_rate: Option, }, /// Send half of a WebRTC collaborator: per-channel audio handed to the /// session's encode task. The wire codec is set by the UI, not the graph. @@ -1106,6 +1109,7 @@ fn resolve_outputs( codec: data.codec, opus_bitrate: data.opus_bitrate, opus_application: data.opus_application, + sample_rate: data.sample_rate.filter(|_| data.codec != NetCodec::Opus), } } // Send half of a collaborator: audio wired in goes to peers, diff --git a/src-tauri/src/audio/netaudio/packet.rs b/src-tauri/src/audio/netaudio/packet.rs index d785d17e..ff7d398e 100644 --- a/src-tauri/src/audio/netaudio/packet.rs +++ b/src-tauri/src/audio/netaudio/packet.rs @@ -35,31 +35,83 @@ impl Format { } } +pub const HEADER_LEN_EXT: usize = 12; + pub struct Parsed<'a> { pub format: Format, pub channel: u8, pub seq: u16, + pub sample_rate: u32, + pub opus_bitrate_kbps: Option, + pub opus_app: Option, pub payload: &'a [u8], } -/// Writes the header into `buf` (cleared first); the caller appends the payload. -pub fn write_header(buf: &mut Vec, format: Format, channel: u8, seq: u16) { +/// Writes the extended self-describing header into `buf` (cleared first); the caller appends the payload. +pub fn write_header( + buf: &mut Vec, + format: Format, + channel: u8, + seq: u16, + sample_rate: u32, + opus_bitrate_kbps: u16, + opus_app: u8, +) { buf.clear(); - buf.push(format.to_byte()); + buf.push(0x80 | format.to_byte()); buf.push(channel); buf.extend_from_slice(&seq.to_be_bytes()); + buf.extend_from_slice(&sample_rate.to_be_bytes()); + buf.extend_from_slice(&opus_bitrate_kbps.to_be_bytes()); + buf.push(opus_app); + buf.push(0); // reserved } pub fn parse(data: &[u8]) -> Option> { if data.len() < HEADER_LEN { return None; } - Some(Parsed { - format: Format::from_byte(data[0])?, - channel: data[1], - seq: u16::from_be_bytes([data[2], data[3]]), - payload: &data[HEADER_LEN..], - }) + let b0 = data[0]; + if b0 & 0x80 != 0 { + if data.len() < 8 { + return None; + } + let format = Format::from_byte(b0 & 0x7F)?; + let channel = data[1]; + let seq = u16::from_be_bytes([data[2], data[3]]); + let sample_rate = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); + let (opus_bitrate_kbps, opus_app, header_len) = if data.len() >= HEADER_LEN_EXT { + let kbps = u16::from_be_bytes([data[8], data[9]]); + let app = data[10]; + let kbps_opt = if kbps > 0 { Some(kbps) } else { None }; + let app_opt = if app > 0 { Some(app) } else { None }; + (kbps_opt, app_opt, HEADER_LEN_EXT) + } else { + (None, None, 8) + }; + Some(Parsed { + format, + channel, + seq, + sample_rate, + opus_bitrate_kbps, + opus_app, + payload: &data[header_len..], + }) + } else { + let format = Format::from_byte(b0)?; + let channel = data[1]; + let seq = u16::from_be_bytes([data[2], data[3]]); + Some(Parsed { + format, + channel, + seq, + sample_rate: 48_000, + opus_bitrate_kbps: None, + opus_app: None, + payload: &data[HEADER_LEN..], + }) + } } /// Interleaved f32 samples -> little-endian bytes. diff --git a/src-tauri/src/audio/netaudio/receiver.rs b/src-tauri/src/audio/netaudio/receiver.rs index 89e5f958..a72cb32b 100644 --- a/src-tauri/src/audio/netaudio/receiver.rs +++ b/src-tauri/src/audio/netaudio/receiver.rs @@ -3,13 +3,13 @@ //! decoded to 48 kHz and fanned out to every output subgraph via `FanoutRegistry`. use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use tokio::net::UdpSocket; use tracing::{info, warn}; -use crate::audio::stream_recv::{broadcast_push, ChannelBroadcast, ConsumerHandle, FanoutRegistry}; +use crate::audio::stream_recv::{ChannelBroadcast, ConsumerHandle, FanoutRegistry}; use super::codec::ChannelDecoder; use super::packet; @@ -30,14 +30,26 @@ pub struct NetReceiver { bytes: AtomicU64, packets: AtomicU64, lost: AtomicU64, + sample_rate: AtomicU32, + format: AtomicU32, + opus_bitrate: AtomicU32, + opus_app: AtomicU32, } -/// `(bytes, packets, lost, channels, buffer_ms)` since this receiver bound its -/// socket. `channels` is the highest wire index seen plus one, so the UI can -/// grow its handles to whatever the sender actually transmits; `buffer_ms` is -/// the jitter buffer depth the network currently forces, and so the latency -/// this node adds. -pub fn stats(node_id: &str) -> Option<(u64, u64, u64, u32, u32)> { +pub struct ReceiverStatsSnapshot { + pub bytes: u64, + pub packets: u64, + pub lost: u64, + pub channels: u32, + pub buffer_ms: u32, + pub sample_rate: u32, + pub format: Option, + pub opus_bitrate: Option, + pub opus_app: Option, +} + +/// Cumulative and link stats since this receiver bound its socket. +pub fn stats(node_id: &str) -> Option { let reg = registry().lock().unwrap(); reg.get(node_id).map(|r| { let channels = r @@ -48,18 +60,46 @@ pub fn stats(node_id: &str) -> Option<(u64, u64, u64, u32, u32)> { .max() .map(|&i| i as u32 + 1) .unwrap_or(0); + let sample_rate = r.sample_rate.load(Ordering::Relaxed); + let sr = if sample_rate > 0 { + sample_rate + } else { + super::SR + }; let buffer_ms = r .fanout .buffer_depth() - .map(|samples| samples * 1000 / super::SR) + .map(|samples| samples * 1000 / sr) .unwrap_or(0); - ( - r.bytes.load(Ordering::Relaxed), - r.packets.load(Ordering::Relaxed), - r.lost.load(Ordering::Relaxed), + let fmt_raw = r.format.load(Ordering::Relaxed); + let format = if fmt_raw <= 2 { + packet::Format::from_byte(fmt_raw as u8) + } else { + None + }; + let opus_bitrate_raw = r.opus_bitrate.load(Ordering::Relaxed); + let opus_bitrate = if opus_bitrate_raw > 0 { + Some(opus_bitrate_raw) + } else { + None + }; + let opus_app_raw = r.opus_app.load(Ordering::Relaxed); + let opus_app = if opus_app_raw > 0 { + Some(opus_app_raw as u8) + } else { + None + }; + ReceiverStatsSnapshot { + bytes: r.bytes.load(Ordering::Relaxed), + packets: r.packets.load(Ordering::Relaxed), + lost: r.lost.load(Ordering::Relaxed), channels, buffer_ms, - ) + sample_rate, + format, + opus_bitrate, + opus_app, + } }) } @@ -97,6 +137,10 @@ pub fn get_or_create(node_id: &str, port: u16) -> Arc { bytes: AtomicU64::new(0), packets: AtomicU64::new(0), lost: AtomicU64::new(0), + sample_rate: AtomicU32::new(48_000), + format: AtomicU32::new(u32::MAX), + opus_bitrate: AtomicU32::new(0), + opus_app: AtomicU32::new(0), }); receiver.clone().spawn_recv(); reg.insert(node_id.to_string(), receiver.clone()); @@ -142,6 +186,16 @@ impl NetReceiver { }; self.bytes.fetch_add(n as u64, Ordering::Relaxed); self.packets.fetch_add(1, Ordering::Relaxed); + self.sample_rate.store(pkt.sample_rate, Ordering::Relaxed); + self.format + .store(pkt.format.to_byte() as u32, Ordering::Relaxed); + if let Some(kbps) = pkt.opus_bitrate_kbps { + self.opus_bitrate + .store(kbps as u32 * 1000, Ordering::Relaxed); + } + 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); match step { @@ -171,7 +225,13 @@ impl NetReceiver { decoder.decode(pkt.format, pkt.payload, &mut pcm); } if !pcm.is_empty() { - broadcast_push(&channel.broadcast, pkt.seq, packets, &pcm); + crate::audio::stream_recv::broadcast_push_sr( + &channel.broadcast, + pkt.seq, + packets, + &pcm, + pkt.sample_rate, + ); } } } diff --git a/src-tauri/src/audio/netaudio/sender.rs b/src-tauri/src/audio/netaudio/sender.rs index 42037019..d92bb078 100644 --- a/src-tauri/src/audio/netaudio/sender.rs +++ b/src-tauri/src/audio/netaudio/sender.rs @@ -27,6 +27,7 @@ struct Config { format: Format, opus_bitrate: u32, opus_application: OpusApplication, + sample_rate: u32, } pub struct NetSender { @@ -57,20 +58,30 @@ fn registry() -> &'static Mutex>> { REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } +/// Drops a sender and frees its socket/task. +pub fn release(node_id: &str) { + let mut reg = registry().lock().unwrap(); + if let Some(s) = reg.remove(node_id) { + s.stop(); + } +} + /// Returns the sender for `node_id`, binding the send socket on first use. A -/// config change (target / codec / bitrate) tears the old task down and rebuilds. +/// config change (target / codec / bitrate / sample rate) tears the old task down and rebuilds. pub fn get_or_create( node_id: &str, target: SocketAddr, format: Format, opus_bitrate: u32, opus_application: OpusApplication, + sample_rate: u32, ) -> Arc { let config = Config { target, format, opus_bitrate, opus_application, + sample_rate, }; let mut reg = registry().lock().unwrap(); if let Some(s) = reg.get(node_id) { @@ -181,12 +192,31 @@ 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) + } else { + (0, 0) + }; for i in 0..encoders.len() { let channel = i as u8; let seq = &mut seqs[i]; encoders[i].push(&ins[i], |payload| { - let mut d = Vec::with_capacity(packet::HEADER_LEN + payload.len()); - packet::write_header(&mut d, format, channel, *seq); + let mut d = Vec::with_capacity(packet::HEADER_LEN_EXT + payload.len()); + packet::write_header( + &mut d, + format, + channel, + *seq, + sample_rate, + opus_bitrate_kbps, + opus_app_byte, + ); *seq = seq.wrapping_add(1); d.extend_from_slice(payload); packets.push(d); diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 962b28b1..5d430aa1 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -1527,6 +1527,7 @@ pub(super) fn build_output_graph( format, *opus_bitrate, *opus_application, + output_sr, ); sender.set_send_consumers(send_consumers); } diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index 5fc3aa59..988c3738 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -23,7 +23,9 @@ use tracing::{info, warn}; use crate::audio::effects::{ EffectControl, EffectRegistry, GrHandle, LufsHandle, MeterHandle, WaveformHandle, }; -use crate::audio::graph::{EffectSpec, InputSpec, OutputSpec, RecordingFormat, ValidGraph}; +use crate::audio::graph::{ + EffectSpec, InputSpec, NetCodec, OutputSpec, RecordingFormat, ValidGraph, +}; use crate::audio::input_bridge::{broadcast_channel, BroadcastTx, CaptureStats}; use crate::error::{AppError, AppResult}; @@ -301,6 +303,18 @@ impl ActivePipeline { } fn teardown(&mut self) { + if let Some(current) = &self.current { + for inp in ¤t.inputs { + if matches!(inp.spec, InputSpec::NetReceiver { .. }) { + crate::audio::netaudio::receiver::release(&inp.id); + } + } + for out in ¤t.outputs { + if matches!(out.spec, OutputSpec::NetSender { .. }) { + crate::audio::netaudio::sender::release(&out.id); + } + } + } self.tear_down_outputs(); self.stale_bridges.clear(); self.inputs.clear(); @@ -442,6 +456,7 @@ impl ActivePipeline { drop(state); } else if let Some(state) = self.wire_senders.remove(id) { state.worker.stop.store(true, Ordering::SeqCst); + crate::audio::netaudio::sender::release(id); drop(state); } else { self.speakers.remove(id); @@ -487,6 +502,11 @@ impl ActivePipeline { .cloned() .collect(); for id in to_drop { + if let Some(spec) = old_input_specs.get(id.as_str()) { + if matches!(spec, InputSpec::NetReceiver { .. }) { + crate::audio::netaudio::receiver::release(&id); + } + } self.inputs.remove(&id); self.meters.remove(&id); } @@ -779,7 +799,15 @@ impl ActivePipeline { .get(&out.id) .map(|o| o.sample_rate()) .unwrap_or(pipeline_sr), - OutputSpec::NetSender { .. } => crate::audio::netaudio::SR, + OutputSpec::NetSender { + codec, sample_rate, .. + } => { + if *codec == NetCodec::Opus { + crate::audio::netaudio::SR + } else { + sample_rate.unwrap_or(pipeline_sr) + } + } OutputSpec::WebRtcSend { .. } => pipeline_sr, }; let mut my_pairs: Vec<(String, Producer)> = Vec::new(); @@ -1156,7 +1184,7 @@ impl ActivePipeline { }, ); } - ResolvedOutput::WireSender => { + ResolvedOutput::WireSender(_) => { let sample_rate = og.sample_rate(); if let Some(state) = self.wire_senders.get_mut(&out.id) { state.ctrl.send_graph(og)?; diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index c7c70602..75e06178 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -15,7 +15,7 @@ use tracing::{info, warn}; use crate::audio::clock::{ClockSource, DeviceFillClock, SystemClockTicker}; use crate::audio::effects::{update_meter, MeterHandle, WaveformHandle}; use crate::audio::encoders::{build_encoder, validate_append_target, AudioEncoder}; -use crate::audio::graph::{OutputSpec, RecordingFormat, RecordingMode, ValidOutput}; +use crate::audio::graph::{NetCodec, OutputSpec, RecordingFormat, RecordingMode, ValidOutput}; use crate::audio::resample::MultiResampler; use crate::audio::streams; use crate::error::{AppError, AppResult}; @@ -75,10 +75,10 @@ pub(super) enum ResolvedOutput { /// from the file's current length instead of zero. base_frames: u64, }, - // The DAG produces at 48 kHz; the send rings are wired inside + // The DAG produces at its configured rate; the send rings are wired inside // `build_output_graph`, so nothing device-specific to resolve here. Covers // both direct-IP and WebRTC senders. - WireSender, + WireSender(u32), } impl ResolvedOutput { @@ -86,7 +86,7 @@ impl ResolvedOutput { match self { ResolvedOutput::Speaker(s) => s.sample_rate, ResolvedOutput::File { sample_rate, .. } => *sample_rate, - ResolvedOutput::WireSender => crate::audio::netaudio::SR, + ResolvedOutput::WireSender(sr) => *sr, } } } @@ -167,9 +167,21 @@ pub(super) fn resolve_output( base_frames, }) } - OutputSpec::NetSender { .. } | OutputSpec::WebRtcSend { .. } => { - Ok(ResolvedOutput::WireSender) + OutputSpec::NetSender { + codec, sample_rate, .. + } => { + let sr = if *codec == NetCodec::Opus { + crate::audio::netaudio::SR + } else { + sample_rate + .or(file_sr_hint) + .unwrap_or(crate::audio::netaudio::SR) + }; + Ok(ResolvedOutput::WireSender(sr)) } + OutputSpec::WebRtcSend { .. } => Ok(ResolvedOutput::WireSender( + file_sr_hint.unwrap_or(crate::audio::netaudio::SR), + )), } } diff --git a/src-tauri/src/audio/stream_recv.rs b/src-tauri/src/audio/stream_recv.rs index fb2555a9..325705e2 100644 --- a/src-tauri/src/audio/stream_recv.rs +++ b/src-tauri/src/audio/stream_recv.rs @@ -91,6 +91,7 @@ pub struct ChannelFeed { sync: Arc>, prods: Mutex>>, state: Mutex, + pub sample_rate: Arc, } #[derive(Default)] @@ -122,10 +123,21 @@ pub struct ConsumerHandle { /// Push one channel's audio for the `packets` packets ending at `seq` (a lost /// packet is carried as its concealment, so a push always covers whole packets). -/// Tracking where the samples sit on the timeline -- rather than when they -/// arrived -- is what keeps a channel wired in mid-stream in phase with its -/// siblings. pub fn broadcast_push(broadcast: &ChannelBroadcast, seq: u16, packets: u16, samples: &[f32]) { + broadcast_push_sr(broadcast, seq, packets, samples, SR); +} + +/// Push one channel's audio with the sample rate the packet carried. +pub fn broadcast_push_sr( + broadcast: &ChannelBroadcast, + seq: u16, + packets: u16, + samples: &[f32], + sample_rate: u32, +) { + if sample_rate > 0 { + broadcast.sample_rate.store(sample_rate, Ordering::Relaxed); + } let _sync = broadcast.sync.lock().unwrap(); let mut prods = broadcast.prods.lock().unwrap(); prods.retain(|p| !p.is_abandoned()); @@ -154,12 +166,15 @@ fn group_id(key: &str) -> u64 { h } -/// One channel's playback state for one consumer: a 48 kHz jitter ring plus a -/// fixed-output resampler (48 kHz -> consumer rate) whose ratio tracks drift. +/// One channel's playback state for one consumer: a jitter ring plus a +/// fixed-output resampler (source rate -> consumer rate) whose ratio tracks drift. pub struct PlaybackTap { group: u64, consumer: Consumer, resampler: MultiResamplerOut, + rate: u32, + current_source_sr: u32, + source_sr: Arc, base_ratio: f64, last_ratio: f64, realtime: bool, @@ -191,14 +206,19 @@ impl PlaybackTap { realtime: bool, primed: bool, drift: Arc, + source_sr: Arc, ) -> Self { - let base_ratio = rate as f64 / SR as f64; + let in_sr = source_sr.load(Ordering::Relaxed).max(1); + let base_ratio = rate as f64 / in_sr as f64; let resampler = - MultiResamplerOut::new(SR, rate, OUT_BLOCK_FRAMES, 1).expect("mono resampler init"); + MultiResamplerOut::new(in_sr, rate, OUT_BLOCK_FRAMES, 1).expect("mono resampler init"); Self { group, consumer, resampler, + rate, + current_source_sr: in_sr, + source_sr, base_ratio, last_ratio: base_ratio, realtime, @@ -273,6 +293,15 @@ impl PlaybackTap { /// 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 { + 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) { + self.resampler = r; + } + } // Track drift ratio for this block. if self.realtime { let d = f32::from_bits(self.drift.load(Ordering::Relaxed)) as f64; @@ -430,7 +459,15 @@ impl FanoutRegistry { bc.prods.lock().unwrap().push(prod); map.lock().unwrap().insert( key.clone(), - PlaybackTap::new(gid, cons, output_sr, realtime, false, drift.clone()), + PlaybackTap::new( + gid, + cons, + output_sr, + realtime, + false, + drift.clone(), + bc.sample_rate.clone(), + ), ); } } @@ -460,6 +497,7 @@ impl FanoutRegistry { sync: sync.clone(), prods: Mutex::new(Vec::new()), state: Mutex::new(FeedState::default()), + sample_rate: Arc::new(AtomicU32::new(SR)), }); let mut consumers = self.consumers.lock().unwrap(); consumers.retain(|c| c.taps.strong_count() > 0); @@ -506,7 +544,15 @@ impl FanoutRegistry { bc.prods.lock().unwrap().push(prod); taps.insert( key.clone(), - PlaybackTap::new(gid, cons, c.rate, c.realtime, primed, c.drift.clone()), + PlaybackTap::new( + gid, + cons, + c.rate, + c.realtime, + primed, + c.drift.clone(), + bc.sample_rate.clone(), + ), ); } } diff --git a/src-tauri/src/audio/webrtc/tasks.rs b/src-tauri/src/audio/webrtc/tasks.rs index a9c5de3c..462ffdc3 100644 --- a/src-tauri/src/audio/webrtc/tasks.rs +++ b/src-tauri/src/audio/webrtc/tasks.rs @@ -155,8 +155,16 @@ pub fn spawn_encode_task(session: Arc) { 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 + payload.len()); - packet::write_header(&mut d, format, channel, *seq); + let mut d = Vec::with_capacity(packet::HEADER_LEN_EXT + 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)); @@ -201,7 +209,8 @@ 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 payload = data.slice(packet::HEADER_LEN..); + let header_len = data.len() - pkt.payload.len(); + let payload = data.slice(header_len..); let peer = { let peers = session.peers.lock().await; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 10c4cd41..17ce6e24 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -687,6 +687,10 @@ pub struct NetReceiverStats { pub lost: u64, pub channels: u32, pub buffer_ms: u32, + pub sample_rate: u32, + pub format: Option, + pub opus_bitrate: Option, + pub opus_app: Option, } /// The DAG only binds receivers reachable from an output, so an unrouted node @@ -705,15 +709,30 @@ pub fn net_receiver_release(node_id: String) { /// (windowed into a recent loss ratio / rate on the frontend). #[tauri::command] pub fn net_receiver_stats(node_id: String) -> Option { - crate::audio::netaudio::receiver::stats(&node_id).map( - |(bytes, packets, lost, channels, buffer_ms)| NetReceiverStats { - bytes, - packets, - lost, - channels, - buffer_ms, - }, - ) + crate::audio::netaudio::receiver::stats(&node_id).map(|s| { + let format_str = s.format.map(|f| match f { + crate::audio::netaudio::packet::Format::PcmF32 => "pcm-f32".to_string(), + crate::audio::netaudio::packet::Format::PcmI16 => "pcm-i16".to_string(), + crate::audio::netaudio::packet::Format::Opus => "opus".to_string(), + }); + let opus_app_str = s.opus_app.and_then(|a| match a { + 1 => Some("voip".to_string()), + 2 => Some("audio".to_string()), + 3 => Some("low-delay".to_string()), + _ => None, + }); + NetReceiverStats { + bytes: s.bytes, + packets: s.packets, + lost: s.lost, + channels: s.channels, + buffer_ms: s.buffer_ms, + sample_rate: s.sample_rate, + format: format_str, + opus_bitrate: s.opus_bitrate, + opus_app: opus_app_str, + } + }) } #[derive(serde::Serialize)] diff --git a/src/lib/components/format.ts b/src/lib/components/format.ts index 23765023..66d0ca9f 100644 --- a/src/lib/components/format.ts +++ b/src/lib/components/format.ts @@ -45,3 +45,10 @@ export function formatRate(bytesPerSec: number): string { if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} kB/s`; return `${(bytesPerSec / (1024 * 1024)).toFixed(2)} MB/s`; } + +/** Human-readable audio sample rate / frequency, e.g. `48 kHz`, `44.1 kHz`, `96 kHz`, `440 Hz`. */ +export function formatHz(hz: number): string { + if (hz < 1000) return `${hz} Hz`; + const k = hz / 1000; + return `${Number(k.toFixed(k % 1 === 0 ? 0 : 1))} kHz`; +} diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts index 94734884..4ab70c43 100644 --- a/src/lib/modules/audio/methods.ts +++ b/src/lib/modules/audio/methods.ts @@ -149,6 +149,10 @@ export const methods = { lost: number; channels: number; bufferMs: number; + sampleRate: number; + format: 'pcm-f32' | 'pcm-i16' | 'opus' | null; + opusBitrate: number | null; + opusApp: 'voip' | 'audio' | 'low-delay' | null; } | null> => invoke('net_receiver_stats', { nodeId }), /** Direct-IP send stats, or null when the node isn't running. */ netSenderStats: (nodeId: string): Promise<{ bytes: number; packets: number } | null> => invoke('net_sender_stats', { nodeId }), diff --git a/src/lib/modules/audio/ui/pipeline_rate_badge.svelte b/src/lib/modules/audio/ui/pipeline_rate_badge.svelte index 7afb5ce7..43bfbb02 100644 --- a/src/lib/modules/audio/ui/pipeline_rate_badge.svelte +++ b/src/lib/modules/audio/ui/pipeline_rate_badge.svelte @@ -1,12 +1,9 @@ ; @@ -24,7 +24,7 @@ let srcTooltip = $derived.by(() => { if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `WebRTC audio operates at 48 kHz (resampled from ${formatRate(appSettings.pipelineSampleRate)} and back)`; + return `WebRTC audio operates at 48 kHz (resampled from ${formatHz(appSettings.pipelineSampleRate)} and back)`; }); const MAX_CHANNELS = 255; diff --git a/src/lib/modules/flow/ui/input/app_audio.svelte b/src/lib/modules/flow/ui/input/app_audio.svelte index c7166b50..8fb366a5 100644 --- a/src/lib/modules/flow/ui/input/app_audio.svelte +++ b/src/lib/modules/flow/ui/input/app_audio.svelte @@ -11,6 +11,7 @@ import { onNodeAction } from '$lib/modules/flow/utils'; import { onDestroy, onMount } from 'svelte'; import { appSettings } from '$lib/modules/settings/stores.svelte'; + import { formatHz } from '$lib/components/format'; type AppAudioNodeType = Node; let { id, data }: NodeProps = $props(); @@ -28,9 +29,13 @@ let unlistenRefresh: (() => void) | undefined; onMount(() => { - unlistenRefresh = onNodeAction(id, 'refresh', () => refresh()); + unlistenRefresh = onNodeAction(id, 'refresh', () => { + refresh().catch(() => {}); + }); + }); + onDestroy(() => { + unlistenRefresh?.(); }); - onDestroy(() => unlistenRefresh?.()); let options = $derived( audioStore.audioApplications.map((a) => ({ @@ -58,8 +63,7 @@ let srcTooltip = $derived.by(() => { if (appSettings.pipelineSampleRate === 48_000) return undefined; - const targetK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; - return `Resampling: 48 kHz → ${targetK}`; + return `Resampling: 48 kHz → ${formatHz(appSettings.pipelineSampleRate)}`; }); @@ -79,6 +83,8 @@ {#if missing} App no longer running + {:else if data.bundleId} + 48 kHz · 2 ch · f32 {/if} {#if data.bundleId && !missing} diff --git a/src/lib/modules/flow/ui/input/microphone.svelte b/src/lib/modules/flow/ui/input/microphone.svelte index 4a31a78f..6ca6e457 100644 --- a/src/lib/modules/flow/ui/input/microphone.svelte +++ b/src/lib/modules/flow/ui/input/microphone.svelte @@ -70,9 +70,7 @@ await gain.set(pct / 100); } - function formatRate(hz: number): string { - return hz >= 1000 ? `${(hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1)} kHz` : `${hz} Hz`; - } + import { formatHz } from '$lib/components/format'; function formatPct(p: number): string { return `${Math.round(p)}%`; @@ -84,7 +82,7 @@ let srcTooltip = $derived.by(() => { if (!info || info.sampleRate === appSettings.pipelineSampleRate) return undefined; - return `Resampling: ${formatRate(info.sampleRate)} → ${formatRate(appSettings.pipelineSampleRate)}`; + return `Resampling: ${formatHz(info.sampleRate)} → ${formatHz(appSettings.pipelineSampleRate)}`; }); @@ -107,8 +105,8 @@ {#if missing} Selected device not available {:else if info} - - {formatRate(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} + + {formatHz(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} {/if} diff --git a/src/lib/modules/flow/ui/input/net_receiver.svelte b/src/lib/modules/flow/ui/input/net_receiver.svelte index 3268dd8a..4a84f8e8 100644 --- a/src/lib/modules/flow/ui/input/net_receiver.svelte +++ b/src/lib/modules/flow/ui/input/net_receiver.svelte @@ -4,7 +4,7 @@ import type { NetReceiverNodeData } from '$lib/modules/pipeline/types'; import { methods as audioMethods } from '$lib/modules/audio/methods'; import SignalBars from '$lib/components/signal_bars.svelte'; - import { formatRate, LossWindow } from '$lib/components/format'; + import { formatHz, formatRate, LossWindow } from '$lib/components/format'; import Wrapper from '../node.svelte'; import { ArrowDownload } from '$lib/components/icons'; import { parseHandle } from '$lib/modules/flow/utils'; @@ -15,9 +15,30 @@ const flow = useSvelteFlow(); + let detectedSampleRate = $state(null); + let detectedFormat = $state<'pcm-f32' | 'pcm-i16' | 'opus' | null>(null); + let detectedOpusBitrate = $state(null); + let detectedOpusApp = $state<'voip' | 'audio' | 'low-delay' | null>(null); + + let codecLabel = $derived.by(() => { + if (!detectedFormat) return null; + if (detectedFormat === 'pcm-f32') return 'PCM (f32)'; + if (detectedFormat === 'pcm-i16') return 'PCM (i16)'; + if (detectedFormat === 'opus') return 'Opus'; + return detectedFormat; + }); + + let opusModeLabel = $derived.by(() => { + if (detectedFormat !== 'opus') return null; + const parts: string[] = []; + if (detectedOpusBitrate) parts.push(`${Math.round(detectedOpusBitrate / 1000)} kbps`); + if (detectedOpusApp) parts.push(detectedOpusApp); + return parts.length > 0 ? parts.join(' · ') : null; + }); + let srcTooltip = $derived.by(() => { - if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `Resampling: 48 kHz → ${formatRate(appSettings.pipelineSampleRate)}`; + if (!detectedSampleRate || detectedSampleRate === appSettings.pipelineSampleRate) return undefined; + return `Resampling: ${formatHz(detectedSampleRate)} → ${formatHz(appSettings.pipelineSampleRate)}`; }); let loss = $state(null); @@ -38,6 +59,10 @@ rate = 0; bufferMs = 0; received = 0; + detectedSampleRate = null; + detectedFormat = null; + detectedOpusBitrate = null; + detectedOpusApp = null; prevBytes = 0; prevAt = now; lossWindow.reset(); @@ -51,8 +76,17 @@ prevAt = now; bufferMs = s.bufferMs; received = s.channels; + detectedSampleRate = s.sampleRate || null; + detectedFormat = s.format || null; + detectedOpusBitrate = s.opusBitrate || null; + detectedOpusApp = s.opusApp || null; }, POLL_MS); - onDestroy(() => clearInterval(interval)); + + let isDestroyed = false; + onDestroy(() => { + isDestroyed = true; + clearInterval(interval); + }); const MAX_CHANNELS = 255; // Highest wire index the sender has actually delivered. @@ -62,7 +96,6 @@ audioMethods.netReceiverListen(id, data.port).catch(() => {}); }); - onDestroy(() => audioMethods.netReceiverRelease(id).catch(() => {})); const wired = useNodeConnections({ id: untrack(() => id), handleType: 'source' }); let wiredChannels = $derived( wired.current.reduce((n, c) => { @@ -75,6 +108,7 @@ let channelCount = $derived(Math.max(1, Math.min(Math.max(received, wiredChannels), MAX_CHANNELS))); $effect(() => { + if (isDestroyed) return; const next = channelCount; if (next !== untrack(() => data.channels)) flow.updateNodeData(id, { channels: next }); }); @@ -96,7 +130,7 @@ maxChannels={MAX_CHANNELS} minChannels={received} selfGrowing> -
+
UDP port @@ -109,6 +143,30 @@ onchange={(e) => setPort(e.currentTarget.value)} />
+ +
+
+ codec + + {codecLabel ?? 'waiting...'} + +
+ {#if opusModeLabel} +
+ mode + + {opusModeLabel} + +
+ {/if} +
+ stream + + {detectedSampleRate ? `${formatHz(detectedSampleRate)}${received > 0 ? ` · ${received} ch` : ''}` : '--'} + +
+
+
diff --git a/src/lib/modules/flow/ui/input/system_audio.svelte b/src/lib/modules/flow/ui/input/system_audio.svelte index 7c0f5912..4d7e583d 100644 --- a/src/lib/modules/flow/ui/input/system_audio.svelte +++ b/src/lib/modules/flow/ui/input/system_audio.svelte @@ -72,10 +72,11 @@ // System Audio capture is stereo; expose one output handle per channel. const channelCount = 2; + import { formatHz } from '$lib/components/format'; + let srcTooltip = $derived.by(() => { if (appSettings.pipelineSampleRate === 48_000) return undefined; - const targetK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; - return `Resampling: 48 kHz → ${targetK}`; + return `Resampling: 48 kHz → ${formatHz(appSettings.pipelineSampleRate)}`; }); @@ -129,6 +130,7 @@ checked={data.excludeCurrentApp ?? true} onChange={(v) => flow.updateNodeData(id, { excludeCurrentApp: v })} /> {/if} + 48 kHz · 2 ch · f32
diff --git a/src/lib/modules/flow/ui/output/file_recording.svelte b/src/lib/modules/flow/ui/output/file_recording.svelte index 2942023f..bbca4bc2 100644 --- a/src/lib/modules/flow/ui/output/file_recording.svelte +++ b/src/lib/modules/flow/ui/output/file_recording.svelte @@ -808,13 +808,13 @@ {formatDuration(durationSec)}
-
+
{formatLabelFor(recording && committedFormat !== null ? committedFormat : data.format)} · {data.format.kind === 'opus' || data.format.kind === 'mp3' ? '48 kHz' : `${(data.sampleRate ?? 48_000) / 1000} kHz`} · {channelLabel} - {formatSize(estSize)} + {formatSize(estSize)}
{#if dirty}
changes pending - restart or choose new file
diff --git a/src/lib/modules/flow/ui/output/net_sender.svelte b/src/lib/modules/flow/ui/output/net_sender.svelte index 670d0830..f7852bad 100644 --- a/src/lib/modules/flow/ui/output/net_sender.svelte +++ b/src/lib/modules/flow/ui/output/net_sender.svelte @@ -3,11 +3,12 @@ import { useNodeConnections, useSvelteFlow, type Node, type NodeProps } from '@xyflow/svelte'; import type { NetSenderNodeData, NetCodec, OpusApplication } from '$lib/modules/pipeline/types'; import { methods as audioMethods } from '$lib/modules/audio/methods'; - import { formatRate } from '$lib/components/format'; + import { formatHz, formatRate } from '$lib/components/format'; import { parseHandle } from '$lib/modules/flow/utils'; import Wrapper from '../node.svelte'; import { ArrowUpload } from '$lib/components/icons'; import SegmentedButtons from '$lib/components/segmented_buttons.svelte'; + import NumberStepper from '$lib/components/number_stepper.svelte'; import { appSettings } from '$lib/modules/settings/stores.svelte'; type NetSenderNodeType = Node; @@ -16,8 +17,13 @@ const flow = useSvelteFlow(); let srcTooltip = $derived.by(() => { - if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `Resampling: ${formatRate(appSettings.pipelineSampleRate)} → 48 kHz`; + if (data.codec === 'opus') { + if (appSettings.pipelineSampleRate === 48_000) return undefined; + return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → 48 kHz`; + } + const targetSr = data.sampleRate ?? appSettings.pipelineSampleRate; + if (targetSr === appSettings.pipelineSampleRate) return undefined; + return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → ${formatHz(targetSr)}`; }); let rate = $state(0); // bytes/sec @@ -39,7 +45,12 @@ prevBytes = s.bytes; prevAt = now; }, 1000); - onDestroy(() => clearInterval(interval)); + + let isDestroyed = false; + onDestroy(() => { + isDestroyed = true; + clearInterval(interval); + }); const MAX_CHANNELS = 255; const wired = useNodeConnections({ id: untrack(() => id), handleType: 'target' }); @@ -52,6 +63,7 @@ // The sender opens one send ring per channel, so it tracks the cables. $effect(() => { + if (isDestroyed) return; const next = Math.max(1, Math.min(wiredChannels, MAX_CHANNELS)); if (next !== untrack(() => data.channels)) flow.updateNodeData(id, { channels: next }); }); @@ -95,6 +107,49 @@ function setApp(app: OpusApplication) { flow.updateNodeData(id, { opusApplication: app }); } + + function kHz(n: number): string { + const k = n / 1000; + return String(Number.isInteger(k) ? k : Number(k.toFixed(3))); + } + + const PRESET_RATES = [44_100, 48_000, 88_200, 96_000]; + const rateValues = new Set(PRESET_RATES.map(String)); + + let customRateSelected = $state(false); + + let rateSelection = $derived.by(() => { + if (data.sampleRate === null) return 'auto'; + if (customRateSelected || !rateValues.has(String(data.sampleRate))) return 'custom'; + return String(data.sampleRate); + }); + + let rateOptions = $derived([ + { value: 'auto', label: 'Auto' }, + ...PRESET_RATES.map((r) => ({ value: String(r), label: kHz(r) })), + { value: 'custom', label: 'Custom' } + ]); + + function setRateSelection(sel: string) { + if (sel === 'custom') { + customRateSelected = true; + if (data.sampleRate === null) { + flow.updateNodeData(id, { sampleRate: appSettings.pipelineSampleRate }); + } + return; + } + customRateSelected = false; + if (sel === 'auto') { + flow.updateNodeData(id, { sampleRate: null }); + } else { + flow.updateNodeData(id, { sampleRate: Number(sel) }); + } + } + + function setCustomRate(n: number) { + const next = Math.min(384_000, Math.max(8_000, Math.round(n))); + flow.updateNodeData(id, { sampleRate: next }); + } @@ -148,6 +203,23 @@ value={data.opusApplication} onSelect={setApp} />
+ {:else} + +
+ + {#if rateSelection === 'custom'} +
+ Hz + +
+ {/if} +
{/if}
diff --git a/src/lib/modules/flow/ui/output/speaker.svelte b/src/lib/modules/flow/ui/output/speaker.svelte index fb91de44..ef32e40f 100644 --- a/src/lib/modules/flow/ui/output/speaker.svelte +++ b/src/lib/modules/flow/ui/output/speaker.svelte @@ -74,9 +74,7 @@ await volume.set(pct / 100); } - function formatRate(hz: number): string { - return hz >= 1000 ? `${(hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1)} kHz` : `${hz} Hz`; - } + import { formatHz } from '$lib/components/format'; function formatPct(p: number): string { return `${Math.round(p)}%`; @@ -91,7 +89,7 @@ let srcTooltip = $derived.by(() => { if (!info || info.sampleRate === appSettings.pipelineSampleRate) return undefined; - return `Resampling: ${formatRate(appSettings.pipelineSampleRate)} → ${formatRate(info.sampleRate)}`; + return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → ${formatHz(info.sampleRate)}`; }); @@ -112,8 +110,8 @@ {#if missing} Selected device not available {:else if info} - - {formatRate(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} + + {formatHz(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} {/if} diff --git a/src/lib/modules/pipeline/defaults.ts b/src/lib/modules/pipeline/defaults.ts index 813e3a1f..4d175dfd 100644 --- a/src/lib/modules/pipeline/defaults.ts +++ b/src/lib/modules/pipeline/defaults.ts @@ -70,7 +70,8 @@ export const DEFAULT_NODE_DATA: { [K in NodeKind]: NodeDataMap[K] } = { channels: 1, codec: 'opus', opusBitrate: 96000, - opusApplication: 'audio' + opusApplication: 'audio', + sampleRate: null } }; diff --git a/src/lib/modules/pipeline/generated/NetSenderData.ts b/src/lib/modules/pipeline/generated/NetSenderData.ts index d2ceaf92..85c02cde 100644 --- a/src/lib/modules/pipeline/generated/NetSenderData.ts +++ b/src/lib/modules/pipeline/generated/NetSenderData.ts @@ -2,4 +2,4 @@ import type { NetCodec } from "./NetCodec"; import type { OpusApplication } from "./OpusApplication"; -export type NetSenderData = { targetIp: string, port: number, channels: number, codec: NetCodec, opusBitrate: number, opusApplication: OpusApplication, }; +export type NetSenderData = { targetIp: string, port: number, channels: number, codec: NetCodec, opusBitrate: number, opusApplication: OpusApplication, sampleRate: number | null, }; From 5833af52a4b89c831de4f7a1f059b0a9efdbb8ec Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:53:02 +0300 Subject: [PATCH 4/9] refactor(netaudio): format-aware wire header with 8-byte base and optional codec metadata --- src-tauri/src/audio/netaudio/packet.rs | 43 +++++++++++++++++++------- src-tauri/src/audio/netaudio/sender.rs | 9 +++--- src-tauri/src/audio/webrtc/tasks.rs | 3 +- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/audio/netaudio/packet.rs b/src-tauri/src/audio/netaudio/packet.rs index ff7d398e..704ca46e 100644 --- a/src-tauri/src/audio/netaudio/packet.rs +++ b/src-tauri/src/audio/netaudio/packet.rs @@ -35,8 +35,14 @@ impl Format { } } +pub const HEADER_LEN_BASE: usize = 8; pub const HEADER_LEN_EXT: usize = 12; +/// Bit 7: set if packet includes the 8-byte base extended header (`sample_rate: u32`). +pub const FLAG_EXTENDED: u8 = 0x80; +/// Bit 6: set if packet includes the 4-byte codec metadata extension (`codec_param`). +pub const FLAG_CODEC_META: u8 = 0x40; + pub struct Parsed<'a> { pub format: Format, pub channel: u8, @@ -47,24 +53,32 @@ pub struct Parsed<'a> { pub payload: &'a [u8], } -/// Writes the extended self-describing header into `buf` (cleared first); the caller appends the payload. +/// Writes the self-describing header into `buf` (cleared first); the caller appends the payload. +/// If `codec_param` is provided (e.g. for Opus: bitrate + application mode), writes a 12-byte header with FLAG_CODEC_META. +/// Otherwise (e.g. for PCM), writes a compact 8-byte header with only FLAG_EXTENDED. pub fn write_header( buf: &mut Vec, format: Format, channel: u8, seq: u16, sample_rate: u32, - opus_bitrate_kbps: u16, - opus_app: u8, + codec_param: Option<(u16, u8)>, ) { buf.clear(); - buf.push(0x80 | format.to_byte()); + let has_codec_meta = codec_param.is_some(); + let mut b0 = FLAG_EXTENDED | format.to_byte(); + if has_codec_meta { + b0 |= FLAG_CODEC_META; + } + buf.push(b0); buf.push(channel); buf.extend_from_slice(&seq.to_be_bytes()); buf.extend_from_slice(&sample_rate.to_be_bytes()); - buf.extend_from_slice(&opus_bitrate_kbps.to_be_bytes()); - buf.push(opus_app); - buf.push(0); // reserved + if let Some((p16, p8)) = codec_param { + buf.extend_from_slice(&p16.to_be_bytes()); + buf.push(p8); + buf.push(0); // reserved + } } pub fn parse(data: &[u8]) -> Option> { @@ -72,23 +86,28 @@ pub fn parse(data: &[u8]) -> Option> { return None; } let b0 = data[0]; - if b0 & 0x80 != 0 { - if data.len() < 8 { + if b0 & FLAG_EXTENDED != 0 { + if data.len() < HEADER_LEN_BASE { return None; } - let format = Format::from_byte(b0 & 0x7F)?; + let format = Format::from_byte(b0 & 0x3F)?; let channel = data[1]; let seq = u16::from_be_bytes([data[2], data[3]]); let sample_rate = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); - let (opus_bitrate_kbps, opus_app, header_len) = if data.len() >= HEADER_LEN_EXT { + + let (opus_bitrate_kbps, opus_app, header_len) = if b0 & FLAG_CODEC_META != 0 { + if data.len() < HEADER_LEN_EXT { + return None; + } let kbps = u16::from_be_bytes([data[8], data[9]]); let app = data[10]; let kbps_opt = if kbps > 0 { Some(kbps) } else { None }; let app_opt = if app > 0 { Some(app) } else { None }; (kbps_opt, app_opt, HEADER_LEN_EXT) } else { - (None, None, 8) + (None, None, HEADER_LEN_BASE) }; + Some(Parsed { format, channel, diff --git a/src-tauri/src/audio/netaudio/sender.rs b/src-tauri/src/audio/netaudio/sender.rs index d92bb078..51a55cb5 100644 --- a/src-tauri/src/audio/netaudio/sender.rs +++ b/src-tauri/src/audio/netaudio/sender.rs @@ -193,15 +193,15 @@ 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 codec_param = 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) + Some(((self.config.opus_bitrate / 1000) as u16, app)) } else { - (0, 0) + None }; for i in 0..encoders.len() { let channel = i as u8; @@ -214,8 +214,7 @@ impl NetSender { channel, *seq, sample_rate, - opus_bitrate_kbps, - opus_app_byte, + codec_param, ); *seq = seq.wrapping_add(1); d.extend_from_slice(payload); diff --git a/src-tauri/src/audio/webrtc/tasks.rs b/src-tauri/src/audio/webrtc/tasks.rs index 462ffdc3..825b5a12 100644 --- a/src-tauri/src/audio/webrtc/tasks.rs +++ b/src-tauri/src/audio/webrtc/tasks.rs @@ -162,8 +162,7 @@ pub fn spawn_encode_task(session: Arc) { channel, *seq, OPUS_SR, - (bitrate / 1000) as u16, - 1, + Some(((bitrate / 1000) as u16, 1)), ); *seq = seq.wrapping_add(1); d.extend_from_slice(payload); From 35fcf9bd0d77b47545b6543592ced57a28d3d0eb Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:46:58 +0300 Subject: [PATCH 5/9] refactor(netaudio): clean v2 wire header with separate fields and codec-driven metadata --- src-tauri/src/audio/netaudio/packet.rs | 134 +++++++++++++++++-------- src-tauri/src/audio/netaudio/sender.rs | 11 +- src-tauri/src/audio/webrtc/tasks.rs | 5 +- 3 files changed, 100 insertions(+), 50 deletions(-) diff --git a/src-tauri/src/audio/netaudio/packet.rs b/src-tauri/src/audio/netaudio/packet.rs index 704ca46e..e21a9f01 100644 --- a/src-tauri/src/audio/netaudio/packet.rs +++ b/src-tauri/src/audio/netaudio/packet.rs @@ -1,11 +1,21 @@ -//! Wire format for direct-IP audio. Each UDP datagram is a 4-byte header -//! followed by one payload: `[format][channel][seq_be_hi][seq_be_lo]`. +//! Wire format for direct-IP audio. //! -//! `seq` is a per-channel packet counter for loss/reorder detection. Audio is -//! always carried at 48 kHz stereo regardless of `format`, so the receiver is -//! format-agnostic beyond decoding the payload. +//! ### Protocol v2: +//! - Base header (9 bytes, for PCM): +//! `[version: 0x82][format: 1B][channel: 1B][seq: 2B BE][sample_rate: 4B BE]` +//! - Extended header (12 bytes, for Opus only): +//! `[version: 0x82][format: 1B][channel: 1B][seq: 2B BE][sample_rate: 4B BE][bitrate_kbps: 2B BE][opus_app: 1B]` +//! +//! ### Protocol v1 (legacy fallback): +//! - Fixed 4 bytes: `[format: 1B][channel: 1B][seq: 2B BE]` (assumes 48 kHz stereo). + +pub const HEADER_LEN_V1: usize = 4; +pub const HEADER_LEN_V2_BASE: usize = 9; +pub const HEADER_LEN_V2_OPUS: usize = 12; + +/// Protocol version 2 marker byte (`0x82`). Distinct from legacy v1 format bytes (0, 1, 2). +pub const PROTOCOL_V2: u8 = 0x82; -pub const HEADER_LEN: usize = 4; /// Keep datagrams under a typical MTU so PCM isn't IP-fragmented. pub const MAX_PAYLOAD: usize = 1200; @@ -35,14 +45,6 @@ impl Format { } } -pub const HEADER_LEN_BASE: usize = 8; -pub const HEADER_LEN_EXT: usize = 12; - -/// Bit 7: set if packet includes the 8-byte base extended header (`sample_rate: u32`). -pub const FLAG_EXTENDED: u8 = 0x80; -/// Bit 6: set if packet includes the 4-byte codec metadata extension (`codec_param`). -pub const FLAG_CODEC_META: u8 = 0x40; - pub struct Parsed<'a> { pub format: Format, pub channel: u8, @@ -54,58 +56,53 @@ pub struct Parsed<'a> { } /// Writes the self-describing header into `buf` (cleared first); the caller appends the payload. -/// If `codec_param` is provided (e.g. for Opus: bitrate + application mode), writes a 12-byte header with FLAG_CODEC_META. -/// Otherwise (e.g. for PCM), writes a compact 8-byte header with only FLAG_EXTENDED. +/// - For PCM: writes 9 bytes `[PROTOCOL_V2, format, channel, seq_be, sample_rate_be]`. +/// - For Opus: appends 3 bytes `[bitrate_kbps_be, opus_app]` (12 bytes total). pub fn write_header( buf: &mut Vec, format: Format, channel: u8, seq: u16, sample_rate: u32, - codec_param: Option<(u16, u8)>, + opus_bitrate_kbps: u16, + opus_app: u8, ) { buf.clear(); - let has_codec_meta = codec_param.is_some(); - let mut b0 = FLAG_EXTENDED | format.to_byte(); - if has_codec_meta { - b0 |= FLAG_CODEC_META; - } - buf.push(b0); + buf.push(PROTOCOL_V2); + buf.push(format.to_byte()); buf.push(channel); buf.extend_from_slice(&seq.to_be_bytes()); buf.extend_from_slice(&sample_rate.to_be_bytes()); - if let Some((p16, p8)) = codec_param { - buf.extend_from_slice(&p16.to_be_bytes()); - buf.push(p8); - buf.push(0); // reserved + if format == Format::Opus { + buf.extend_from_slice(&opus_bitrate_kbps.to_be_bytes()); + buf.push(opus_app); } } pub fn parse(data: &[u8]) -> Option> { - if data.len() < HEADER_LEN { + if data.len() < HEADER_LEN_V1 { return None; } - let b0 = data[0]; - if b0 & FLAG_EXTENDED != 0 { - if data.len() < HEADER_LEN_BASE { + if data[0] == PROTOCOL_V2 { + if data.len() < HEADER_LEN_V2_BASE { return None; } - let format = Format::from_byte(b0 & 0x3F)?; - let channel = data[1]; - let seq = u16::from_be_bytes([data[2], data[3]]); - let sample_rate = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); + let format = Format::from_byte(data[1])?; + let channel = data[2]; + let seq = u16::from_be_bytes([data[3], data[4]]); + let sample_rate = u32::from_be_bytes([data[5], data[6], data[7], data[8]]); - let (opus_bitrate_kbps, opus_app, header_len) = if b0 & FLAG_CODEC_META != 0 { - if data.len() < HEADER_LEN_EXT { + let (opus_bitrate_kbps, opus_app, header_len) = if format == Format::Opus { + if data.len() < HEADER_LEN_V2_OPUS { return None; } - let kbps = u16::from_be_bytes([data[8], data[9]]); - let app = data[10]; + let kbps = u16::from_be_bytes([data[9], data[10]]); + let app = data[11]; let kbps_opt = if kbps > 0 { Some(kbps) } else { None }; let app_opt = if app > 0 { Some(app) } else { None }; - (kbps_opt, app_opt, HEADER_LEN_EXT) + (kbps_opt, app_opt, HEADER_LEN_V2_OPUS) } else { - (None, None, HEADER_LEN_BASE) + (None, None, HEADER_LEN_V2_BASE) }; Some(Parsed { @@ -118,7 +115,8 @@ pub fn parse(data: &[u8]) -> Option> { payload: &data[header_len..], }) } else { - let format = Format::from_byte(b0)?; + // Legacy v1 header: [format, channel, seq_be] + let format = Format::from_byte(data[0])?; let channel = data[1]; let seq = u16::from_be_bytes([data[2], data[3]]); Some(Parsed { @@ -128,7 +126,7 @@ pub fn parse(data: &[u8]) -> Option> { sample_rate: 48_000, opus_bitrate_kbps: None, opus_app: None, - payload: &data[HEADER_LEN..], + payload: &data[HEADER_LEN_V1..], }) } } @@ -164,3 +162,53 @@ pub fn pcm_i16_decode(payload: &[u8], out: &mut Vec) { out.push(v as f32 / i16::MAX as f32); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_v2_pcm_roundtrip() { + let mut buf = Vec::new(); + write_header(&mut buf, Format::PcmF32, 1, 42, 96_000, 0, 0); + assert_eq!(buf.len(), HEADER_LEN_V2_BASE); + buf.extend_from_slice(&[1, 2, 3, 4]); + + let parsed = parse(&buf).expect("should parse v2 pcm"); + assert_eq!(parsed.format, Format::PcmF32); + assert_eq!(parsed.channel, 1); + assert_eq!(parsed.seq, 42); + assert_eq!(parsed.sample_rate, 96_000); + assert_eq!(parsed.opus_bitrate_kbps, None); + assert_eq!(parsed.opus_app, None); + assert_eq!(parsed.payload, &[1, 2, 3, 4]); + } + + #[test] + fn test_v2_opus_roundtrip() { + let mut buf = Vec::new(); + write_header(&mut buf, Format::Opus, 0, 100, 48_000, 128, 3); + assert_eq!(buf.len(), HEADER_LEN_V2_OPUS); + buf.extend_from_slice(&[0xFA, 0xFB]); + + let parsed = parse(&buf).expect("should parse v2 opus"); + assert_eq!(parsed.format, Format::Opus); + assert_eq!(parsed.channel, 0); + assert_eq!(parsed.seq, 100); + assert_eq!(parsed.sample_rate, 48_000); + assert_eq!(parsed.opus_bitrate_kbps, Some(128)); + assert_eq!(parsed.opus_app, Some(3)); + assert_eq!(parsed.payload, &[0xFA, 0xFB]); + } + + #[test] + fn test_v1_legacy_fallback() { + let buf = vec![Format::PcmF32.to_byte(), 0, 0, 10, 0xAA, 0xBB]; + let parsed = parse(&buf).expect("should parse legacy v1"); + assert_eq!(parsed.format, Format::PcmF32); + assert_eq!(parsed.channel, 0); + assert_eq!(parsed.seq, 10); + assert_eq!(parsed.sample_rate, 48_000); + assert_eq!(parsed.payload, &[0xAA, 0xBB]); + } +} diff --git a/src-tauri/src/audio/netaudio/sender.rs b/src-tauri/src/audio/netaudio/sender.rs index 51a55cb5..1f874239 100644 --- a/src-tauri/src/audio/netaudio/sender.rs +++ b/src-tauri/src/audio/netaudio/sender.rs @@ -193,28 +193,29 @@ impl NetSender { let mut packets: Vec> = Vec::new(); let sample_rate = self.config.sample_rate; - let codec_param = if format == Format::Opus { + 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, }; - Some(((self.config.opus_bitrate / 1000) as u16, app)) + ((self.config.opus_bitrate / 1000) as u16, app) } else { - None + (0, 0) }; for i in 0..encoders.len() { let channel = i as u8; let seq = &mut seqs[i]; encoders[i].push(&ins[i], |payload| { - let mut d = Vec::with_capacity(packet::HEADER_LEN_EXT + payload.len()); + let mut d = Vec::with_capacity(packet::HEADER_LEN_V2_OPUS + payload.len()); packet::write_header( &mut d, format, channel, *seq, sample_rate, - codec_param, + opus_bitrate_kbps, + opus_app_byte, ); *seq = seq.wrapping_add(1); d.extend_from_slice(payload); diff --git a/src-tauri/src/audio/webrtc/tasks.rs b/src-tauri/src/audio/webrtc/tasks.rs index 825b5a12..54ab0e52 100644 --- a/src-tauri/src/audio/webrtc/tasks.rs +++ b/src-tauri/src/audio/webrtc/tasks.rs @@ -155,14 +155,15 @@ pub fn spawn_encode_task(session: Arc) { 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_EXT + payload.len()); + let mut d = Vec::with_capacity(packet::HEADER_LEN_V2_OPUS + payload.len()); packet::write_header( &mut d, format, channel, *seq, OPUS_SR, - Some(((bitrate / 1000) as u16, 1)), + (bitrate / 1000) as u16, + 1, ); *seq = seq.wrapping_add(1); d.extend_from_slice(payload); From efdd94caeac23701fd81482b2e6da19f04a66ee6 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:01:50 +0300 Subject: [PATCH 6/9] feat(flow): unify audio node specs styling with .node-spec and fix file recording layout --- src/app.css | 3 ++ .../modules/flow/ui/input/app_audio.svelte | 4 +-- .../modules/flow/ui/input/audio_file.svelte | 2 +- .../modules/flow/ui/input/microphone.svelte | 4 +-- .../modules/flow/ui/input/net_receiver.svelte | 20 ++++++------- .../modules/flow/ui/input/system_audio.svelte | 4 +-- .../flow/ui/output/file_recording.svelte | 30 ++++++++++++------- .../modules/flow/ui/output/net_sender.svelte | 6 ++-- src/lib/modules/flow/ui/output/speaker.svelte | 2 +- 9 files changed, 43 insertions(+), 32 deletions(-) diff --git a/src/app.css b/src/app.css index 6afd573c..dd29058f 100644 --- a/src/app.css +++ b/src/app.css @@ -233,6 +233,9 @@ @apply focus:outline-none px-2.5 bg-neutral-100 hover:bg-neutral-200 hover:text-theme transition-all text-neutral-800 rounded-lg h-6.5 flex justify-center items-center; } + .node-spec { + @apply font-mono text-[9px] text-neutral-600; + } } .svelte-flow { diff --git a/src/lib/modules/flow/ui/input/app_audio.svelte b/src/lib/modules/flow/ui/input/app_audio.svelte index 8fb366a5..e4f5229f 100644 --- a/src/lib/modules/flow/ui/input/app_audio.svelte +++ b/src/lib/modules/flow/ui/input/app_audio.svelte @@ -68,7 +68,7 @@ -
+
App no longer running {:else if data.bundleId} - 48 kHz · 2 ch · f32 + 48 kHz · 2 ch · f32 {/if} {#if data.bundleId && !missing} diff --git a/src/lib/modules/flow/ui/input/audio_file.svelte b/src/lib/modules/flow/ui/input/audio_file.svelte index 03afef31..75939e05 100644 --- a/src/lib/modules/flow/ui/input/audio_file.svelte +++ b/src/lib/modules/flow/ui/input/audio_file.svelte @@ -209,7 +209,7 @@ minChannels={channels} maxChannels={channels || undefined} selfGrowing> -
+
{basename(data.filePath)}
diff --git a/src/lib/modules/flow/ui/input/microphone.svelte b/src/lib/modules/flow/ui/input/microphone.svelte index 6ca6e457..371fac58 100644 --- a/src/lib/modules/flow/ui/input/microphone.svelte +++ b/src/lib/modules/flow/ui/input/microphone.svelte @@ -87,7 +87,7 @@ -
+
refresh()}> {#snippet footer(close)} @@ -105,7 +105,7 @@ {#if missing} Selected device not available {:else if info} - + {formatHz(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} {/if} diff --git a/src/lib/modules/flow/ui/input/net_receiver.svelte b/src/lib/modules/flow/ui/input/net_receiver.svelte index 4a84f8e8..05a865da 100644 --- a/src/lib/modules/flow/ui/input/net_receiver.svelte +++ b/src/lib/modules/flow/ui/input/net_receiver.svelte @@ -144,38 +144,38 @@
-
+
- codec - + codec + {codecLabel ?? 'waiting...'}
{#if opusModeLabel}
- mode - + mode + {opusModeLabel}
{/if}
- stream - + stream + {detectedSampleRate ? `${formatHz(detectedSampleRate)}${received > 0 ? ` · ${received} ch` : ''}` : '--'}
-
+
- + {loss == null ? '--' : `${(loss * 100).toFixed(1)}%`}
- {formatRate(rate)} + {formatRate(rate)}
diff --git a/src/lib/modules/flow/ui/input/system_audio.svelte b/src/lib/modules/flow/ui/input/system_audio.svelte index 4d7e583d..437ff4a8 100644 --- a/src/lib/modules/flow/ui/input/system_audio.svelte +++ b/src/lib/modules/flow/ui/input/system_audio.svelte @@ -81,7 +81,7 @@ -
+
{#if showBanner}
flow.updateNodeData(id, { excludeCurrentApp: v })} /> {/if} - 48 kHz · 2 ch · f32 + 48 kHz · 2 ch · f32
diff --git a/src/lib/modules/flow/ui/output/file_recording.svelte b/src/lib/modules/flow/ui/output/file_recording.svelte index bbca4bc2..3d428cef 100644 --- a/src/lib/modules/flow/ui/output/file_recording.svelte +++ b/src/lib/modules/flow/ui/output/file_recording.svelte @@ -22,6 +22,7 @@ import { Eye, EyeOff, Folder, FolderOpen, FileRecord, Pulse } from '$lib/components/icons'; import { RECORDING_FORMATS } from '$lib/modules/pipeline/recording-formats'; import NumberStepper from '$lib/components/number_stepper.svelte'; + import { formatHz } from '$lib/components/format'; import { onNodeAction, parseHandle } from '$lib/modules/flow/utils'; import SegmentedButtons from '$lib/components/segmented_buttons.svelte'; import WaveformScope from '$lib/components/waveform_scope.svelte'; @@ -628,21 +629,22 @@ function formatLabelFor(fmt: RecordingFormat): string { if (fmt.kind === 'wav') { const bd = fmt.bitDepth; - return bd === 'i16' ? 'WAV PCM 16-bit' : bd === 'i24' ? 'WAV PCM 24-bit' : 'WAV 32-bit float'; + return bd === 'i16' ? 'WAV 16-bit' : bd === 'i24' ? 'WAV 24-bit' : 'WAV 32-bit float'; } if (fmt.kind === 'flac') { - return `FLAC ${fmt.bitDepth === 'i24' ? '24-bit' : '16-bit'} · ${fmt.compression}`; + return `FLAC ${fmt.bitDepth === 'i24' ? '24-bit' : '16-bit'}`; } if (fmt.kind === 'opus') { - return `Opus ${Math.round(fmt.bitrate / 1000)} kbps · ${fmt.application}`; + const app = fmt.application === 'audio' ? '' : ` · ${fmt.application}`; + return `Opus ${Math.round(fmt.bitrate / 1000)} kbps${app}`; } if (fmt.kind === 'mp3') { return `MP3 ${fmt.bitrateKbps} kbps`; } if (fmt.kind === 'aac') { - return `AAC ${Math.round(fmt.bitrate / 1000)} kbps · M4A`; + return `AAC ${Math.round(fmt.bitrate / 1000)} kbps`; } - return `AIFF PCM ${fmt.bitDepth === 'i24' ? '24-bit' : '16-bit'}`; + return `AIFF ${fmt.bitDepth === 'i24' ? '24-bit' : '16-bit'}`; } let estSize = $derived(estimatedSize()); @@ -806,22 +808,28 @@ {recording ? '● REC' : '○'} - {formatDuration(durationSec)} +
+ {formatDuration(durationSec)} + {#if estSize > 0} + · + {formatSize(estSize)} + {/if} +
-
+
{formatLabelFor(recording && committedFormat !== null ? committedFormat : data.format)} - · {data.format.kind === 'opus' || data.format.kind === 'mp3' ? '48 kHz' : `${(data.sampleRate ?? 48_000) / 1000} kHz`} - · {channelLabel} - {formatSize(estSize)} + + {formatHz(targetSampleRate)} · {channelLabel} +
{#if dirty}
changes pending - restart or choose new file
{/if}
- + Waveform {#if !isAppendable(data.format)} diff --git a/src/lib/modules/flow/ui/output/net_sender.svelte b/src/lib/modules/flow/ui/output/net_sender.svelte index f7852bad..10695d7e 100644 --- a/src/lib/modules/flow/ui/output/net_sender.svelte +++ b/src/lib/modules/flow/ui/output/net_sender.svelte @@ -175,9 +175,9 @@
-
- Sending - {formatRate(rate)} +
+ Sending + {formatRate(rate)}

diff --git a/src/lib/modules/flow/ui/output/speaker.svelte b/src/lib/modules/flow/ui/output/speaker.svelte index ef32e40f..5d1bb91e 100644 --- a/src/lib/modules/flow/ui/output/speaker.svelte +++ b/src/lib/modules/flow/ui/output/speaker.svelte @@ -110,7 +110,7 @@ {#if missing} Selected device not available {:else if info} - + {formatHz(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} {/if} From 57a27bb33120c1f8f0a7db68bb406aff1a396761 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:21:32 +0300 Subject: [PATCH 7/9] refactor(ui): unify formatting helpers in format.ts and replace custom implementations --- src/lib/components/format.ts | 63 ++++++++++++++++++- src/lib/modules/audio/ui/running_timer.svelte | 11 +--- .../modules/flow/ui/effect/de_esser.svelte | 7 +-- src/lib/modules/flow/ui/effect/eq.svelte | 9 +-- .../modules/flow/ui/effect/level_meter.svelte | 8 +-- src/lib/modules/flow/ui/effect/mute.svelte | 4 +- .../flow/ui/effect/noise_suppressor.svelte | 6 +- .../modules/flow/ui/effect/spectrum.svelte | 3 +- .../flow/ui/effect/webrtc_collaborator.svelte | 2 +- .../modules/flow/ui/input/app_audio.svelte | 10 +-- .../modules/flow/ui/input/audio_file.svelte | 26 +++----- .../modules/flow/ui/input/microphone.svelte | 6 +- .../modules/flow/ui/input/net_receiver.svelte | 4 +- .../modules/flow/ui/input/system_audio.svelte | 10 +-- .../flow/ui/output/file_recording.svelte | 27 ++------ .../modules/flow/ui/output/net_sender.svelte | 11 +--- src/lib/modules/flow/ui/output/speaker.svelte | 6 +- .../modules/updater/ui/update_banner.svelte | 8 +-- src/routes/settings/+page.svelte | 3 +- src/routes/virtual-devices/+page.svelte | 3 +- 20 files changed, 106 insertions(+), 121 deletions(-) diff --git a/src/lib/components/format.ts b/src/lib/components/format.ts index 66d0ca9f..a21d4465 100644 --- a/src/lib/components/format.ts +++ b/src/lib/components/format.ts @@ -46,9 +46,66 @@ export function formatRate(bytesPerSec: number): string { return `${(bytesPerSec / (1024 * 1024)).toFixed(2)} MB/s`; } -/** Human-readable audio sample rate / frequency, e.g. `48 kHz`, `44.1 kHz`, `96 kHz`, `440 Hz`. */ +/** Returns the numeric kHz representation, e.g. 48000 -> "48", 44100 -> "44.1", 48002 -> "48.002". */ +export function formatKhzValue(hz: number): string { + const k = hz / 1000; + return String(Number(k.toFixed(3))); +} + +/** Human-readable audio sample rate / frequency, e.g. `48 kHz`, `44.1 kHz`, `48.002 kHz`, `440 Hz`. */ export function formatHz(hz: number): string { if (hz < 1000) return `${hz} Hz`; - const k = hz / 1000; - return `${Number(k.toFixed(k % 1 === 0 ? 0 : 1))} kHz`; + return `${formatKhzValue(hz)} kHz`; +} + +/** Compact audio frequency label for ticks and EQ bands, e.g. `32`, `500`, `1k`, `2.5k`, `16k`. */ +export function formatFreq(hz: number): string { + if (hz >= 1000) return `${formatKhzValue(hz)}k`; + return String(Math.round(hz)); +} + +/** Percentage representation, e.g. `75%`, `12.5%`. */ +export function formatPct(p: number, decimals: number = 0): string { + if (!Number.isFinite(p)) return '0%'; + if (decimals > 0) return `${p.toFixed(decimals)}%`; + return `${Math.round(p)}%`; +} + +/** Human-readable data size, e.g. `512 B`, `24.5 KB`, `120.4 MB`, `1.25 GB`. */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${Math.round(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } + +export const formatSize = formatBytes; + +/** Time/duration string, e.g. `0:12.4` (decimals=1) or `1:05:32` / `0:45` (decimals=0). */ +export function formatDuration(sec: number, decimals: number = 0): string { + if (!Number.isFinite(sec) || sec <= 0) { + return decimals > 0 ? `0:00.${'0'.repeat(decimals)}` : '0:00'; + } + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = Math.floor(sec % 60); + const sStr = String(s).padStart(2, '0'); + const frac = decimals > 0 ? (sec % 1).toFixed(decimals).slice(1) : ''; + if (h > 0) { + return `${h}:${String(m).padStart(2, '0')}:${sStr}${frac}`; + } + return `${m}:${sStr}${frac}`; +} + +/** Gain in dB with sign, e.g. `+3.0`, `-6.0`, `0.0`. */ +export function formatGain(db: number): string { + const v = db.toFixed(1); + return db > 0 ? `+${v}` : v; +} + +/** Level meter decibel readout with floor handling, e.g. `-12.4`, `−∞`. */ +export function formatDb(db: number, floor: number = -96): string { + return Number.isFinite(db) && db > floor ? db.toFixed(1) : '−∞'; +} + diff --git a/src/lib/modules/audio/ui/running_timer.svelte b/src/lib/modules/audio/ui/running_timer.svelte index e9acf6a3..79925cc3 100644 --- a/src/lib/modules/audio/ui/running_timer.svelte +++ b/src/lib/modules/audio/ui/running_timer.svelte @@ -1,5 +1,6 @@ -{format(elapsed)} +{formatDuration(elapsed)} diff --git a/src/lib/modules/flow/ui/effect/de_esser.svelte b/src/lib/modules/flow/ui/effect/de_esser.svelte index d8f05510..ea4239bf 100644 --- a/src/lib/modules/flow/ui/effect/de_esser.svelte +++ b/src/lib/modules/flow/ui/effect/de_esser.svelte @@ -7,6 +7,7 @@ import { PresetBar } from '$lib/modules/preset/ui'; import type { PresetData } from '$lib/modules/preset'; import Slider from './_slider.svelte'; + import { formatHz } from '$lib/components/format'; type DeEsserNodeType = Node; let { id, data }: NodeProps = $props(); @@ -26,10 +27,6 @@ function toggleBypass() { set({ bypassed: !data.bypassed }); } - - function fmtHz(v: number): string { - return v >= 1000 ? `${(v / 1000).toFixed(1)} k` : `${Math.round(v)} `; - } @@ -44,7 +41,7 @@ step={50} unit="Hz" defaultValue={6500} - format={fmtHz} + format={formatHz} ticks={[4000, 6500, 9000, 12000]} onChange={(v) => set({ frequency: v })} /> ; let { id, data }: NodeProps = $props(); @@ -161,14 +162,6 @@ } } - function formatFreq(hz: number): string { - if (hz >= 1000) return `${hz / 1000}k`; - return String(hz); - } - function formatGain(g: number): string { - const v = g.toFixed(1); - return g > 0 ? `+${v}` : v; - } diff --git a/src/lib/modules/flow/ui/effect/level_meter.svelte b/src/lib/modules/flow/ui/effect/level_meter.svelte index e344175e..a5a0eaf2 100644 --- a/src/lib/modules/flow/ui/effect/level_meter.svelte +++ b/src/lib/modules/flow/ui/effect/level_meter.svelte @@ -7,6 +7,7 @@ import { DataBar } from '$lib/components/icons'; import MeterBar from '$lib/components/meter_bar.svelte'; import { onNodeAction, channelColor, channelLabel } from '$lib/modules/flow/utils'; + import { formatDb } from '$lib/components/format'; type LevelMeterNodeType = Node; let { id, data }: NodeProps = $props(); @@ -62,9 +63,6 @@ return (pct / 100) * -DB_FLOOR + DB_FLOOR; } - function formatDb(db: number): string { - return isFinite(db) && db > DB_FLOOR ? db.toFixed(1) : '−∞'; - } function hoverLabel(pct: number): string { return pctToDb(pct).toFixed(1); @@ -214,7 +212,7 @@ {#each barVals as db, i (i)}
{channelLabel(i, channelCount)} - {formatDb(db)} + {formatDb(db, DB_FLOOR)}
{/each}
@@ -229,7 +227,7 @@ {#each maxVals as db, i (i)}
{channelLabel(i, channelCount)} - {formatDb(db)} + {formatDb(db, DB_FLOOR)}
{/each} diff --git a/src/lib/modules/flow/ui/effect/mute.svelte b/src/lib/modules/flow/ui/effect/mute.svelte index 2808681a..852ffb5e 100644 --- a/src/lib/modules/flow/ui/effect/mute.svelte +++ b/src/lib/modules/flow/ui/effect/mute.svelte @@ -11,6 +11,7 @@ import { Combobox, RescanButton } from '$lib/modules/form/ui'; import { onMount } from 'svelte'; import Slider from './_slider.svelte'; + import { formatPct } from '$lib/components/format'; type MuteNodeType = Node; let { id, data }: NodeProps = $props(); @@ -119,9 +120,6 @@ flow.updateNodeData(id, { cueVolume: v }); } - function formatPct(v: number): string { - return `${Math.round(v)}%`; - } function clearHotkey() { bindError = ''; diff --git a/src/lib/modules/flow/ui/effect/noise_suppressor.svelte b/src/lib/modules/flow/ui/effect/noise_suppressor.svelte index ec3ddd21..051f4fa3 100644 --- a/src/lib/modules/flow/ui/effect/noise_suppressor.svelte +++ b/src/lib/modules/flow/ui/effect/noise_suppressor.svelte @@ -8,6 +8,7 @@ import type { PresetData } from '$lib/modules/preset'; import Slider from './_slider.svelte'; import { appSettings } from '$lib/modules/settings/stores.svelte'; + import { formatHz } from '$lib/components/format'; type NoiseSuppressorNodeType = Node; let { id, data }: NodeProps = $props(); @@ -43,8 +44,7 @@ let srcTooltip = $derived.by(() => { if (appSettings.pipelineSampleRate === 48_000) return undefined; - const targetK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; - return `Internal model runs at 48 kHz (resampled from ${targetK} and back)`; + return `Internal model runs at ${formatHz(48_000)} (resampled from ${formatHz(appSettings.pipelineSampleRate)} and back)`; }); @@ -62,7 +62,7 @@
-

DeepFilterNet speech denoise. 48 kHz only.

+

DeepFilterNet speech denoise. {formatHz(48_000)} only.

fMax) continue; - out.push({ f, label: f >= 1000 ? `${f / 1000}k` : `${f}`, major: m === 1 }); + out.push({ f, label: formatFreq(f), major: m === 1 }); } } return out; diff --git a/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte b/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte index 282689bc..ea8f3ce9 100644 --- a/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte +++ b/src/lib/modules/flow/ui/effect/webrtc_collaborator.svelte @@ -24,7 +24,7 @@ let srcTooltip = $derived.by(() => { if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `WebRTC audio operates at 48 kHz (resampled from ${formatHz(appSettings.pipelineSampleRate)} and back)`; + return `WebRTC audio operates at ${formatHz(48_000)} (resampled from ${formatHz(appSettings.pipelineSampleRate)} and back)`; }); const MAX_CHANNELS = 255; diff --git a/src/lib/modules/flow/ui/input/app_audio.svelte b/src/lib/modules/flow/ui/input/app_audio.svelte index e4f5229f..1d61852a 100644 --- a/src/lib/modules/flow/ui/input/app_audio.svelte +++ b/src/lib/modules/flow/ui/input/app_audio.svelte @@ -11,7 +11,7 @@ import { onNodeAction } from '$lib/modules/flow/utils'; import { onDestroy, onMount } from 'svelte'; import { appSettings } from '$lib/modules/settings/stores.svelte'; - import { formatHz } from '$lib/components/format'; + import { formatHz, formatPct } from '$lib/components/format'; type AppAudioNodeType = Node; let { id, data }: NodeProps = $props(); @@ -52,10 +52,6 @@ audioMethods.setInputVolume(id, scalar).catch(() => {}); } - function formatPct(p: number): string { - return `${Math.round(p)}%`; - } - let volumePct = $derived((data.volume ?? 1) * 100); // App Audio capture is stereo; expose one output handle per channel. @@ -63,7 +59,7 @@ let srcTooltip = $derived.by(() => { if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `Resampling: 48 kHz → ${formatHz(appSettings.pipelineSampleRate)}`; + return `Resampling: ${formatHz(48_000)} → ${formatHz(appSettings.pipelineSampleRate)}`; }); @@ -84,7 +80,7 @@ {#if missing} App no longer running {:else if data.bundleId} - 48 kHz · 2 ch · f32 + {formatHz(48_000)} · 2 ch · f32 {/if} {#if data.bundleId && !missing} diff --git a/src/lib/modules/flow/ui/input/audio_file.svelte b/src/lib/modules/flow/ui/input/audio_file.svelte index 75939e05..401a7c56 100644 --- a/src/lib/modules/flow/ui/input/audio_file.svelte +++ b/src/lib/modules/flow/ui/input/audio_file.svelte @@ -12,6 +12,7 @@ import { onNodeAction } from '$lib/modules/flow/utils'; import { Tooltip } from '$lib/modules/overlay/ui'; import { appSettings } from '$lib/modules/settings/stores.svelte'; + import { formatHz, formatDuration, formatPct } from '$lib/components/format'; type AudioFileNodeType = Node; let { id, data }: NodeProps = $props(); @@ -157,16 +158,6 @@ return i >= 0 ? p.slice(i + 1) : p; } - function formatTime(sec: number): string { - if (!Number.isFinite(sec) || sec < 0) sec = 0; - const minutes = Math.floor(sec / 60); - const remainder = sec - minutes * 60; - return `${minutes}:${remainder.toFixed(1).padStart(4, '0')}`; - } - - function formatRate(hz: number): string { - return hz >= 1000 ? `${(hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1)} kHz` : `${hz} Hz`; - } function extension(p: string | null): string { const i = p?.lastIndexOf('.') ?? -1; @@ -186,15 +177,12 @@ audioMethods.setInputVolume(id, scalar).catch(() => {}); } - function formatPct(p: number): string { - return `${Math.round(p)}%`; - } let volumePct = $derived((data.volume ?? 1) * 100); let srcTooltip = $derived.by(() => { if (sampleRate <= 0 || sampleRate === appSettings.pipelineSampleRate) return undefined; - return `Resampling: ${formatRate(sampleRate)} → ${formatRate(appSettings.pipelineSampleRate)}`; + return `Resampling: ${formatHz(sampleRate)} → ${formatHz(appSettings.pipelineSampleRate)}`; }); @@ -219,9 +207,9 @@ {/if} {#if sampleRate > 0} -
- {formatRate(sampleRate)} · {channelLabel} - {extension(data.filePath)} +
+ {formatHz(sampleRate)} · {channelLabel} + {extension(data.filePath)}
{/if} @@ -256,7 +244,7 @@ onkeyup={clearScrub} />
- {formatTime(currentSec)} + {formatDuration(currentSec, 1)}
- {formatTime(totalSec)} + {formatDuration(totalSec, 1)}
diff --git a/src/lib/modules/flow/ui/input/microphone.svelte b/src/lib/modules/flow/ui/input/microphone.svelte index 371fac58..051218d0 100644 --- a/src/lib/modules/flow/ui/input/microphone.svelte +++ b/src/lib/modules/flow/ui/input/microphone.svelte @@ -70,11 +70,7 @@ await gain.set(pct / 100); } - import { formatHz } from '$lib/components/format'; - - function formatPct(p: number): string { - return `${Math.round(p)}%`; - } + import { formatHz, formatPct } from '$lib/components/format'; let gainPct = $derived((gain.scalar ?? 0) * 100); diff --git a/src/lib/modules/flow/ui/input/net_receiver.svelte b/src/lib/modules/flow/ui/input/net_receiver.svelte index 05a865da..582e8778 100644 --- a/src/lib/modules/flow/ui/input/net_receiver.svelte +++ b/src/lib/modules/flow/ui/input/net_receiver.svelte @@ -4,7 +4,7 @@ import type { NetReceiverNodeData } from '$lib/modules/pipeline/types'; import { methods as audioMethods } from '$lib/modules/audio/methods'; import SignalBars from '$lib/components/signal_bars.svelte'; - import { formatHz, formatRate, LossWindow } from '$lib/components/format'; + import { formatHz, formatRate, formatPct, LossWindow } from '$lib/components/format'; import Wrapper from '../node.svelte'; import { ArrowDownload } from '$lib/components/icons'; import { parseHandle } from '$lib/modules/flow/utils'; @@ -172,7 +172,7 @@
- {loss == null ? '--' : `${(loss * 100).toFixed(1)}%`} + {loss == null ? '--' : formatPct(loss * 100, 1)}
{formatRate(rate)} diff --git a/src/lib/modules/flow/ui/input/system_audio.svelte b/src/lib/modules/flow/ui/input/system_audio.svelte index 437ff4a8..2b464ef5 100644 --- a/src/lib/modules/flow/ui/input/system_audio.svelte +++ b/src/lib/modules/flow/ui/input/system_audio.svelte @@ -63,20 +63,16 @@ audioMethods.setInputVolume(id, scalar).catch(() => {}); } - function formatPct(p: number): string { - return `${Math.round(p)}%`; - } + import { formatHz, formatPct } from '$lib/components/format'; let volumePct = $derived((data.volume ?? 1) * 100); // System Audio capture is stereo; expose one output handle per channel. const channelCount = 2; - import { formatHz } from '$lib/components/format'; - let srcTooltip = $derived.by(() => { if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `Resampling: 48 kHz → ${formatHz(appSettings.pipelineSampleRate)}`; + return `Resampling: ${formatHz(48_000)} → ${formatHz(appSettings.pipelineSampleRate)}`; }); @@ -130,7 +126,7 @@ checked={data.excludeCurrentApp ?? true} onChange={(v) => flow.updateNodeData(id, { excludeCurrentApp: v })} /> {/if} - 48 kHz · 2 ch · f32 + {formatHz(48_000)} · 2 ch · f32
diff --git a/src/lib/modules/flow/ui/output/file_recording.svelte b/src/lib/modules/flow/ui/output/file_recording.svelte index 3d428cef..f2581dc2 100644 --- a/src/lib/modules/flow/ui/output/file_recording.svelte +++ b/src/lib/modules/flow/ui/output/file_recording.svelte @@ -22,7 +22,7 @@ import { Eye, EyeOff, Folder, FolderOpen, FileRecord, Pulse } from '$lib/components/icons'; import { RECORDING_FORMATS } from '$lib/modules/pipeline/recording-formats'; import NumberStepper from '$lib/components/number_stepper.svelte'; - import { formatHz } from '$lib/components/format'; + import { formatHz, formatKhzValue, formatDuration, formatSize } from '$lib/components/format'; import { onNodeAction, parseHandle } from '$lib/modules/flow/utils'; import SegmentedButtons from '$lib/components/segmented_buttons.svelte'; import WaveformScope from '$lib/components/waveform_scope.svelte'; @@ -436,18 +436,6 @@ return idx >= 0 ? p.slice(idx + 1) : p; } - function formatDuration(sec: number): string { - const minutes = Math.floor(sec / 60); - const remainder = sec - minutes * 60; - return `${minutes}:${remainder.toFixed(1).padStart(4, '0')}`; - } - - function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; - } const WAV_BIT_DEPTHS: { value: WavBitDepth; label: string; sub: string }[] = [ { value: 'i16', label: '16-bit', sub: 'PCM' }, @@ -477,11 +465,6 @@ { value: 'i24', label: '24-bit' } ]; - function kHz(n: number): string { - const k = n / 1000; - return String(Number.isInteger(k) ? k : Number(k.toFixed(3))); - } - // Custom is a UI choice that only reveals the numeric input, so it cannot // be derived from `sampleRate` alone. let customRateSelected = $state(false); @@ -491,7 +474,7 @@ let rateSelection = $derived(customRateSelected || !rateValues.has(String(data.sampleRate ?? 0)) ? 'custom' : String(data.sampleRate)); let rateOptions = $derived( (cfg.rate.rates ?? []) - .map((r) => ({ value: String(r), label: kHz(r) })) + .map((r) => ({ value: String(r), label: formatKhzValue(r) })) .concat(cfg.rate.mode === 'grid+custom' ? [{ value: 'custom', label: 'Custom' }] : []) .map((r) => ({ ...r, disabled: locked })) ); @@ -664,9 +647,7 @@ let targetSampleRate = $derived(data.format.kind === 'opus' || data.format.kind === 'mp3' ? 48_000 : (data.sampleRate ?? 48_000)); let srcTooltip = $derived.by(() => { if (targetSampleRate === appSettings.pipelineSampleRate) return undefined; - const pK = appSettings.pipelineSampleRate >= 1000 ? `${appSettings.pipelineSampleRate / 1000} kHz` : `${appSettings.pipelineSampleRate} Hz`; - const tK = targetSampleRate >= 1000 ? `${targetSampleRate / 1000} kHz` : `${targetSampleRate} Hz`; - return `Resampling: ${pK} → ${tK}`; + return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → ${formatHz(targetSampleRate)}`; }); @@ -809,7 +790,7 @@ {recording ? '● REC' : '○'}
- {formatDuration(durationSec)} + {formatDuration(durationSec, 1)} {#if estSize > 0} · {formatSize(estSize)} diff --git a/src/lib/modules/flow/ui/output/net_sender.svelte b/src/lib/modules/flow/ui/output/net_sender.svelte index 10695d7e..0bee2bab 100644 --- a/src/lib/modules/flow/ui/output/net_sender.svelte +++ b/src/lib/modules/flow/ui/output/net_sender.svelte @@ -3,7 +3,7 @@ import { useNodeConnections, useSvelteFlow, type Node, type NodeProps } from '@xyflow/svelte'; import type { NetSenderNodeData, NetCodec, OpusApplication } from '$lib/modules/pipeline/types'; import { methods as audioMethods } from '$lib/modules/audio/methods'; - import { formatHz, formatRate } from '$lib/components/format'; + import { formatHz, formatRate, formatKhzValue } from '$lib/components/format'; import { parseHandle } from '$lib/modules/flow/utils'; import Wrapper from '../node.svelte'; import { ArrowUpload } from '$lib/components/icons'; @@ -19,7 +19,7 @@ let srcTooltip = $derived.by(() => { if (data.codec === 'opus') { if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → 48 kHz`; + return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → ${formatHz(48_000)}`; } const targetSr = data.sampleRate ?? appSettings.pipelineSampleRate; if (targetSr === appSettings.pipelineSampleRate) return undefined; @@ -108,11 +108,6 @@ flow.updateNodeData(id, { opusApplication: app }); } - function kHz(n: number): string { - const k = n / 1000; - return String(Number.isInteger(k) ? k : Number(k.toFixed(3))); - } - const PRESET_RATES = [44_100, 48_000, 88_200, 96_000]; const rateValues = new Set(PRESET_RATES.map(String)); @@ -126,7 +121,7 @@ let rateOptions = $derived([ { value: 'auto', label: 'Auto' }, - ...PRESET_RATES.map((r) => ({ value: String(r), label: kHz(r) })), + ...PRESET_RATES.map((r) => ({ value: String(r), label: formatKhzValue(r) })), { value: 'custom', label: 'Custom' } ]); diff --git a/src/lib/modules/flow/ui/output/speaker.svelte b/src/lib/modules/flow/ui/output/speaker.svelte index 5d1bb91e..6b70412b 100644 --- a/src/lib/modules/flow/ui/output/speaker.svelte +++ b/src/lib/modules/flow/ui/output/speaker.svelte @@ -74,11 +74,7 @@ await volume.set(pct / 100); } - import { formatHz } from '$lib/components/format'; - - function formatPct(p: number): string { - return `${Math.round(p)}%`; - } + import { formatHz, formatPct } from '$lib/components/format'; let volumePct = $derived((volume.scalar ?? 0) * 100); // The graph mix is metered before the device attenuates it; without the diff --git a/src/lib/modules/updater/ui/update_banner.svelte b/src/lib/modules/updater/ui/update_banner.svelte index e543a628..77225dfb 100644 --- a/src/lib/modules/updater/ui/update_banner.svelte +++ b/src/lib/modules/updater/ui/update_banner.svelte @@ -6,6 +6,7 @@ import Markdown from '$lib/components/markdown.svelte'; import { Checkmark } from '$lib/components/icons'; import { getCachedAppInfo } from '$lib/modules/app_info'; + import { formatBytes } from '$lib/components/format'; const info = getCachedAppInfo(); @@ -27,9 +28,6 @@ return Math.min(100, Math.round((s.downloaded / s.total) * 100)); } - function mb(bytes: number): string { - return `${(bytes / 1_000_000).toFixed(1)} MB`; - } function dismiss() { updaterStore.state = { phase: 'idle' }; @@ -76,10 +74,10 @@
- {s.total ? `${progressPct()}%` : mb(s.downloaded)} + {s.total ? `${progressPct()}%` : formatBytes(s.downloaded)} - {s.total ? `${mb(s.downloaded)} of ${mb(s.total)}` : 'Size unknown'} + {s.total ? `${formatBytes(s.downloaded)} of ${formatBytes(s.total)}` : 'Size unknown'}
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index ced6a28b..92ab44c1 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -9,6 +9,7 @@ import { themeStore, type ThemePref } from '$lib/modules/theme/stores'; import { appSettings, GRID_SIZES, SNAPSHOT_LIMITS, PIPELINE_SAMPLE_RATE_PRESETS } from '$lib/modules/settings/stores.svelte'; import NumberStepper from '$lib/components/number_stepper.svelte'; + import { formatHz } from '$lib/components/format'; import PresetsSection from './_presets_section.svelte'; const SHAPES: { value: EdgeShape; label: string; hint: string }[] = [ @@ -246,7 +247,7 @@ ? 'border-neutral-900 bg-neutral-200 text-theme' : 'border-neutral-400 bg-neutral-100 text-neutral-1000 hover:bg-neutral-200' ]}> - {rate >= 1000 ? `${rate / 1000} kHz` : `${rate} Hz`} + {formatHz(rate)} {/each} {/each}
From df284c89a26529b384d6a8c0d138c67b2e81ea68 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:30:26 +0300 Subject: [PATCH 8/9] fix(audio): bypass capture normalizer for AudioFile and restore native rate in DAG --- src-tauri/src/audio/pipeline/dag.rs | 2 +- src-tauri/src/audio/pipeline/input/mod.rs | 6 ++++++ src-tauri/src/audio/pipeline/mod.rs | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 5d430aa1..2daaa437 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -1213,7 +1213,7 @@ 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 + DSP_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; diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index bac23e76..4907109c 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -204,6 +204,12 @@ pub(super) fn start_input_stream( meter: Option, app: &AppHandle, ) -> AppResult { + // 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); + } let sample_rate = resolved.sample_rate(); let channels = resolved.native_channels() as usize; let (raw_producer, mut raw_consumer) = diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index 988c3738..31d550db 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -639,7 +639,11 @@ impl ActivePipeline { input_native_channels.insert(inp.id.clone(), state.channels); } else { let resolved = resolve_input(inp)?; - input_native_sr.insert(inp.id.clone(), pipeline_sr); + let sr = match &resolved { + ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, + _ => pipeline_sr, + }; + input_native_sr.insert(inp.id.clone(), sr); input_native_channels.insert(inp.id.clone(), resolved.native_channels()); input_runtime.insert(inp.id.clone(), resolved); } @@ -974,7 +978,10 @@ impl ActivePipeline { let resolved = input_runtime.remove(&input_id).ok_or_else(|| { AppError::Validation(format!("input runtime missing for {input_id}")) })?; - let sample_rate = pipeline_sr; + let sample_rate = match &resolved { + ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, + _ => pipeline_sr, + }; let channels = resolved.native_channels(); let meter = new_input_meters .remove(&input_id) From 96a2722d894d9d1a94d751d996d7715c825dc7a0 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:42:16 +0300 Subject: [PATCH 9/9] fix(audio): fix speaker worker resampling block size and cut consumer buffer sizing --- src-tauri/src/audio/pipeline/dag.rs | 11 +++++++---- src-tauri/src/audio/pipeline/output/mod.rs | 12 ++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 2daaa437..6cbb7f62 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -1670,6 +1670,11 @@ fn ring_source( channels, )?) }; + let out_max = resampler + .as_ref() + .map(|r| r.out_max()) + .unwrap_or(RESAMPLE_CHUNK); + let staging_cap = (out_max * 4 + DSP_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; let input_samples_per_block = input_frames_per_block as usize * channels; @@ -1698,10 +1703,8 @@ fn ring_source( resampler, input_staging: Vec::with_capacity((RESAMPLE_CHUNK + SPLICE_FADE_FRAMES) * channels + 8), splice_tmp: Vec::with_capacity(SPLICE_FADE_FRAMES * channels), - out_pending: StagingRing::with_capacity( - RESAMPLE_CHUNK * channels * 4 + DSP_BLOCK_FRAMES * channels, - ), - chunk_tmp: Vec::with_capacity(RESAMPLE_CHUNK * channels + 8), + out_pending: StagingRing::with_capacity(staging_cap), + chunk_tmp: Vec::with_capacity(out_max * channels), out_buf: vec![0.0; DSP_BLOCK_FRAMES * channels], input_samples_per_block, realtime, diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index 75e06178..432f8aaa 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -20,7 +20,7 @@ use crate::audio::resample::MultiResampler; use crate::audio::streams; use crate::error::{AppError, AppResult}; -use super::dag::{OutputGraph, DSP_BLOCK_FRAMES, RESAMPLE_CHUNK}; +use super::dag::{OutputGraph, DSP_BLOCK_FRAMES}; use super::worker::{dsp_worker, WorkerCtrl}; #[cfg(target_os = "macos")] @@ -394,7 +394,7 @@ pub(super) fn spawn_speaker_worker( Some(MultiResampler::new( pipeline_rate, initial_device_rate, - RESAMPLE_CHUNK, + DSP_BLOCK_FRAMES, channels, )?) }; @@ -402,7 +402,7 @@ pub(super) fn spawn_speaker_worker( 0.0_f32; resampler .as_ref() - .map(|r| r.out_max() * channels * (DSP_BLOCK_FRAMES / RESAMPLE_CHUNK)) + .map(|r| r.out_max() * channels) .unwrap_or(DSP_BLOCK_FRAMES * channels) ]; let join = thread::Builder::new() @@ -412,11 +412,7 @@ pub(super) fn spawn_speaker_worker( worker.run(stop_thread, clock, |block| { update_meter(&meter, block, channels); let device_block = if let Some(resampler) = &mut resampler { - let mut written = 0; - for chunk in block.chunks_exact(RESAMPLE_CHUNK * channels) { - written += - resampler.process_chunk_into(chunk, &mut resampled[written..])?; - } + let written = resampler.process_chunk_into(block, &mut resampled)?; &resampled[..written] } else { block