diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 7a561b2e..3f67ab50 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -103,6 +103,23 @@ A backend that cannot support the feature returns an error; it does not substitu - Time-critical audio work belongs to MMCSS/Pro Audio scheduling. Do not use generic process or thread priority boosts as a substitute, and never block, allocate or perform COM/UI work in the render callback. - COM initialization and endpoint management remain on control threads. The callback exchanges audio and status only through preallocated buffers and lock-free state. +### Capture & Sample Rate Negotiation per Platform + +App Audio and System Audio capture adhere to OS-specific hardware/driver semantics: + +- **macOS (CoreAudio Process Tap / ScreenCaptureKit)**: + - Process Taps (`AudioHardwareCreateProcessTap`) tap directly into the system audio server buffer before delivery to hardware. + - Rate is strictly dictated by the active hardware output device's nominal sample rate (configured in Audio MIDI Setup). CoreAudio taps do not provide arbitrary client-side resampling. + - _In plain terms_: the sample rate shown in the App/System Audio UI will always equal your macOS output device's sample rate (not the pipeline setting). When Pipeline Sample Rate matches the output device, capture is bit-transparent (no resamplers active). +- **Windows (WASAPI)**: + - _System Audio_: standard WASAPI Loopback on the default render endpoint. The sample rate matches the physical output endpoint's configured mix format (`IAudioClient::GetMixFormat`). + - _App Audio_: process loopback via `VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK`. The virtual capture endpoint accepts any target sample rate at initialization; we initialize it directly with the Pipeline sample rate. + - _In plain terms_: App Audio runs at whatever sample rate is configured in your pipeline settings (zero internal resamplers). System Audio runs at your default Windows playback device's sample rate. +- **Linux (PipeWire)**: + - Both App and System Audio negotiate the Pipeline sample rate directly via SPA format parameters (`spa::param::ParamType::Format`). + - PipeWire natively delivers frames matching the requested pipeline quantum/rate without extra application-level resampling if negotiated. + - _In plain terms_: both App Audio and System Audio sample rates will always equal whatever sample rate is configured in your pipeline settings. + ### Cross-platform output contract - The physical stream always receives its native channel width. Sample-rate conversion may stop at the highest routed channel; remaining channels are explicitly zero-filled before the device ring. diff --git a/src-tauri/src/audio/capture/mod.rs b/src-tauri/src/audio/capture/mod.rs index 2fca9006..9f9728d6 100644 --- a/src-tauri/src/audio/capture/mod.rs +++ b/src-tauri/src/audio/capture/mod.rs @@ -16,3 +16,51 @@ pub use linux::Capture; mod windows; #[cfg(target_os = "windows")] pub use windows::{loopback_mix_rate, Capture}; + +use crate::audio::device::NativeDeviceInfo; +use crate::error::AppResult; + +pub fn capture_device_info( + kind: &str, + pipeline_sample_rate: Option, +) -> AppResult { + #[cfg(target_os = "macos")] + { + let _ = (kind, pipeline_sample_rate); + Ok(NativeDeviceInfo { + sample_rate: capture_rate(), + channels: 2, + sample_format: "f32", + }) + } + #[cfg(target_os = "windows")] + { + let sample_rate = match kind { + "system" => loopback_mix_rate()?, + _ => pipeline_sample_rate.unwrap_or(48_000), + }; + Ok(NativeDeviceInfo { + sample_rate, + channels: 2, + sample_format: "f32", + }) + } + #[cfg(target_os = "linux")] + { + let _ = kind; + Ok(NativeDeviceInfo { + sample_rate: pipeline_sample_rate.unwrap_or(48_000), + channels: 2, + sample_format: "f32", + }) + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + let _ = (kind, pipeline_sample_rate); + Ok(NativeDeviceInfo { + sample_rate: 48_000, + channels: 2, + sample_format: "f32", + }) + } +} diff --git a/src-tauri/src/audio/capture/windows.rs b/src-tauri/src/audio/capture/windows.rs index 7a29ae0e..8ae1f913 100644 --- a/src-tauri/src/audio/capture/windows.rs +++ b/src-tauri/src/audio/capture/windows.rs @@ -55,7 +55,7 @@ impl Capture { pub fn start_app( bundle_id: &str, - _sample_rate: u32, + sample_rate: u32, _channels: u32, bridge: BroadcastRx, ) -> AppResult { @@ -63,7 +63,7 @@ impl Capture { AppError::Stream(format!("no active audio session found for {bundle_id:?}")) })?; Ok(spawn(bridge, move |stop, bridge| { - run_process_loopback(pid, stop, bridge) + run_process_loopback(pid, sample_rate, stop, bridge) })) } } @@ -154,8 +154,14 @@ fn run_loopback(stop: Arc, bridge: BroadcastRx) -> AppResult<()> { } // Per-app capture via the Win10 2004+ process-loopback activation. The virtual -// device has no mix format, so we ask for 48 kHz stereo f32 explicitly. -fn run_process_loopback(pid: u32, stop: Arc, bridge: BroadcastRx) -> AppResult<()> { +// device has no fixed mix format, so we initialize it directly with the desired +// pipeline sample rate to avoid unnecessary resampling. +fn run_process_loopback( + pid: u32, + sample_rate: u32, + stop: Arc, + bridge: BroadcastRx, +) -> AppResult<()> { unsafe { ensure_com(); let mut params = AUDIOCLIENT_ACTIVATION_PARAMS { @@ -200,11 +206,16 @@ fn run_process_loopback(pid: u32, stop: Arc, bridge: BroadcastRx) -> .cast() .map_err(com_err)?; + let rate = if sample_rate > 0 { + sample_rate + } else { + TARGET_RATE + }; let wfx = WAVEFORMATEX { wFormatTag: 3, // WAVE_FORMAT_IEEE_FLOAT nChannels: TARGET_CHANNELS, - nSamplesPerSec: TARGET_RATE, - nAvgBytesPerSec: TARGET_RATE * TARGET_CHANNELS as u32 * 4, + nSamplesPerSec: rate, + nAvgBytesPerSec: rate * TARGET_CHANNELS as u32 * 4, nBlockAlign: TARGET_CHANNELS * 4, wBitsPerSample: 32, cbSize: 0, @@ -222,13 +233,7 @@ fn run_process_loopback(pid: u32, stop: Arc, bridge: BroadcastRx) -> let capture: IAudioCaptureClient = client.GetService().map_err(com_err)?; client.Start().map_err(com_err)?; - let r = pump( - &capture, - TARGET_CHANNELS as usize, - TARGET_RATE, - &stop, - bridge, - ); + let r = pump(&capture, TARGET_CHANNELS as usize, rate, &stop, bridge); let _ = client.Stop(); r } diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index 97c19579..bbffaa26 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -198,6 +198,18 @@ pub(super) fn start_audio_file( Ok(InputHandle::AudioFile(reader)) } +fn input_resampler( + native_rate: u32, + target_sample_rate: u32, + channels: usize, +) -> AppResult> { + if native_rate == target_sample_rate { + Ok(None) + } else { + MultiResampler::new(native_rate, target_sample_rate, RESAMPLE_CHUNK, channels).map(Some) + } +} + /// 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), @@ -238,14 +250,9 @@ pub(super) fn start_input_stream( let mut native_rate = sample_rate; #[cfg(not(any(target_os = "macos", target_os = "linux")))] let 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 resampler = match input_resampler(native_rate, target_sample_rate, channels) { + Ok(resampler) => resampler, + Err(_) => return, }; let mut output_buf = Vec::with_capacity( resampler @@ -271,12 +278,9 @@ pub(super) fn start_input_stream( } } native_rate = rate; - resampler = if rate == target_sample_rate { - None - } else { - MultiResampler::new(rate, target_sample_rate, RESAMPLE_CHUNK, channels) - .ok() - }; + resampler = input_resampler(rate, target_sample_rate, channels) + .ok() + .flatten(); output_buf = Vec::with_capacity( resampler .as_ref() @@ -322,3 +326,58 @@ pub(super) fn start_input_stream( join: Some(join), })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_input_resampler_bypassed_when_rates_match() { + // When native_rate == target_sample_rate (e.g. 96 kHz App Audio and 96 kHz pipeline), + // no resampler must be allocated, ensuring bit-transparent passthrough with zero quality loss. + for rate in [44_100, 48_000, 96_000, 192_000] { + let resampler = input_resampler(rate, rate, 2).unwrap(); + assert!( + resampler.is_none(), + "resampler should be None for matching rate {rate}" + ); + } + } + + #[test] + fn test_input_resampler_only_allocated_when_rates_differ() { + let resampler = input_resampler(48_000, 96_000, 2).unwrap(); + assert!( + resampler.is_some(), + "resampler must be Some when rates differ" + ); + } + + #[test] + fn test_input_bit_transparent_sample_passthrough() { + // Verify that when resampler is None, samples pass through bit-for-bit without any modification. + let mut input_buf = vec![0.0f32; RESAMPLE_CHUNK * 2]; + for (i, sample) in input_buf.iter_mut().enumerate() { + *sample = ((i as f32) * 0.001).sin(); + } + + let mut resampler = input_resampler(96_000, 96_000, 2).unwrap(); + let mut output_buf = Vec::new(); + + let normalized = if let Some(resampler) = &mut resampler { + output_buf.clear(); + resampler + .process_chunk(&input_buf, &mut output_buf) + .unwrap(); + output_buf.as_slice() + } else { + input_buf.as_slice() + }; + + assert_eq!(normalized.len(), input_buf.len()); + // Verify bit-exact equality (no rounding, no sinc filtering) + for (a, b) in normalized.iter().zip(input_buf.iter()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + } +} diff --git a/src-tauri/src/audio/pipeline/input/windows.rs b/src-tauri/src/audio/pipeline/input/windows.rs index 8035b4b5..ad2ccb82 100644 --- a/src-tauri/src/audio/pipeline/input/windows.rs +++ b/src-tauri/src/audio/pipeline/input/windows.rs @@ -15,11 +15,12 @@ use crate::error::AppResult; use super::super::native::native_config; use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; -const LOOPBACK_FALLBACK_RATE: u32 = 48_000; - const LOOPBACK_CHANNELS: usize = 2; -pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult { +pub(in crate::audio::pipeline) fn resolve_input( + inp: &ValidInput, + target_sample_rate: u32, +) -> AppResult { match &inp.spec { InputSpec::Microphone { device_id } => { let device = device::find(DeviceKind::Input, device_id)?; @@ -35,12 +36,11 @@ pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult Ok(ResolvedInput::SystemAudio { - sample_rate: crate::audio::capture::loopback_mix_rate() - .unwrap_or(LOOPBACK_FALLBACK_RATE), + sample_rate: crate::audio::capture::loopback_mix_rate()?, exclude_current_app: *exclude_current_app, }), InputSpec::AppAudio { bundle_id } => Ok(ResolvedInput::AppAudio { - sample_rate: LOOPBACK_FALLBACK_RATE, + sample_rate: target_sample_rate, bundle_id: bundle_id.clone(), }), InputSpec::AudioFile { file_path } => resolve_audio_file(file_path), diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index ba15eb97..2b62e8e5 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -638,9 +638,9 @@ impl ActivePipeline { input_native_sr.insert(inp.id.clone(), state.sample_rate); input_native_channels.insert(inp.id.clone(), state.channels); } else { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] let resolved = resolve_input(inp, pipeline_sr)?; - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] let resolved = resolve_input(inp)?; let sr = match &resolved { ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index e8b57e10..80b8bb9f 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -330,6 +330,18 @@ pub(super) fn speaker_ring( (producer, fill, level, target, io) } +fn output_resampler( + pipeline_rate: u32, + device_rate: u32, + channels: usize, +) -> AppResult> { + if pipeline_rate == device_rate { + Ok(None) + } else { + FixedRateResampler::new(pipeline_rate, device_rate, DSP_BLOCK_FRAMES, channels).map(Some) + } +} + // Shared by both platforms' `start_speaker_stream`: a device-fill-paced // worker that mixes the output sub-graph and bulk-pushes blocks into the // speaker ring. @@ -354,16 +366,7 @@ pub(super) fn spawn_speaker_worker( target, )); let initial_device_rate = device_sample_rate.load(Ordering::Relaxed); - let mut resampler = if initial_device_rate == pipeline_rate { - None - } else { - Some(FixedRateResampler::new( - pipeline_rate, - initial_device_rate, - DSP_BLOCK_FRAMES, - channels, - )?) - }; + let mut resampler = output_resampler(pipeline_rate, initial_device_rate, channels)?; let mut resampled = vec![ 0.0_f32; resampler @@ -594,3 +597,61 @@ pub(super) fn start_recorder_worker( wave, )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::pipeline::dag::RESAMPLE_CHUNK; + + #[test] + fn test_output_resampler_bypassed_when_rates_match() { + // When initial_device_rate == pipeline_rate (e.g. 96 kHz pipeline and 96 kHz speaker), + // no output resampler must be allocated, preserving 1:1 bit-transparent playback. + for rate in [44_100, 48_000, 96_000, 192_000] { + let resampler = output_resampler(rate, rate, 2).unwrap(); + assert!( + resampler.is_none(), + "output resampler should be None for matching rate {rate}" + ); + } + } + + #[test] + fn test_output_resampler_only_allocated_when_rates_differ() { + let resampler = output_resampler(96_000, 48_000, 2).unwrap(); + assert!( + resampler.is_some(), + "output resampler must be Some when rates differ" + ); + } + + #[test] + fn test_output_bit_transparent_sample_passthrough() { + // Verify that when output resampler is None, samples pass directly to the device buffer. + let total_samples = RESAMPLE_CHUNK * 2; + let mut block = vec![0.0f32; total_samples]; + for (i, sample) in block.iter_mut().enumerate() { + *sample = ((i as f32) * 0.005).cos(); + } + + let mut resampler = output_resampler(96_000, 96_000, 2).unwrap(); + let mut resampled = Vec::new(); + let active_channels = 2; + let mut resampled_channels = 0; + + let device_block = if let Some(resampler) = &mut resampler { + resampled_channels = resampled_channels.max(active_channels); + let written = resampler + .process_chunk_into(&block[..total_samples], resampled_channels, &mut resampled) + .unwrap(); + &resampled[..written] + } else { + &block[..total_samples] + }; + + assert_eq!(device_block.len(), total_samples); + for (a, b) in device_block.iter().zip(block.iter()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5c31ab26..a33a451c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -263,6 +263,14 @@ pub fn device_info(kind: DeviceKind, name: String) -> AppResult, +) -> AppResult { + crate::audio::capture::capture_device_info(&kind, pipeline_sample_rate) +} + #[tauri::command] pub fn check_capture_permission() -> CapturePermission { let state = permission::capture(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e082372..9e5e5ae7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -326,6 +326,7 @@ pub fn run() { commands::uninstall_virtual_driver, commands::apply_virtual_devices, commands::device_info, + commands::capture_device_info, commands::check_capture_permission, commands::path_exists, commands::read_file_peaks, diff --git a/src/lib/modules/audio/device_info.svelte.ts b/src/lib/modules/audio/device_info.svelte.ts new file mode 100644 index 00000000..5edd6571 --- /dev/null +++ b/src/lib/modules/audio/device_info.svelte.ts @@ -0,0 +1,174 @@ +import { onDestroy, onMount } from 'svelte'; +import { methods } from './methods'; +import { audioStore } from './stores.svelte'; +import { appSettings } from '$lib/modules/settings/stores.svelte'; +import { formatHz } from '$lib/components/format'; +import type { NativeDeviceInfo } from './types'; + +export type DeviceInfoTarget = { kind: 'input' | 'output'; deviceId: () => string | null } | { kind: 'system' | 'app'; pipelineRate?: () => number }; + +export interface DeviceInfoState { + readonly info: NativeDeviceInfo | null; + readonly isLoading: boolean; + readonly sampleRate: number; + readonly channels: number; + readonly sampleFormat: string; + readonly specText: string; + readonly resamplingTooltip: string | undefined; + refresh(): Promise; +} + +/** + * Universally tracks the native sample rate, channels, and format of an audio + * device or capture source, keeping it reactively up to date with hardware / OS + * changes (e.g. sample rate changes in Audio MIDI Setup or Windows Sound Settings). + */ +export function useDeviceInfo(target: DeviceInfoTarget): DeviceInfoState { + let info = $state(null); + let isLoading = $state(true); + let requestId = 0; + let currentKey: string | null = null; + + async function query(): Promise { + const id = ++requestId; + if ('deviceId' in target) { + const deviceId = target.deviceId(); + if (!deviceId) { + currentKey = null; + info = null; + isLoading = false; + return; + } + const key = `${target.kind}:${deviceId}`; + if (currentKey !== key) { + currentKey = key; + info = null; + } + isLoading = true; + try { + const r = await methods.deviceInfo(target.kind, deviceId); + if (id === requestId && target.deviceId() === deviceId) { + info = r; + } + } catch { + if (id === requestId && target.deviceId() === deviceId) { + info = null; + } + } finally { + if (id === requestId) isLoading = false; + } + } else { + const rate = target.pipelineRate ? target.pipelineRate() : appSettings.pipelineSampleRate; + const key = `${target.kind}:${rate}`; + if (currentKey !== key) { + currentKey = key; + info = null; + } + isLoading = true; + try { + const r = await methods.captureDeviceInfo(target.kind, rate); + if (id === requestId) info = r; + } catch { + if (id === requestId) info = null; + } finally { + if (id === requestId) isLoading = false; + } + } + } + + // Re-run whenever reactive dependencies change + $effect(() => { + if ('deviceId' in target) { + const id = target.deviceId(); + const _devs = target.kind === 'input' ? audioStore.inputDevices : audioStore.outputDevices; + const _running = audioStore.isRunning; + if (!id) { + info = null; + isLoading = false; + return; + } + void query(); + } else { + const _devs = audioStore.outputDevices; + const _running = audioStore.isRunning; + const _rate = target.pipelineRate ? target.pipelineRate() : appSettings.pipelineSampleRate; + void query(); + } + }); + + let timer: ReturnType | undefined; + + onMount(() => { + const onRefresh = () => { + void query(); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('focus', onRefresh); + } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onRefresh); + timer = setInterval(() => { + if (document.visibilityState === 'visible') { + void query(); + } + }, 2500); + } + + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener('focus', onRefresh); + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onRefresh); + } + if (timer) clearInterval(timer); + }; + }); + + onDestroy(() => { + if (timer) clearInterval(timer); + }); + + const sampleRate = $derived(info?.sampleRate ?? 48_000); + const channels = $derived(info?.channels ?? 2); + const sampleFormat = $derived(info?.sampleFormat ?? 'f32'); + const specText = $derived(`${formatHz(sampleRate)} · ${channels} ch · ${sampleFormat}`); + + const resamplingTooltip = $derived.by(() => { + if (!info) return undefined; + if (target.kind === 'output') { + if (info.sampleRate === appSettings.pipelineSampleRate) return undefined; + return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → ${formatHz(info.sampleRate)}`; + } else { + const sr = info.sampleRate; + if (sr === appSettings.pipelineSampleRate) return undefined; + return `Resampling: ${formatHz(sr)} → ${formatHz(appSettings.pipelineSampleRate)}`; + } + }); + + return { + get info() { + return info; + }, + get isLoading() { + return isLoading; + }, + get sampleRate() { + return sampleRate; + }, + get channels() { + return channels; + }, + get sampleFormat() { + return sampleFormat; + }, + get specText() { + return specText; + }, + get resamplingTooltip() { + return resamplingTooltip; + }, + refresh: query + }; +} diff --git a/src/lib/modules/audio/index.ts b/src/lib/modules/audio/index.ts index 8f3168cc..e0225468 100644 --- a/src/lib/modules/audio/index.ts +++ b/src/lib/modules/audio/index.ts @@ -9,5 +9,6 @@ const Audio = { ui }; +export { useDeviceInfo, type DeviceInfoTarget, type DeviceInfoState } from './device_info.svelte'; export type { types }; export default Audio; diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts index 4ab70c43..827ce139 100644 --- a/src/lib/modules/audio/methods.ts +++ b/src/lib/modules/audio/methods.ts @@ -36,6 +36,8 @@ export const methods = { listAudioApplications: (): Promise => invoke('list_audio_applications'), getAppIcons: (bundleIds: string[]): Promise> => invoke>('get_app_icons', { bundleIds }), deviceInfo: (kind: 'input' | 'output', name: string): Promise => invoke('device_info', { kind, name }), + captureDeviceInfo: (kind: 'system' | 'app', pipelineSampleRate?: number): Promise => + invoke('capture_device_info', { kind, pipelineSampleRate }), checkCapturePermission: (): Promise => invoke('check_capture_permission'), pathExists: (path: string): Promise => invoke('path_exists', { path }), /** Min/max peak bins read from a WAV/AIFF file for a requested frame range. */ diff --git a/src/lib/modules/flow/ui/input/app_audio.svelte b/src/lib/modules/flow/ui/input/app_audio.svelte index 1d61852a..2217b42c 100644 --- a/src/lib/modules/flow/ui/input/app_audio.svelte +++ b/src/lib/modules/flow/ui/input/app_audio.svelte @@ -3,6 +3,7 @@ import type { AppAudioNodeData } from '$lib/modules/pipeline/types'; import { audioStore } from '$lib/modules/audio/stores.svelte'; import { methods as audioMethods } from '$lib/modules/audio/methods'; + import { useDeviceInfo } from '$lib/modules/audio'; import Wrapper from '../node.svelte'; import InputMeter from './_input_meter.svelte'; import Slider from '../effect/_slider.svelte'; @@ -10,8 +11,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'; - import { formatHz, formatPct } from '$lib/components/format'; + import { formatPct } from '$lib/components/format'; type AppAudioNodeType = Node; let { id, data }: NodeProps = $props(); @@ -19,6 +19,8 @@ const flow = useSvelteFlow(); const updateNodeInternals = useUpdateNodeInternals(); + const devInfo = useDeviceInfo({ kind: 'app' }); + function setApp(value: string | null) { flow.updateNodeData(id, { bundleId: value }); } @@ -30,7 +32,8 @@ let unlistenRefresh: (() => void) | undefined; onMount(() => { unlistenRefresh = onNodeAction(id, 'refresh', () => { - refresh().catch(() => {}); + void refresh(); + void devInfo.refresh(); }); }); onDestroy(() => { @@ -54,12 +57,12 @@ let volumePct = $derived((data.volume ?? 1) * 100); - // App Audio capture is stereo; expose one output handle per channel. - const channelCount = 2; + let channelCount = $derived(devInfo.channels); + let srcTooltip = $derived(devInfo.resamplingTooltip); - let srcTooltip = $derived.by(() => { - if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `Resampling: ${formatHz(48_000)} → ${formatHz(appSettings.pipelineSampleRate)}`; + $effect(() => { + const _ = channelCount; + updateNodeInternals(id); }); @@ -79,8 +82,12 @@ {#if missing} App no longer running + {:else if data.bundleId && devInfo.info} + {devInfo.specText} {:else if data.bundleId} - {formatHz(48_000)} · 2 ch · f32 + + {devInfo.isLoading ? 'Detecting format…' : 'Unable to detect format'} + {/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 051218d0..9b22e538 100644 --- a/src/lib/modules/flow/ui/input/microphone.svelte +++ b/src/lib/modules/flow/ui/input/microphone.svelte @@ -5,7 +5,7 @@ import { audioStore } from '$lib/modules/audio/stores.svelte'; import { methods as audioMethods } from '$lib/modules/audio/methods'; import { deviceVolume } from '$lib/modules/audio/device_volume.svelte'; - import type { NativeDeviceInfo } from '$lib/modules/audio/types'; + import { useDeviceInfo } from '$lib/modules/audio'; import Wrapper from '../node.svelte'; import Slider from '../effect/_slider.svelte'; import InputMeter from './_input_meter.svelte'; @@ -14,7 +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'; + import { formatPct } from '$lib/components/format'; const isWindows = platform() === 'windows'; @@ -24,7 +24,13 @@ const flow = useSvelteFlow(); const updateNodeInternals = useUpdateNodeInternals(); - let info = $state(null); + let options = $derived(audioStore.inputDevices.map((d) => ({ value: d.id, label: d.name }))); + let missing = $derived(!!data.deviceId && !audioStore.inputDevices.some((d) => d.id === data.deviceId)); + + const devInfo = useDeviceInfo({ + kind: 'input', + deviceId: () => (missing ? null : (data.deviceId ?? null)) + }); // unsupported: device has no software-settable gain (hardware-knob mics). const gain = deviceVolume('input', () => (missing ? null : (data.deviceId ?? null))); @@ -39,46 +45,25 @@ let unlistenRefresh: (() => void) | undefined; onMount(() => { - unlistenRefresh = onNodeAction(id, 'refresh', () => refresh()); + unlistenRefresh = onNodeAction(id, 'refresh', () => { + void refresh(); + void devInfo.refresh(); + }); }); onDestroy(() => unlistenRefresh?.()); - let options = $derived(audioStore.inputDevices.map((d) => ({ value: d.id, label: d.name }))); - let missing = $derived(!!data.deviceId && !audioStore.inputDevices.some((d) => d.id === data.deviceId)); - - $effect(() => { - const deviceId = data.deviceId; - if (!deviceId || missing) { - info = null; - return; - } - let cancelled = false; - audioMethods - .deviceInfo('input', deviceId) - .then((r) => { - if (!cancelled) info = r; - }) - .catch(() => { - if (!cancelled) info = null; - }); - return () => { - cancelled = true; - }; - }); - async function setGainPct(pct: number) { await gain.set(pct / 100); } - import { formatHz, formatPct } from '$lib/components/format'; - let gainPct = $derived((gain.scalar ?? 0) * 100); - let channelCount = $derived(Math.max(info?.channels ?? 2, 1)); + let channelCount = $derived(Math.max(devInfo.channels, 1)); + let srcTooltip = $derived(devInfo.resamplingTooltip); - let srcTooltip = $derived.by(() => { - if (!info || info.sampleRate === appSettings.pipelineSampleRate) return undefined; - return `Resampling: ${formatHz(info.sampleRate)} → ${formatHz(appSettings.pipelineSampleRate)}`; + $effect(() => { + const _ = channelCount; + updateNodeInternals(id); }); @@ -100,9 +85,13 @@ {#if missing} Selected device not available - {:else if info} + {:else if devInfo.info} - {formatHz(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} + {devInfo.specText} + + {:else if data.deviceId} + + {devInfo.isLoading ? 'Detecting format…' : 'Unable to detect format'} {/if} diff --git a/src/lib/modules/flow/ui/input/system_audio.svelte b/src/lib/modules/flow/ui/input/system_audio.svelte index 2b464ef5..2d8587dd 100644 --- a/src/lib/modules/flow/ui/input/system_audio.svelte +++ b/src/lib/modules/flow/ui/input/system_audio.svelte @@ -4,6 +4,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import type { SystemAudioNodeData } from '$lib/modules/pipeline/types'; import { methods as audioMethods } from '$lib/modules/audio/methods'; + import { useDeviceInfo } from '$lib/modules/audio'; import type { CapturePermission } from '$lib/modules/audio/types'; import { PREVIEW_CTX } from '$lib/modules/flow/utils'; import Wrapper from '../node.svelte'; @@ -12,7 +13,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'; + import { formatPct } from '$lib/components/format'; // Self-exclusion is macOS-only; Linux (PipeWire) and Windows (WASAPI // loopback) need it neither. @@ -26,6 +27,8 @@ const flow = useSvelteFlow(); const updateNodeInternals = useUpdateNodeInternals(); + const devInfo = useDeviceInfo({ kind: 'system' }); + let permission = $state(null); let checking = $state(false); @@ -63,16 +66,14 @@ audioMethods.setInputVolume(id, scalar).catch(() => {}); } - 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; + let channelCount = $derived(devInfo.channels); + let srcTooltip = $derived(devInfo.resamplingTooltip); - let srcTooltip = $derived.by(() => { - if (appSettings.pipelineSampleRate === 48_000) return undefined; - return `Resampling: ${formatHz(48_000)} → ${formatHz(appSettings.pipelineSampleRate)}`; + $effect(() => { + const _ = channelCount; + updateNodeInternals(id); }); @@ -126,7 +127,13 @@ checked={data.excludeCurrentApp ?? true} onChange={(v) => flow.updateNodeData(id, { excludeCurrentApp: v })} /> {/if} - {formatHz(48_000)} · 2 ch · f32 + {#if devInfo.info} + {devInfo.specText} + {:else} + + {devInfo.isLoading ? 'Detecting format…' : 'Unable to detect format'} + + {/if} diff --git a/src/lib/modules/flow/ui/output/speaker.svelte b/src/lib/modules/flow/ui/output/speaker.svelte index 6b70412b..297f49ad 100644 --- a/src/lib/modules/flow/ui/output/speaker.svelte +++ b/src/lib/modules/flow/ui/output/speaker.svelte @@ -5,7 +5,7 @@ import { audioStore } from '$lib/modules/audio/stores.svelte'; import { methods as audioMethods } from '$lib/modules/audio/methods'; import { deviceVolume } from '$lib/modules/audio/device_volume.svelte'; - import type { NativeDeviceInfo } from '$lib/modules/audio/types'; + import { useDeviceInfo } from '$lib/modules/audio'; import Wrapper from '../node.svelte'; import InputMeter from '../input/_input_meter.svelte'; import Slider from '../effect/_slider.svelte'; @@ -14,7 +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'; + import { formatPct } from '$lib/components/format'; const isWindows = platform() === 'windows'; const virtualDevicesLabel = isWindows ? 'Use virtual microphone' : 'Add virtual device'; @@ -25,7 +25,13 @@ const flow = useSvelteFlow(); const updateNodeInternals = useUpdateNodeInternals(); - let info = $state(null); + let options = $derived(audioStore.outputDevices.map((d) => ({ value: d.id, label: d.name }))); + let missing = $derived(!!data.deviceId && !audioStore.outputDevices.some((d) => d.id === data.deviceId)); + + const devInfo = useDeviceInfo({ + kind: 'output', + deviceId: () => (missing ? null : (data.deviceId ?? null)) + }); const volume = deviceVolume('output', () => (missing ? null : (data.deviceId ?? null))); @@ -43,49 +49,28 @@ let unlistenRefresh: (() => void) | undefined; onMount(() => { - unlistenRefresh = onNodeAction(id, 'refresh', () => refresh()); + unlistenRefresh = onNodeAction(id, 'refresh', () => { + void refresh(); + void devInfo.refresh(); + }); }); onDestroy(() => unlistenRefresh?.()); - let options = $derived(audioStore.outputDevices.map((d) => ({ value: d.id, label: d.name }))); - let missing = $derived(!!data.deviceId && !audioStore.outputDevices.some((d) => d.id === data.deviceId)); - - $effect(() => { - const deviceId = data.deviceId; - if (!deviceId || missing) { - info = null; - return; - } - let cancelled = false; - audioMethods - .deviceInfo('output', deviceId) - .then((r) => { - if (!cancelled) info = r; - }) - .catch(() => { - if (!cancelled) info = null; - }); - return () => { - cancelled = true; - }; - }); - async function setVolumePct(pct: number) { await volume.set(pct / 100); } - 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 // device's own dB the reading cannot be corrected to what is heard. let meterOffsetDb = $derived(volume.db ?? 0); - let channelCount = $derived(Math.max(info?.channels ?? 2, 1)); + let channelCount = $derived(Math.max(devInfo.channels, 1)); + let srcTooltip = $derived(devInfo.resamplingTooltip); - let srcTooltip = $derived.by(() => { - if (!info || info.sampleRate === appSettings.pipelineSampleRate) return undefined; - return `Resampling: ${formatHz(appSettings.pipelineSampleRate)} → ${formatHz(info.sampleRate)}`; + $effect(() => { + const _ = channelCount; + updateNodeInternals(id); }); @@ -105,9 +90,13 @@ {#if missing} Selected device not available - {:else if info} + {:else if devInfo.info} - {formatHz(info.sampleRate)} · {info.channels} ch · {info.sampleFormat} + {devInfo.specText} + + {:else if data.deviceId} + + {devInfo.isLoading ? 'Detecting format…' : 'Unable to detect format'} {/if} diff --git a/src/routes/preview/+page.svelte b/src/routes/preview/+page.svelte index 76bf26f3..3c7e3ed0 100644 --- a/src/routes/preview/+page.svelte +++ b/src/routes/preview/+page.svelte @@ -8,7 +8,7 @@ (cmd) => { if (cmd === 'list_input_devices') return [{ ...SPLITWAVE_DEVICE, kind: 'input' }]; if (cmd === 'list_output_devices') return [{ ...SPLITWAVE_DEVICE, kind: 'output' }]; - if (cmd === 'device_info') return { sampleRate: 48000, channels: 2, sampleFormat: 'f32' }; + if (cmd === 'device_info' || cmd === 'capture_device_info') return { sampleRate: 48000, channels: 2, sampleFormat: 'f32' }; if (cmd === 'get_device_volume') return 0.75; if (cmd === 'scan_plugins') return []; if (cmd.startsWith('list_')) return [];