diff --git a/desktop/src-tauri/src/cmd/audio.rs b/desktop/src-tauri/src/cmd/audio.rs index 69f121f4..2e38cc7f 100644 --- a/desktop/src-tauri/src/cmd/audio.rs +++ b/desktop/src-tauri/src/cmd/audio.rs @@ -29,21 +29,46 @@ pub fn get_audio_devices() -> Result> { let host = cpal::default_host(); let mut audio_devices = Vec::new(); - let default_in = host.default_input_device().map(|e| e.name()).context("name")?; - let default_out = host.default_output_device().map(|e| e.name()).context("name")?; - tracing::debug!("Default Input Device:\n{:?}", default_in); - tracing::debug!("Default Output Device:\n{:?}", default_out); + let default_in_name = host.default_input_device().and_then(|e| e.name().ok()); + let default_out_name = host.default_output_device().and_then(|e| e.name().ok()); + tracing::debug!("Default Input Device:\n{:?}", default_in_name); + tracing::debug!("Default Output Device:\n{:?}", default_out_name); let devices = host.devices()?; tracing::debug!("Devices: "); for (device_index, device) in devices.enumerate() { - let name = device.name()?; - let is_default_in = default_in.as_ref().is_ok_and(|d| d == &name); - let is_default_out = default_out.as_ref().is_ok_and(|d| d == &name); + let pcm_id = match device.name() { + Ok(name) => name, + Err(e) => { + tracing::warn!("Skipping device {}: {}", device_index, e); + continue; + } + }; + + // On Linux/ALSA, only show "default" and "plughw:" devices. + // Raw "hw:" devices lack format conversion and often fail. + // Other virtual devices (dmix, dsnoop, surround, etc.) add clutter. + #[cfg(target_os = "linux")] + if pcm_id != "default" && !pcm_id.starts_with("plughw:") { + continue; + } + + // Use description for a human-friendly name, fall back to pcm_id + let name = device + .description() + .ok() + .map(|d| d.name().to_string()) + .unwrap_or_else(|| pcm_id.clone()); + + let is_default_in = default_in_name.as_ref().is_some_and(|d| d == &pcm_id); + let is_default_out = default_out_name.as_ref().is_some_and(|d| d == &pcm_id); + + // "default" ALSA device has Unknown direction but supports both input and output + let is_input = device.supports_input() || pcm_id == "default"; let audio_device = AudioDevice { - is_default: is_default_in || is_default_out, - is_input: device.supports_input(), + is_default: is_default_in || is_default_out || pcm_id == "default", + is_input, id: device_index.to_string(), name, }; @@ -70,6 +95,7 @@ pub async fn start_record( let mut wav_paths: Vec<(PathBuf, u32)> = Vec::new(); let mut stream_handles = Vec::new(); let mut stream_writers = Vec::new(); + let peak: PeakLevel = Arc::new(AtomicU32::new(0)); for device in devices { tracing::debug!("Recording from device: {}", device.name); @@ -79,7 +105,7 @@ pub async fn start_record( let (device, config) = if is_input { let device_id: usize = device.id.parse().context("Failed to parse device ID")?; let dev = host.devices()?.nth(device_id).context("Failed to get device by ID")?; - let config = dev.default_input_config().context("Failed to get default input config")?; + let config = find_working_input_config(&dev)?; (dev, config) } else { get_output_device_and_config(&host, &device)? @@ -95,7 +121,7 @@ pub async fn start_record( stream_writers.push(writer.clone()); let writer_2 = writer.clone(); - let stream = build_input_stream(&device, config, writer_2)?; + let stream = build_input_stream(&device, config, writer_2, peak.clone())?; stream.play()?; tracing::debug!("Stream started playing"); @@ -104,8 +130,22 @@ pub async fn start_record( tracing::debug!("Stream handle created"); } + // Emit audio amplitude at ~30fps for the visualizer + let emitter_stop = Arc::new(AtomicBool::new(false)); + let emitter_stop_clone = emitter_stop.clone(); + let peak_clone = peak.clone(); + let app_emitter = app_handle.clone(); + tokio::spawn(async move { + while !emitter_stop_clone.load(Ordering::Relaxed) { + tokio::time::sleep(std::time::Duration::from_millis(33)).await; + let level = f32::from_bits(peak_clone.load(Ordering::Relaxed)); + let _ = app_emitter.emit("audio_amplitude", level); + } + }); + let app_handle_clone = app_handle.clone(); app_handle.once("stop_record", move |_event| { + emitter_stop.store(true, Ordering::Relaxed); for (i, stream_handle) in stream_handles.iter().enumerate() { let stream_handle = stream_handle.lock().map_err(|e| eyre!("{:?}", e)).log_error(); if let Some(mut stream_handle) = stream_handle { @@ -199,6 +239,22 @@ pub async fn start_record( Ok(()) } +/// Try default input config first, then fall back to iterating supported configs. +/// On ALSA/PipeWire the default config can report parameters that fail at snd_pcm_hw_params. +fn find_working_input_config(device: &Device) -> Result { + if let Ok(config) = device.default_input_config() { + return Ok(config); + } + // Fall back: pick the first supported config, preferring lower sample rates + let mut configs: Vec<_> = device + .supported_input_configs() + .context("No supported input configs")? + .collect(); + configs.sort_by_key(|c| c.min_sample_rate()); + let range = configs.into_iter().next().context("No supported input configs available")?; + Ok(range.with_max_sample_rate()) +} + #[allow(unused_variables)] fn get_output_device_and_config(host: &cpal::Host, audio_device: &AudioDevice) -> Result<(Device, SupportedStreamConfig)> { // On macOS, use the default output device directly — cpal's loopback support @@ -219,25 +275,26 @@ fn get_output_device_and_config(host: &cpal::Host, audio_device: &AudioDevice) - } } -fn build_input_stream_typed(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result +fn build_input_stream_typed(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle, peak: PeakLevel) -> Result where T: SizedSample + hound::Sample + FromSample + Mul + Copy, + f32: FromSample, { let stream = device.build_input_stream( &config.into(), - move |data: &[T], _: &_| write_input_data::(data, &writer), + move |data: &[T], _: &_| write_input_data::(data, &writer, &peak), |err| tracing::error!("An error occurred on stream: {}", err), None, )?; Ok(stream) } -fn build_input_stream(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result { +fn build_input_stream(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle, peak: PeakLevel) -> Result { match config.sample_format() { - cpal::SampleFormat::I8 => build_input_stream_typed::(device, config, writer), - cpal::SampleFormat::I16 => build_input_stream_typed::(device, config, writer), - cpal::SampleFormat::I32 => build_input_stream_typed::(device, config, writer), - cpal::SampleFormat::F32 => build_input_stream_typed::(device, config, writer), + cpal::SampleFormat::I8 => build_input_stream_typed::(device, config, writer, peak.clone()), + cpal::SampleFormat::I16 => build_input_stream_typed::(device, config, writer, peak.clone()), + cpal::SampleFormat::I32 => build_input_stream_typed::(device, config, writer, peak.clone()), + cpal::SampleFormat::F32 => build_input_stream_typed::(device, config, writer, peak.clone()), sample_format => bail!("Unsupported sample format '{}'", sample_format), } } @@ -260,18 +317,25 @@ fn wav_spec_from_config(config: &cpal::SupportedStreamConfig) -> hound::WavSpec } use std::ops::Mul; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +type PeakLevel = Arc; -fn write_input_data(input: &[T], writer: &WavWriterHandle) +fn write_input_data(input: &[T], writer: &WavWriterHandle, peak: &PeakLevel) where T: Sample, U: Sample + hound::Sample + FromSample + Mul + Copy, + f32: FromSample, { + let mut max = 0f32; if let Ok(mut guard) = writer.try_lock() { if let Some(writer) = guard.as_mut() { for &sample in input.iter() { + max = max.max(f32::from_sample(sample).abs()); let sample: U = U::from_sample(sample); writer.write_sample(sample).ok(); } } } + peak.store(max.to_bits(), Ordering::Relaxed); } diff --git a/desktop/src/pages/home/AudioVisualizer.tsx b/desktop/src/pages/home/AudioVisualizer.tsx index 33d30bb6..812a2a1f 100644 --- a/desktop/src/pages/home/AudioVisualizer.tsx +++ b/desktop/src/pages/home/AudioVisualizer.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { motion } from 'framer-motion' +import { listen } from '@tauri-apps/api/event' export interface AudioVisualizerProps { isRecording: boolean @@ -7,71 +8,6 @@ export interface AudioVisualizerProps { className?: string } -// --- Audio engine --- - -interface AudioEngine { - ctx: AudioContext - analyser: AnalyserNode - stream: MediaStream - buffer: Uint8Array -} - -async function resolveDeviceId(name: string): Promise { - const devices = await navigator.mediaDevices.enumerateDevices() - return devices - .filter((d) => d.kind === 'audioinput') - .find((d) => d.label.toLowerCase().includes(name.toLowerCase()))?.deviceId -} - -async function createAudioEngine(deviceName?: string): Promise { - const deviceId = deviceName ? await resolveDeviceId(deviceName) : undefined - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: deviceId - ? { deviceId: { exact: deviceId }, echoCancellation: false, noiseSuppression: false, autoGainControl: false } - : true, - }) - - const ctx = new AudioContext() - if (ctx.state === 'suspended') await ctx.resume() - - // Two cascaded highpass filters at 85Hz — aggressively removes DC offset and low rumble - const highpass1 = ctx.createBiquadFilter() - highpass1.type = 'highpass' - highpass1.frequency.value = 85 - highpass1.Q.value = 0.7 - - const highpass2 = ctx.createBiquadFilter() - highpass2.type = 'highpass' - highpass2.frequency.value = 85 - highpass2.Q.value = 0.7 - - // Fixed gain boost — no dynamic compression, no level jumps - const gain = ctx.createGain() - gain.gain.value = 6 - - const analyser = ctx.createAnalyser() - analyser.fftSize = 2048 - analyser.smoothingTimeConstant = 0.3 - - // Chain: mic → highpass × 2 → gain → analyser - const source = ctx.createMediaStreamSource(stream) - source.connect(highpass1) - highpass1.connect(highpass2) - highpass2.connect(gain) - gain.connect(analyser) - - return { ctx, analyser, stream, buffer: new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)) } -} - -function destroyAudioEngine(engine: AudioEngine | null) { - if (!engine) return - engine.stream.getTracks().forEach((t) => t.stop()) - if (engine.ctx.state !== 'closed') engine.ctx.close() -} - -// --- Theme --- - function getWaveColor(): string { const isDark = document.documentElement.classList.contains('dark') return isDark ? 'rgba(255, 255, 255, 0.85)' : 'rgba(30, 41, 59, 0.75)' @@ -82,22 +18,10 @@ function getCenterLineColor(): string { return isDark ? 'rgba(148, 163, 184, 0.15)' : 'rgba(100, 116, 139, 0.15)' } -// --- Waveform renderer --- - -const SENSITIVITY = 6 - -function drawWaveform( - ctx: CanvasRenderingContext2D, - w: number, - h: number, - data: Float32Array, - bufferLength: number, -) { +function drawWaveform(ctx: CanvasRenderingContext2D, w: number, h: number, amplitude: number, time: number) { ctx.clearRect(0, 0, w, h) - const midY = h / 2 - // Center line ctx.strokeStyle = getCenterLineColor() ctx.lineWidth = 1 ctx.beginPath() @@ -105,48 +29,34 @@ function drawWaveform( ctx.lineTo(w, midY) ctx.stroke() - // Waveform with smooth bezier curves ctx.strokeStyle = getWaveColor() ctx.lineWidth = 2 ctx.lineJoin = 'round' ctx.lineCap = 'round' ctx.beginPath() - const sampleCount = Math.floor(bufferLength * 0.25) - const sliceWidth = w / sampleCount - - const getY = (i: number) => { - const v = data[i] / 128.0 - // Clamp to prevent waveform from going off-canvas - const deflection = Math.max(-0.95, Math.min(0.95, (v - 1.0) * SENSITIVITY)) - return (deflection + 1.0) * midY - } - - for (let i = 0; i < sampleCount; i++) { - const y = getY(i) - if (i === 0) { - ctx.moveTo(0, y) - } else { - const prevX = (i - 1) * sliceWidth - const currX = i * sliceWidth - ctx.quadraticCurveTo(prevX, getY(i - 1), (prevX + currX) / 2, (getY(i - 1) + y) / 2) - } + const points = 64 + const sliceWidth = w / points + + for (let i = 0; i < points; i++) { + const x = i * sliceWidth + const wave = + Math.sin(i * 0.3 + time * 5) * 0.5 + + Math.sin(i * 0.7 + time * 3) * 0.3 + + Math.sin(i * 1.1 + time * 7) * 0.2 + const y = midY + wave * amplitude * midY * 0.9 + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) } ctx.stroke() } -// --- Component --- - -export default function AudioVisualizer({ - isRecording, - inputDevice, - className = '', -}: AudioVisualizerProps) { +export default function AudioVisualizer({ isRecording, inputDevice, className = '' }: AudioVisualizerProps) { const canvasRef = useRef(null) - const engineRef = useRef(null) const frameRef = useRef(0) - const [deviceLabel, setDeviceLabel] = useState(null) + const amplitudeRef = useRef(0) + const smoothedRef = useRef(0) const syncCanvasSize = useCallback(() => { const canvas = canvasRef.current @@ -162,62 +72,44 @@ export default function AudioVisualizer({ let cancelled = false - const run = async () => { - try { - const engine = await createAudioEngine(inputDevice?.name) - if (cancelled) { - destroyAudioEngine(engine) - return - } - engineRef.current = engine - setDeviceLabel(inputDevice?.name ?? null) - - const canvas = canvasRef.current - if (!canvas) return - const ctx = canvas.getContext('2d') - if (!ctx) return - - syncCanvasSize() - window.addEventListener('resize', syncCanvasSize) - - const smoothed = new Float32Array(engine.analyser.frequencyBinCount).fill(128) - - const tick = () => { - if (cancelled) return - - engine.analyser.getByteTimeDomainData(engine.buffer) - - // Smooth with asymmetric lerp — no manual DC/gain needed - for (let i = 0; i < smoothed.length; i++) { - const diff = engine.buffer[i] - smoothed[i] - smoothed[i] += diff * (Math.abs(diff) > 3 ? 0.35 : 0.15) - } - - const dpr = window.devicePixelRatio - const w = canvas.width / dpr - const h = canvas.height / dpr - ctx.save() - ctx.scale(dpr, dpr) - drawWaveform(ctx, w, h, smoothed, engine.analyser.frequencyBinCount) - ctx.restore() - - frameRef.current = requestAnimationFrame(tick) - } - - frameRef.current = requestAnimationFrame(tick) - } catch (err) { - console.error('AudioVisualizer:', err) - } + const unlisten = listen('audio_amplitude', (event) => { + amplitudeRef.current = event.payload + }) + + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + syncCanvasSize() + window.addEventListener('resize', syncCanvasSize) + + const tick = () => { + if (cancelled) return + // Smooth: fast attack, slow decay + const target = amplitudeRef.current + const diff = target - smoothedRef.current + smoothedRef.current += diff * (diff > 0 ? 0.4 : 0.08) + + const dpr = window.devicePixelRatio + const w = canvas.width / dpr + const h = canvas.height / dpr + ctx.save() + ctx.scale(dpr, dpr) + drawWaveform(ctx, w, h, smoothedRef.current, performance.now() / 1000) + ctx.restore() + + frameRef.current = requestAnimationFrame(tick) } - run() + frameRef.current = requestAnimationFrame(tick) return () => { cancelled = true cancelAnimationFrame(frameRef.current) window.removeEventListener('resize', syncCanvasSize) - destroyAudioEngine(engineRef.current) - engineRef.current = null + unlisten.then((fn) => fn()) + smoothedRef.current = 0 } }, [isRecording, inputDevice, syncCanvasSize]) @@ -231,7 +123,7 @@ export default function AudioVisualizer({ className={`w-full rounded-lg border border-border/50 bg-card/50 p-3 ${className}`} >
- {deviceLabel || 'Audio Level'} + {inputDevice?.name || 'Audio Level'} @@ -240,11 +132,7 @@ export default function AudioVisualizer({ Recording
- + ) }