From 52c15c8ba51c40b1a9f9072c6db81512905e2fb4 Mon Sep 17 00:00:00 2001 From: Vinicios de Lima Clarindo Date: Wed, 23 Sep 2026 17:50:01 -0300 Subject: [PATCH 1/6] Fix dictation indicator black flash and missing overlay on Linux Closes #1596 - Create indicator window hidden; show only after page_ready - Call set_ignore_cursor_events after show to avoid panic on hidden window - Show overlay on hotkey key-down before start_record - Fallback mic selection when no ALSA isDefault device matches - Denylist dictation-indicator in window-state (stale 280x200 restore) - Listen for indicator events before ready signal --- desktop/src-tauri/src/cmd/audio.rs | 23 +++ desktop/src-tauri/src/dictation_indicator.rs | 144 ++++++++++++------ desktop/src-tauri/src/main.rs | 2 +- desktop/src/lib/audio.ts | 19 +++ desktop/src/providers/hotkey.tsx | 16 +- desktop/src/providers/recording-shortcut.tsx | 8 +- .../windows/dictation-indicator-window.tsx | 24 ++- 7 files changed, 175 insertions(+), 61 deletions(-) diff --git a/desktop/src-tauri/src/cmd/audio.rs b/desktop/src-tauri/src/cmd/audio.rs index a26f742d6..ac42d8b95 100644 --- a/desktop/src-tauri/src/cmd/audio.rs +++ b/desktop/src-tauri/src/cmd/audio.rs @@ -44,10 +44,14 @@ pub fn get_audio_devices() -> Result> { let devices = host.devices()?; tracing::debug!("Devices: "); + let mut has_default_input = false; for (device_index, device) in devices.enumerate() { let name = device.description()?.to_string(); 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); + if is_default_in { + has_default_input = true; + } let audio_device = AudioDevice { is_default: is_default_in || is_default_out, @@ -58,6 +62,25 @@ pub fn get_audio_devices() -> Result> { audio_devices.push(audio_device); } + // On Linux/ALSA the host default is often the synthetic name "Default Audio Device", + // which never appears in the enumerated list — so nothing is marked default and + // hotkeys bail with "No default microphone". Prefer the PipeWire-friendly aliases + // that actually open under exclusive hw: locks. + if !has_default_input { + if let Some(index) = audio_devices.iter().position(|d| { + d.is_input + && matches!( + d.name.as_str(), + n if n.contains("Default ALSA Output") + || n.contains("PipeWire Sound Server") + || n.contains("PulseAudio Sound Server") + ) + }) { + tracing::debug!("Marking {} as default input (Linux fallback)", audio_devices[index].name); + audio_devices[index].is_default = true; + } + } + Ok(audio_devices) } diff --git a/desktop/src-tauri/src/dictation_indicator.rs b/desktop/src-tauri/src/dictation_indicator.rs index 3131fc15c..9937856be 100644 --- a/desktop/src-tauri/src/dictation_indicator.rs +++ b/desktop/src-tauri/src/dictation_indicator.rs @@ -1,5 +1,6 @@ use crate::config::STORE_FILENAME; use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use tauri::webview::PageLoadEvent; use tauri::{Emitter, LogicalSize, Manager, PhysicalPosition, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; @@ -23,6 +24,34 @@ pub struct DictationIndicatorPayload { #[derive(Default)] pub struct DictationIndicatorRuntime { current: Mutex>, + page_ready: AtomicBool, +} + +impl DictationIndicatorRuntime { + fn current_state(&self) -> Option { + self.current.lock().ok().and_then(|state| state.clone()) + } + + fn set_current(&self, state: Option) -> Result<(), String> { + *self.current.lock().map_err(|error| error.to_string())? = state; + Ok(()) + } + + fn has_active_state(&self) -> bool { + self.current.lock().is_ok_and(|state| state.is_some()) + } + + fn mark_page_ready(&self) { + self.page_ready.store(true, Ordering::SeqCst); + } + + fn reset_page_ready(&self) { + self.page_ready.store(false, Ordering::SeqCst); + } + + fn is_page_ready(&self) -> bool { + self.page_ready.load(Ordering::SeqCst) + } } pub fn is_enabled(app: &tauri::AppHandle) -> bool { @@ -34,12 +63,16 @@ pub fn is_enabled(app: &tauri::AppHandle) -> bool { } fn create_window(app: &tauri::AppHandle) -> Result { + app.state::().reset_page_ready(); let window = WebviewWindowBuilder::new( app, WINDOW_LABEL, WebviewUrl::App("index.html?window=dictation-indicator".into()), ) .inner_size(WIDTH, HEIGHT) + .min_inner_size(WIDTH, HEIGHT) + .max_inner_size(WIDTH, HEIGHT) + .title("Vibe") .decorations(false) .resizable(false) .always_on_top(true) @@ -49,17 +82,18 @@ fn create_window(app: &tauri::AppHandle) -> Result { .skip_taskbar(true) .transparent(true) .shadow(false) - .visible(true) + // Never paint before HTML/CSS/JS exist — the first frame is otherwise a black rectangle. + .visible(false) .on_page_load(|window, payload| { if payload.event() == PageLoadEvent::Finished { tracing::info!("Dictation indicator page loaded: {}", payload.url()); - let has_active_state = window - .app_handle() - .state::() - .current - .lock() - .is_ok_and(|state| state.is_some()); - if !has_active_state { + let runtime = window.app_handle().state::(); + runtime.mark_page_ready(); + if runtime.has_active_state() { + if let Err(error) = window.show() { + tracing::error!("Could not show dictation indicator after page load: {error}"); + } + } else { let _ = window.hide(); } } @@ -70,7 +104,8 @@ fn create_window(app: &tauri::AppHandle) -> Result { window .set_size(LogicalSize::new(WIDTH, HEIGHT)) .map_err(|error| error.to_string())?; - window.set_ignore_cursor_events(true).map_err(|error| error.to_string())?; + // GDK has no native window until the (hidden) window is realized — set_ignore_cursor_events + // would unwrap None and abort. Applied in present_indicator after show. #[cfg(target_os = "macos")] unsafe { @@ -136,11 +171,10 @@ pub fn set_dictation_indicator_enabled(app: tauri::AppHandle, enabled: bool) -> let store = app.store(STORE_FILENAME).map_err(|error| error.to_string())?; store.set(ENABLED_KEY, serde_json::Value::Bool(enabled)); store.save().map_err(|error| error.to_string())?; + let runtime = app.state::(); if !enabled { - *app.state::() - .current - .lock() - .map_err(|error| error.to_string())? = None; + runtime.set_current(None)?; + runtime.reset_page_ready(); if let Some(window) = app.get_webview_window(WINDOW_LABEL) { window.close().map_err(|error| error.to_string())?; } @@ -150,32 +184,17 @@ pub fn set_dictation_indicator_enabled(app: tauri::AppHandle, enabled: bool) -> Ok(()) } -#[tauri::command] -pub fn show_dictation_indicator(app: tauri::AppHandle, state: DictationIndicatorPayload) -> Result<(), String> { - tracing::info!( - "Showing dictation indicator: status={}, session={}", - state.status, - state.session_id - ); - if !is_enabled(&app) { - tracing::info!("Dictation indicator show skipped because it is disabled"); - return Ok(()); - } - *app.state::() - .current - .lock() - .map_err(|error| error.to_string())? = Some(state.clone()); - let window = match app.get_webview_window(WINDOW_LABEL) { - Some(window) => window, - None => create_window(&app)?, - }; +fn present_indicator(app: &tauri::AppHandle, window: &WebviewWindow, state: &DictationIndicatorPayload) -> Result<(), String> { window .set_size(LogicalSize::new(WIDTH, HEIGHT)) .map_err(|error| error.to_string())?; - if let Err(error) = position_window(&app, &window) { + if let Err(error) = position_window(app, window) { tracing::error!("Could not position dictation indicator: {error}"); } window.show().map_err(|error| error.to_string())?; + if let Err(error) = window.set_ignore_cursor_events(true) { + tracing::error!("Could not ignore cursor events on dictation indicator: {error}"); + } #[cfg(target_os = "macos")] unsafe { use objc2_app_kit::{NSStatusWindowLevel, NSWindow}; @@ -184,9 +203,9 @@ pub fn show_dictation_indicator(app: tauri::AppHandle, state: DictationIndicator native_window.setLevel(NSStatusWindowLevel); native_window.orderFrontRegardless(); } - if let Err(error) = window.emit("dictation-indicator-state", state) { - tracing::error!("Could not update dictation indicator: {error}"); - } + window + .emit("dictation-indicator-state", state.clone()) + .map_err(|error| error.to_string())?; tracing::info!( "Dictation indicator shown (visible={:?}, position={:?}, size={:?}, title={:?}, url={:?})", window.is_visible(), @@ -198,13 +217,33 @@ pub fn show_dictation_indicator(app: tauri::AppHandle, state: DictationIndicator Ok(()) } +#[tauri::command] +pub fn show_dictation_indicator(app: tauri::AppHandle, state: DictationIndicatorPayload) -> Result<(), String> { + tracing::info!( + "Showing dictation indicator: status={}, session={}", + state.status, + state.session_id + ); + if !is_enabled(&app) { + tracing::info!("Dictation indicator show skipped because it is disabled"); + return Ok(()); + } + let runtime = app.state::(); + runtime.set_current(Some(state.clone()))?; + let window = match app.get_webview_window(WINDOW_LABEL) { + Some(window) => window, + None => create_window(&app)?, + }; + if !runtime.is_page_ready() { + tracing::info!("Dictation indicator state queued until webview is ready"); + return Ok(()); + } + present_indicator(&app, &window, &state) +} + #[tauri::command] pub fn get_dictation_indicator_state(app: tauri::AppHandle) -> Result, String> { - app.state::() - .current - .lock() - .map(|state| state.clone()) - .map_err(|error| error.to_string()) + Ok(app.state::().current_state()) } #[tauri::command] @@ -214,15 +253,32 @@ pub fn dictation_indicator_ready(window: tauri::WebviewWindow) { window.label(), window.url() ); + let app = window.app_handle().clone(); + let runtime = app.state::(); + runtime.mark_page_ready(); + let Some(state) = runtime.current_state() else { + tracing::info!("Dictation indicator ready with no active state; staying hidden"); + return; + }; + if let Err(error) = present_indicator(&app, &window, &state) { + tracing::error!("Could not present dictation indicator: {error}"); + } } #[tauri::command] pub fn hide_dictation_indicator(app: tauri::AppHandle, session_id: u64) -> Result<(), String> { tracing::info!("Hiding dictation indicator: session={session_id}"); let runtime = app.state::(); - let mut current = runtime.current.lock().map_err(|error| error.to_string())?; - if current.as_ref().is_some_and(|state| state.session_id == session_id) { - *current = None; + let should_hide = { + let mut current = runtime.current.lock().map_err(|error| error.to_string())?; + if current.as_ref().is_some_and(|state| state.session_id == session_id) { + *current = None; + true + } else { + false + } + }; + if should_hide { if let Some(window) = app.get_webview_window(WINDOW_LABEL) { window.hide().map_err(|error| error.to_string())?; } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index af2294381..d5db8ed8a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -64,7 +64,7 @@ async fn main() -> Result<()> { .plugin( tauri_plugin_window_state::Builder::default() .with_state_flags(!StateFlags::VISIBLE) - .with_denylist(&["meeting-prompt"]) + .with_denylist(&["meeting-prompt", "dictation-indicator"]) .build(), ) .plugin(tauri_plugin_store::Builder::default().build()) diff --git a/desktop/src/lib/audio.ts b/desktop/src/lib/audio.ts index 2eeb9a545..8c64b821b 100644 --- a/desktop/src/lib/audio.ts +++ b/desktop/src/lib/audio.ts @@ -4,3 +4,22 @@ export interface AudioDevice { id: string name: string } + +/** Names that go through PipeWire and actually open under exclusive hw: locks. */ +const PIPEWIRE_INPUT_PREFERENCE = /Default ALSA Output \(currently PipeWire Media Server\)|PipeWire Sound Server|PulseAudio Sound Server/i + +/** Raw ALSA/HDMI/plugin entries that cpal cannot open (or capture silence). */ +const UNUSABLE_INPUT = /Rate Converter|Open Sound System|Discard all samples|HDMI|IEC958|Null|Modem/i + +export function findDefaultInputDevice(devices: AudioDevice[], savedId?: string | null): AudioDevice | null { + const inputs = devices.filter((device) => device.isInput) + if (savedId) { + const saved = inputs.find((device) => device.id === savedId) + if (saved) return saved + } + const marked = inputs.find((device) => device.isDefault) + if (marked) return marked + const pipewire = inputs.find((device) => PIPEWIRE_INPUT_PREFERENCE.test(device.name)) + if (pipewire) return pipewire + return inputs.find((device) => !UNUSABLE_INPUT.test(device.name) && !/fifine Microphone/i.test(device.name)) ?? inputs[0] ?? null +} diff --git a/desktop/src/providers/hotkey.tsx b/desktop/src/providers/hotkey.tsx index 538ec3e07..86e08836c 100644 --- a/desktop/src/providers/hotkey.tsx +++ b/desktop/src/providers/hotkey.tsx @@ -5,7 +5,7 @@ import { emit, listen } from '@tauri-apps/api/event' import { register, unregister, isRegistered } from '@tauri-apps/plugin-global-shortcut' import * as clipboard from '@tauri-apps/plugin-clipboard-manager' import { trackTranscribeFailed, trackTranscribeStarted, trackTranscribeSucceeded } from '~/lib/analytics' -import { AudioDevice } from '~/lib/audio' +import { AudioDevice, findDefaultInputDevice } from '~/lib/audio' import { CONFIG_KEYS } from '~/lib/config-keys' import { gpuOutOfMemoryBefore } from '~/lib/gpu-memory' import { usePersisted } from '~/lib/config-store' @@ -75,6 +75,7 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { const [hotkeyEnabled, setHotkeyEnabled] = usePersisted(CONFIG_KEYS.hotkeyEnabled, false) const [hotkeyShortcut, setHotkeyShortcut] = usePersisted(CONFIG_KEYS.hotkeyShortcut, getDefaultHotkeyShortcut()) + const [savedInputDeviceId] = usePersisted(CONFIG_KEYS.inputDeviceId, null) const [hotkeyCapturing, setHotkeyCapturingState] = useState(false) const [shortcutRegistration, setShortcutRegistration] = useState<{ shortcut: string; error: string | null } | null>(null) const [hotkeyOutputMode, setHotkeyOutputMode] = usePersisted(CONFIG_KEYS.hotkeyOutputMode, 'clipboard') @@ -125,12 +126,14 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { const handleHotkeyDown = useCallback(async () => { if (isHotkeyRecordingRef.current || isStartingRef.current || isStoppingRef.current) return isStartingRef.current = true + // Overlay must appear on key-down even if the mic path fails a moment later. + indicatorSessionRef.current += 1 + showIndicator('recording') try { const devices = await invoke('get_audio_devices') - const defaultInput = devices.find((d) => d.isDefault && d.isInput) + const defaultInput = findDefaultInputDevice(devices, savedInputDeviceId) if (!defaultInput) { - console.error('No default input device found') - return + throw new Error(m.noDefaultMicrophone()) } isHotkeyRecordingRef.current = true @@ -141,17 +144,16 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { devices: [defaultInput], recordingName: null, }) - indicatorSessionRef.current += 1 - showIndicator('recording') } catch (error) { console.error('Hotkey start_record error:', error) + finishIndicator('error', { message: getErrorMessage(error) }) isHotkeyRecordingRef.current = false hotkeyRecordingActive = false setIsHotkeyRecording(false) } finally { isStartingRef.current = false } - }, [showIndicator]) + }, [finishIndicator, showIndicator, savedInputDeviceId]) const handleHotkeyUp = useCallback(async () => { if (!isHotkeyRecordingRef.current || isStoppingRef.current) return diff --git a/desktop/src/providers/recording-shortcut.tsx b/desktop/src/providers/recording-shortcut.tsx index 55992ec3a..a6bf91463 100644 --- a/desktop/src/providers/recording-shortcut.tsx +++ b/desktop/src/providers/recording-shortcut.tsx @@ -4,6 +4,7 @@ import { isRegistered, register, unregister } from '@tauri-apps/plugin-global-sh import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import { toast } from 'sonner' import type { AudioDevice } from '~/lib/audio' +import { findDefaultInputDevice } from '~/lib/audio' import { CONFIG_KEYS } from '~/lib/config-keys' import { usePersisted } from '~/lib/config-store' import { getDefaultRecordingShortcut } from '~/lib/config' @@ -64,8 +65,11 @@ export function RecordingShortcutProvider({ children }: { children: ReactNode }) startingRef.current = true try { const devices = await invoke('get_audio_devices') - const microphone = devices.find((device) => device.isDefault && device.isInput) - const systemAudio = devices.find((device) => device.isDefault && !device.isInput) + const microphone = findDefaultInputDevice(devices) + const systemAudio = + devices.find((device) => device.isDefault && !device.isInput) ?? + devices.find((device) => !device.isInput && !/HDMI|IEC958|Null/i.test(device.name)) ?? + null if (options.microphone && !microphone) throw new Error(m.noDefaultMicrophone()) if (options.systemAudio && !systemAudio) throw new Error(m.systemAudioPermissionInfo()) if (options.systemAudio && !(await ensureSystemAudioPermission())) return false diff --git a/desktop/src/windows/dictation-indicator-window.tsx b/desktop/src/windows/dictation-indicator-window.tsx index 57211be2e..b5299b8ad 100644 --- a/desktop/src/windows/dictation-indicator-window.tsx +++ b/desktop/src/windows/dictation-indicator-window.tsx @@ -17,15 +17,25 @@ export default function DictationIndicatorWindow() { }, []) useEffect(() => { - invoke('dictation_indicator_ready').catch(console.error) - getDictationIndicatorState() - .then((initialState) => { - if (initialState) setState(initialState) + let cancelled = false + let stop: (() => void) | undefined + // Subscribe before announcing ready: present may emit state on that signal. + void (async () => { + const unlisten = await listen('dictation-indicator-state', ({ payload }) => { + if (!cancelled) setState(payload) }) - .catch(console.error) - const unlisten = listen('dictation-indicator-state', ({ payload }) => setState(payload)) + if (cancelled) { + unlisten() + return + } + stop = unlisten + await invoke('dictation_indicator_ready') + const initialState = await getDictationIndicatorState() + if (!cancelled && initialState) setState(initialState) + })().catch(console.error) return () => { - unlisten.then((stop) => stop()) + cancelled = true + stop?.() } }, []) From 56040c48ab36f8e0d4634acbc2270f5fb3452e98 Mon Sep 17 00:00:00 2001 From: Vinicios de Lima Clarindo Date: Thu, 24 Sep 2026 00:17:00 -0300 Subject: [PATCH 2/6] Drop machine-specific fifine exclusion from mic fallback Refs #1596 --- desktop/src/lib/audio.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/lib/audio.ts b/desktop/src/lib/audio.ts index 8c64b821b..fc37f3007 100644 --- a/desktop/src/lib/audio.ts +++ b/desktop/src/lib/audio.ts @@ -21,5 +21,5 @@ export function findDefaultInputDevice(devices: AudioDevice[], savedId?: string if (marked) return marked const pipewire = inputs.find((device) => PIPEWIRE_INPUT_PREFERENCE.test(device.name)) if (pipewire) return pipewire - return inputs.find((device) => !UNUSABLE_INPUT.test(device.name) && !/fifine Microphone/i.test(device.name)) ?? inputs[0] ?? null + return inputs.find((device) => !UNUSABLE_INPUT.test(device.name)) ?? inputs[0] ?? null } From 0e24808c3c23d8ed4c2479b1a39436e764486a91 Mon Sep 17 00:00:00 2001 From: Vinicios de Lima Clarindo Date: Thu, 24 Sep 2026 00:20:31 -0300 Subject: [PATCH 3/6] Strip machine-specific mic heuristics from indicator fix Keep only universal dictation-indicator changes (hidden first paint, page_ready gate, window-state denylist, hotkey overlay on key-down). Revert ALSA/PipeWire default selection and recording-shortcut fallbacks. Refs #1596 --- desktop/src-tauri/src/cmd/audio.rs | 23 -------------------- desktop/src/lib/audio.ts | 19 ---------------- desktop/src/providers/hotkey.tsx | 9 ++++---- desktop/src/providers/recording-shortcut.tsx | 8 ++----- 4 files changed, 6 insertions(+), 53 deletions(-) diff --git a/desktop/src-tauri/src/cmd/audio.rs b/desktop/src-tauri/src/cmd/audio.rs index ac42d8b95..a26f742d6 100644 --- a/desktop/src-tauri/src/cmd/audio.rs +++ b/desktop/src-tauri/src/cmd/audio.rs @@ -44,14 +44,10 @@ pub fn get_audio_devices() -> Result> { let devices = host.devices()?; tracing::debug!("Devices: "); - let mut has_default_input = false; for (device_index, device) in devices.enumerate() { let name = device.description()?.to_string(); 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); - if is_default_in { - has_default_input = true; - } let audio_device = AudioDevice { is_default: is_default_in || is_default_out, @@ -62,25 +58,6 @@ pub fn get_audio_devices() -> Result> { audio_devices.push(audio_device); } - // On Linux/ALSA the host default is often the synthetic name "Default Audio Device", - // which never appears in the enumerated list — so nothing is marked default and - // hotkeys bail with "No default microphone". Prefer the PipeWire-friendly aliases - // that actually open under exclusive hw: locks. - if !has_default_input { - if let Some(index) = audio_devices.iter().position(|d| { - d.is_input - && matches!( - d.name.as_str(), - n if n.contains("Default ALSA Output") - || n.contains("PipeWire Sound Server") - || n.contains("PulseAudio Sound Server") - ) - }) { - tracing::debug!("Marking {} as default input (Linux fallback)", audio_devices[index].name); - audio_devices[index].is_default = true; - } - } - Ok(audio_devices) } diff --git a/desktop/src/lib/audio.ts b/desktop/src/lib/audio.ts index fc37f3007..2eeb9a545 100644 --- a/desktop/src/lib/audio.ts +++ b/desktop/src/lib/audio.ts @@ -4,22 +4,3 @@ export interface AudioDevice { id: string name: string } - -/** Names that go through PipeWire and actually open under exclusive hw: locks. */ -const PIPEWIRE_INPUT_PREFERENCE = /Default ALSA Output \(currently PipeWire Media Server\)|PipeWire Sound Server|PulseAudio Sound Server/i - -/** Raw ALSA/HDMI/plugin entries that cpal cannot open (or capture silence). */ -const UNUSABLE_INPUT = /Rate Converter|Open Sound System|Discard all samples|HDMI|IEC958|Null|Modem/i - -export function findDefaultInputDevice(devices: AudioDevice[], savedId?: string | null): AudioDevice | null { - const inputs = devices.filter((device) => device.isInput) - if (savedId) { - const saved = inputs.find((device) => device.id === savedId) - if (saved) return saved - } - const marked = inputs.find((device) => device.isDefault) - if (marked) return marked - const pipewire = inputs.find((device) => PIPEWIRE_INPUT_PREFERENCE.test(device.name)) - if (pipewire) return pipewire - return inputs.find((device) => !UNUSABLE_INPUT.test(device.name)) ?? inputs[0] ?? null -} diff --git a/desktop/src/providers/hotkey.tsx b/desktop/src/providers/hotkey.tsx index 86e08836c..5b927251d 100644 --- a/desktop/src/providers/hotkey.tsx +++ b/desktop/src/providers/hotkey.tsx @@ -5,7 +5,7 @@ import { emit, listen } from '@tauri-apps/api/event' import { register, unregister, isRegistered } from '@tauri-apps/plugin-global-shortcut' import * as clipboard from '@tauri-apps/plugin-clipboard-manager' import { trackTranscribeFailed, trackTranscribeStarted, trackTranscribeSucceeded } from '~/lib/analytics' -import { AudioDevice, findDefaultInputDevice } from '~/lib/audio' +import { AudioDevice } from '~/lib/audio' import { CONFIG_KEYS } from '~/lib/config-keys' import { gpuOutOfMemoryBefore } from '~/lib/gpu-memory' import { usePersisted } from '~/lib/config-store' @@ -75,7 +75,6 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { const [hotkeyEnabled, setHotkeyEnabled] = usePersisted(CONFIG_KEYS.hotkeyEnabled, false) const [hotkeyShortcut, setHotkeyShortcut] = usePersisted(CONFIG_KEYS.hotkeyShortcut, getDefaultHotkeyShortcut()) - const [savedInputDeviceId] = usePersisted(CONFIG_KEYS.inputDeviceId, null) const [hotkeyCapturing, setHotkeyCapturingState] = useState(false) const [shortcutRegistration, setShortcutRegistration] = useState<{ shortcut: string; error: string | null } | null>(null) const [hotkeyOutputMode, setHotkeyOutputMode] = usePersisted(CONFIG_KEYS.hotkeyOutputMode, 'clipboard') @@ -126,12 +125,12 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { const handleHotkeyDown = useCallback(async () => { if (isHotkeyRecordingRef.current || isStartingRef.current || isStoppingRef.current) return isStartingRef.current = true - // Overlay must appear on key-down even if the mic path fails a moment later. + // Overlay must appear on key-down even if start_record fails a moment later. indicatorSessionRef.current += 1 showIndicator('recording') try { const devices = await invoke('get_audio_devices') - const defaultInput = findDefaultInputDevice(devices, savedInputDeviceId) + const defaultInput = devices.find((d) => d.isDefault && d.isInput) if (!defaultInput) { throw new Error(m.noDefaultMicrophone()) } @@ -153,7 +152,7 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { } finally { isStartingRef.current = false } - }, [finishIndicator, showIndicator, savedInputDeviceId]) + }, [finishIndicator, showIndicator]) const handleHotkeyUp = useCallback(async () => { if (!isHotkeyRecordingRef.current || isStoppingRef.current) return diff --git a/desktop/src/providers/recording-shortcut.tsx b/desktop/src/providers/recording-shortcut.tsx index a6bf91463..55992ec3a 100644 --- a/desktop/src/providers/recording-shortcut.tsx +++ b/desktop/src/providers/recording-shortcut.tsx @@ -4,7 +4,6 @@ import { isRegistered, register, unregister } from '@tauri-apps/plugin-global-sh import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import { toast } from 'sonner' import type { AudioDevice } from '~/lib/audio' -import { findDefaultInputDevice } from '~/lib/audio' import { CONFIG_KEYS } from '~/lib/config-keys' import { usePersisted } from '~/lib/config-store' import { getDefaultRecordingShortcut } from '~/lib/config' @@ -65,11 +64,8 @@ export function RecordingShortcutProvider({ children }: { children: ReactNode }) startingRef.current = true try { const devices = await invoke('get_audio_devices') - const microphone = findDefaultInputDevice(devices) - const systemAudio = - devices.find((device) => device.isDefault && !device.isInput) ?? - devices.find((device) => !device.isInput && !/HDMI|IEC958|Null/i.test(device.name)) ?? - null + const microphone = devices.find((device) => device.isDefault && device.isInput) + const systemAudio = devices.find((device) => device.isDefault && !device.isInput) if (options.microphone && !microphone) throw new Error(m.noDefaultMicrophone()) if (options.systemAudio && !systemAudio) throw new Error(m.systemAudioPermissionInfo()) if (options.systemAudio && !(await ensureSystemAudioPermission())) return false From 56895170d83e169c68c46d5c4dd37ec8ab715e78 Mon Sep 17 00:00:00 2001 From: Vinicios de Lima Clarindo Date: Thu, 24 Sep 2026 10:01:43 -0300 Subject: [PATCH 4/6] Show live mic level meter next to each input device in dropdown --- desktop/src-tauri/src/cmd/audio.rs | 147 +++++++++++++++++- desktop/src-tauri/src/main.rs | 2 + desktop/src-tauri/src/setup.rs | 1 + desktop/src/components/audio-device-input.tsx | 52 ++++++- desktop/src/components/ui/select.tsx | 10 +- desktop/src/lib/audio.test.ts | 29 ++++ desktop/src/lib/audio.ts | 26 ++++ desktop/src/mock-tauri/handlers/media-misc.ts | 20 +++ 8 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 desktop/src/lib/audio.test.ts diff --git a/desktop/src-tauri/src/cmd/audio.rs b/desktop/src-tauri/src/cmd/audio.rs index a26f742d6..37bc33423 100644 --- a/desktop/src-tauri/src/cmd/audio.rs +++ b/desktop/src-tauri/src/cmd/audio.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; -use tauri::{AppHandle, Emitter, Listener}; +use tauri::{AppHandle, Emitter, Listener, Manager}; use crate::error::LogError; use crate::ffmpeg::{get_local_time, random_string}; @@ -115,6 +115,146 @@ impl LevelMeter { } } +/// Streams held while the input dropdown is open so every mic can show a live level. +/// Dropping the vector closes the streams. Guarded by a mutex for idempotent +/// start/stop from the UI and a defensive stop when a real recording starts. +#[derive(Default)] +pub struct InputPreviewState { + streams: Mutex>>, +} + +impl InputPreviewState { + fn stop(&self) { + // Take ownership so the streams are dropped (closed) outside of any other work. + let streams = self.streams.lock().map(|mut guard| guard.take()).unwrap_or(None); + drop(streams); + } +} + +/// Per-device meter for the input-level preview. Unlike [`LevelMeter`] (one shared value +/// for a recording session), each preview stream owns one of these so the UI can render +/// a live bar next to every input device in the dropdown. +/// +/// Emits `input_level` as `{ deviceId, level }`, throttled to ~10/s per device. +struct DeviceLevelMeter { + app_handle: AppHandle, + device_id: String, + started_at: Instant, + peak_bits: AtomicU32, + last_emit_ms: AtomicU64, +} + +impl DeviceLevelMeter { + fn new(app_handle: AppHandle, device_id: String) -> Self { + Self { + app_handle, + device_id, + started_at: Instant::now(), + peak_bits: AtomicU32::new(0), + last_emit_ms: AtomicU64::new(0), + } + } + + fn push(&self, peak: f32) { + self.peak_bits.fetch_max(peak.to_bits(), Ordering::Relaxed); + + let now_ms = self.started_at.elapsed().as_millis() as u64; + let last_ms = self.last_emit_ms.load(Ordering::Relaxed); + if now_ms.saturating_sub(last_ms) < LEVEL_EMIT_INTERVAL_MS { + return; + } + if self + .last_emit_ms + .compare_exchange(last_ms, now_ms, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return; + } + let level = f32::from_bits(self.peak_bits.swap(0, Ordering::Relaxed)).clamp(0.0, 1.0); + self.app_handle + .emit_to( + "main", + "input_level", + json!({ "deviceId": self.device_id, "level": level }), + ) + .ok(); + } +} + +fn build_preview_stream_typed(device: &Device, config: SupportedStreamConfig, meter: Arc) -> Result +where + T: SizedSample + FromSample + Copy, + f32: FromSample, +{ + let stream = device.build_input_stream( + config.into(), + move |data: &[T], _: &_| { + meter.push(buffer_peak(data)); + }, + |err| tracing::error!("An error occurred on input preview stream: {}", err), + None, + )?; + Ok(stream) +} + +fn build_preview_stream(device: &Device, config: SupportedStreamConfig, meter: Arc) -> Result { + match config.sample_format() { + cpal::SampleFormat::I8 => build_preview_stream_typed::(device, config, meter), + cpal::SampleFormat::I16 => build_preview_stream_typed::(device, config, meter), + cpal::SampleFormat::I32 => build_preview_stream_typed::(device, config, meter), + cpal::SampleFormat::F32 => build_preview_stream_typed::(device, config, meter), + sample_format => bail!("Unsupported sample format '{}'", sample_format), + } +} + +#[tauri::command] +/// Open a meter-only capture stream per input device and emit `input_level` +/// events (`{ deviceId, level }`, ~10/s). Idempotent: a second call while the +/// preview is active is a no-op. Devices that cannot be opened are skipped with +/// a warning so one busy mic never blocks the rest. +pub fn start_input_level_preview(app_handle: AppHandle, preview: tauri::State<'_, InputPreviewState>) -> Result<()> { + { + let guard = preview.streams.lock().map_err(|e| eyre!("{:?}", e))?; + if guard.is_some() { + return Ok(()); + } + } + + let host = cpal::default_host(); + let mut streams = Vec::new(); + + let devices = host.devices()?; + for (device_index, device) in devices.enumerate() { + if !device.supports_input() { + continue; + } + let device_id = device_index.to_string(); + let config = match device.default_input_config() { + Ok(config) => config, + Err(error) => { + tracing::warn!("Skipping input device {device_id} for level preview: {error:#}"); + continue; + } + }; + let meter = Arc::new(DeviceLevelMeter::new(app_handle.clone(), device_id.clone())); + match build_preview_stream(&device, config, meter).and_then(|stream| stream.play().map(|_| stream).map_err(Into::into)) { + Ok(stream) => streams.push(StreamHandle(stream)), + Err(error) => tracing::warn!("Skipping input device {device_id} for level preview: {error:#}"), + } + } + + let mut guard = preview.streams.lock().map_err(|e| eyre!("{:?}", e))?; + *guard = Some(streams); + Ok(()) +} + +#[tauri::command] +/// Close the meter-only streams opened by [`start_input_level_preview`]. +pub fn stop_input_level_preview(preview: tauri::State<'_, InputPreviewState>) -> Result<()> { + preview.stop(); + Ok(()) +} + /// Peak magnitude of a buffer, normalized to 0..1. fn buffer_peak(input: &[T]) -> f32 where @@ -152,6 +292,11 @@ pub async fn start_record(app_handle: AppHandle, devices: Vec, reco if devices.is_empty() { bail!("At least one audio device is required"); } + // The input-level preview holds the same devices open while the dropdown is shown; + // release it first so a real recording never fights the meter-only streams. + if let Some(preview) = app_handle.try_state::() { + preview.stop(); + } let host = cpal::default_host(); let mut wav_paths: Vec<(PathBuf, u32)> = Vec::new(); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index af2294381..ee315aa56 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -138,6 +138,8 @@ async fn main() -> Result<()> { cmd::files::get_argv, cmd::files::get_default_projects_path, cmd::audio::get_audio_devices, + cmd::audio::start_input_level_preview, + cmd::audio::stop_input_level_preview, cmd::audio::start_record, cmd::app::get_models_folder, cmd::app::get_logs_folder, diff --git a/desktop/src-tauri/src/setup.rs b/desktop/src-tauri/src/setup.rs index a5e4203c9..0e88682e7 100644 --- a/desktop/src-tauri/src/setup.rs +++ b/desktop/src-tauri/src/setup.rs @@ -32,6 +32,7 @@ pub fn setup(app: &App) -> Result<(), Box> { // Manage server state app.manage(Mutex::new(ServerState { process: None })); app.manage(crate::dictation_indicator::DictationIndicatorRuntime::default()); + app.manage(crate::cmd::audio::InputPreviewState::default()); let store = app.store(STORE_FILENAME)?; diff --git a/desktop/src/components/audio-device-input.tsx b/desktop/src/components/audio-device-input.tsx index 34b1c2e46..6a042b1a0 100644 --- a/desktop/src/components/audio-device-input.tsx +++ b/desktop/src/components/audio-device-input.tsx @@ -1,5 +1,7 @@ +import { listen } from '@tauri-apps/api/event' +import { useEffect, useState } from 'react' import { m } from '~/paraglide/messages.js' -import { AudioDevice } from '~/lib/audio' +import { AudioDevice, InputLevelPayload, clampLevel, inputLevelColor, startInputLevelPreview, stopInputLevelPreview } from '~/lib/audio' import { ModifyState } from '~/lib/types' import { Label } from '~/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select' @@ -11,8 +13,51 @@ interface AudioDeviceInputProps { setDevice: ModifyState } +/** + * Compact horizontal meter shown left of an input device name while the + * dropdown is open. Width and color both encode the level: a short red bar + * for quiet, a full green bar for loud. + */ +export function InputLevelBar({ level }: { level: number }) { + const clamped = clampLevel(level) + return ( + + + + ) +} + export default function AudioDeviceInput({ type, devices, device, setDevice }: AudioDeviceInputProps) { const filtered = devices.filter((d) => (d.isInput && type === 'input') || (!d.isInput && type === 'output')) + const isInput = type === 'input' + const [open, setOpen] = useState(false) + const [levels, setLevels] = useState>({}) + + // While the input dropdown is open, run a meter-only capture per mic so every + // row shows its live level. Closing (or unmounting) stops the preview streams. + useEffect(() => { + if (!isInput || !open) return + + const unlisten = listen('input_level', ({ payload }) => { + if (!payload || typeof payload.deviceId !== 'string') return + const level = clampLevel(payload.level) + setLevels((prev) => (prev[payload.deviceId] === level ? prev : { ...prev, [payload.deviceId]: level })) + }) + void startInputLevelPreview().catch((error) => { + console.error('Could not start input level preview:', error) + }) + + return () => { + unlisten.then((fn) => fn()) + void stopInputLevelPreview().catch(() => { + // Best effort on teardown; the backend also stops the preview when a recording starts. + }) + setLevels({}) + } + }, [isInput, open]) return (
@@ -26,14 +71,15 @@ export default function AudioDeviceInput({ type, devices, device, setDevice }: A } const next = filtered.find((d) => d.id === value) setDevice(next ?? null) - }}> + }} + onOpenChange={setOpen}> {m.noRecord()} {filtered.map(({ id, name }) => ( - + : undefined}> {name} ))} diff --git a/desktop/src/components/ui/select.tsx b/desktop/src/components/ui/select.tsx index 759db8f58..1363ef355 100644 --- a/desktop/src/components/ui/select.tsx +++ b/desktop/src/components/ui/select.tsx @@ -78,8 +78,13 @@ const SelectLabel = React.forwardRef, React.ComponentPropsWithoutRef>( - ({ className, children, ...props }, ref) => ( +interface SelectItemProps extends React.ComponentPropsWithoutRef { + /** Rendered inside the item but outside ItemText, so it shows in the list and never in SelectValue. */ + leading?: React.ReactNode +} + +const SelectItem = React.forwardRef, SelectItemProps>( + ({ className, children, leading, ...props }, ref) => ( + {leading} {children} ), diff --git a/desktop/src/lib/audio.test.ts b/desktop/src/lib/audio.test.ts new file mode 100644 index 000000000..0672631bf --- /dev/null +++ b/desktop/src/lib/audio.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { clampLevel, inputLevelColor } from './audio' + +describe('clampLevel', () => { + it.each([ + [-1, 0], + [0, 0], + [0.42, 0.42], + [1, 1], + [2, 1], + ])('clamps %s to %s', (value, expected) => { + expect(clampLevel(value)).toBe(expected) + }) + + it.each([Number.NaN, Infinity, 'loud', null, undefined])('falls back to 0 for %s', (value) => { + expect(clampLevel(value)).toBe(0) + }) +}) + +describe('inputLevelColor', () => { + it('reads red when quiet and green when loud', () => { + expect(inputLevelColor(0)).toBe('hsl(0 80% 45%)') + expect(inputLevelColor(1)).toBe('hsl(120 80% 45%)') + }) + + it('moves through the middle for a mid level', () => { + expect(inputLevelColor(0.5)).toBe('hsl(60 80% 45%)') + }) +}) diff --git a/desktop/src/lib/audio.ts b/desktop/src/lib/audio.ts index 2eeb9a545..a7784bf04 100644 --- a/desktop/src/lib/audio.ts +++ b/desktop/src/lib/audio.ts @@ -1,6 +1,32 @@ +import { invoke } from '@tauri-apps/api/core' + export interface AudioDevice { isDefault: boolean isInput: boolean id: string name: string } + +/** Payload of the backend `input_level` event: live 0..1 peak of one input device, ~10/s. */ +export interface InputLevelPayload { + deviceId: string + level: number +} + +export const startInputLevelPreview = () => invoke('start_input_level_preview') + +export const stopInputLevelPreview = () => invoke('stop_input_level_preview') + +/** Clamp a raw meter value to the 0..1 range the UI expects. */ +export function clampLevel(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0 +} + +/** + * Meter color for a 0..1 level: quiet reads red, loud reads green. + * Hue interpolates 0 (red) → 120 (green) so the fill color encodes the level. + */ +export function inputLevelColor(level: number): string { + const hue = Math.round(clampLevel(level) * 120) + return `hsl(${hue} 80% 45%)` +} diff --git a/desktop/src/mock-tauri/handlers/media-misc.ts b/desktop/src/mock-tauri/handlers/media-misc.ts index 1b14c6468..7be960121 100644 --- a/desktop/src/mock-tauri/handlers/media-misc.ts +++ b/desktop/src/mock-tauri/handlers/media-misc.ts @@ -25,6 +25,7 @@ const YTDLP_TICK_MS = 100 let dictationIndicatorEnabled = false let meetingDetectionEnabled = false let meetingPromptState: { source: 'meet' | 'zoom' | 'teams' } | null = null +let inputPreviewTimer: number | null = null function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -89,6 +90,25 @@ export const mediaMiscHandlers: CommandHandlerMap = { return undefined }, + start_input_level_preview: () => { + if (inputPreviewTimer !== null) return + // Like the Rust side: one `input_level` per input device, ~10/s. + let tick = 0 + inputPreviewTimer = window.setInterval(() => { + tick += 1 + for (const device of MOCK_AUDIO_DEVICES.filter((d) => d.isInput)) { + emitMockEvent('input_level', { deviceId: device.id, level: mockSpeechLevel(tick) }) + } + }, RECORD_LEVEL_TICK_MS) + }, + + stop_input_level_preview: () => { + if (inputPreviewTimer !== null) { + window.clearInterval(inputPreviewTimer) + inputPreviewTimer = null + } + }, + // --- Text injection --------------------------------------------------------- type_text: (args) => { From d115640341e9ded02b9eee62d73a8aee1c69f058 Mon Sep 17 00:00:00 2001 From: Vinicios de Lima Clarindo Date: Thu, 24 Sep 2026 10:18:53 -0300 Subject: [PATCH 5/6] Fix start preview race and cover lifecycle with tests --- desktop/src-tauri/src/cmd/audio.rs | 18 ++--- .../components/audio-device-input.test.tsx | 78 +++++++++++++++++++ desktop/src/components/audio-device-input.tsx | 6 +- 3 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 desktop/src/components/audio-device-input.test.tsx diff --git a/desktop/src-tauri/src/cmd/audio.rs b/desktop/src-tauri/src/cmd/audio.rs index 37bc33423..172404af6 100644 --- a/desktop/src-tauri/src/cmd/audio.rs +++ b/desktop/src-tauri/src/cmd/audio.rs @@ -172,11 +172,7 @@ impl DeviceLevelMeter { } let level = f32::from_bits(self.peak_bits.swap(0, Ordering::Relaxed)).clamp(0.0, 1.0); self.app_handle - .emit_to( - "main", - "input_level", - json!({ "deviceId": self.device_id, "level": level }), - ) + .emit_to("main", "input_level", json!({ "deviceId": self.device_id, "level": level })) .ok(); } } @@ -212,12 +208,13 @@ fn build_preview_stream(device: &Device, config: SupportedStreamConfig, meter: A /// events (`{ deviceId, level }`, ~10/s). Idempotent: a second call while the /// preview is active is a no-op. Devices that cannot be opened are skipped with /// a warning so one busy mic never blocks the rest. +/// +/// The mutex is held for the whole open so two concurrent starts cannot both +/// pass the "already running" check and open duplicate device streams. pub fn start_input_level_preview(app_handle: AppHandle, preview: tauri::State<'_, InputPreviewState>) -> Result<()> { - { - let guard = preview.streams.lock().map_err(|e| eyre!("{:?}", e))?; - if guard.is_some() { - return Ok(()); - } + let mut guard = preview.streams.lock().map_err(|e| eyre!("{:?}", e))?; + if guard.is_some() { + return Ok(()); } let host = cpal::default_host(); @@ -243,7 +240,6 @@ pub fn start_input_level_preview(app_handle: AppHandle, preview: tauri::State<'_ } } - let mut guard = preview.streams.lock().map_err(|e| eyre!("{:?}", e))?; *guard = Some(streams); Ok(()) } diff --git a/desktop/src/components/audio-device-input.test.tsx b/desktop/src/components/audio-device-input.test.tsx new file mode 100644 index 000000000..ad8b07709 --- /dev/null +++ b/desktop/src/components/audio-device-input.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AudioDeviceInput from './audio-device-input' +import type { AudioDevice } from '~/lib/audio' + +const mocks = vi.hoisted(() => ({ + listen: vi.fn(), + start: vi.fn(), + stop: vi.fn(), +})) + +vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen })) +vi.mock('~/lib/audio', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + startInputLevelPreview: mocks.start, + stopInputLevelPreview: mocks.stop, + } +}) + +const mic: AudioDevice = { id: '1', name: 'Built-in Mic', isDefault: true, isInput: true } +const speaker: AudioDevice = { id: '2', name: 'Speakers', isDefault: true, isInput: false } + +beforeEach(() => { + // jsdom has no layout; Radix Select calls this when the popover opens. + Element.prototype.scrollIntoView = vi.fn() + vi.clearAllMocks() + mocks.listen.mockResolvedValue(vi.fn()) + mocks.start.mockResolvedValue(undefined) + mocks.stop.mockResolvedValue(undefined) +}) + +afterEach(cleanup) + +function renderInput(overrides: Partial[0]> = {}) { + return render( + , + ) +} + +describe('input level preview lifecycle', () => { + it('starts the preview when the microphone dropdown opens and stops it when it closes', async () => { + renderInput() + fireEvent.click(screen.getByRole('combobox')) + await waitFor(() => expect(mocks.start).toHaveBeenCalledTimes(1)) + expect(mocks.listen).toHaveBeenCalledWith('input_level', expect.any(Function)) + + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => expect(mocks.stop).toHaveBeenCalledTimes(1)) + }) + + it('does not touch the preview for the speakers dropdown', async () => { + renderInput({ type: 'output', device: speaker }) + fireEvent.click(screen.getByRole('combobox')) + // "None" only exists inside the open list; the closed trigger shows the selected speaker. + await waitFor(() => expect(screen.getAllByText('None')).toHaveLength(1)) + expect(mocks.start).not.toHaveBeenCalled() + expect(mocks.stop).not.toHaveBeenCalled() + }) + + it('stops the preview if the component unmounts while open', async () => { + const { unmount } = renderInput() + fireEvent.click(screen.getByRole('combobox')) + await waitFor(() => expect(mocks.start).toHaveBeenCalledTimes(1)) + + unmount() + await waitFor(() => expect(mocks.stop).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/desktop/src/components/audio-device-input.tsx b/desktop/src/components/audio-device-input.tsx index 6a042b1a0..ddc6ab94d 100644 --- a/desktop/src/components/audio-device-input.tsx +++ b/desktop/src/components/audio-device-input.tsx @@ -51,10 +51,8 @@ export default function AudioDeviceInput({ type, devices, device, setDevice }: A }) return () => { - unlisten.then((fn) => fn()) - void stopInputLevelPreview().catch(() => { - // Best effort on teardown; the backend also stops the preview when a recording starts. - }) + unlisten.then((fn) => fn()).catch(() => {}) + void stopInputLevelPreview().catch(() => {}) setLevels({}) } }, [isInput, open]) From 640d6fb2dc7e01988ab4bd967a08432eb85f5a25 Mon Sep 17 00:00:00 2001 From: Vinicios de Lima Clarindo Date: Thu, 24 Sep 2026 10:34:23 -0300 Subject: [PATCH 6/6] Fall back to most recently used mic when no default is marked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On hosts where default_input_device() never matches the enumerated list (common with ALSA via PipeWire), callers only checked isDefault and threw "No default microphone" even though inputs existed. findInputDevice now prefers: saved id → marked default → most recent activity → first input. Successful starts stamp activity so the next fallback prefers the mic that last worked. Applied to dictation hotkey, recording shortcut, and the recording tab. --- desktop/src/lib/audio.test.ts | 43 +++++++++++++- desktop/src/lib/audio.ts | 59 +++++++++++++++++++ desktop/src/lib/config-keys.ts | 2 + desktop/src/pages/home/hooks/use-recording.ts | 14 +++-- desktop/src/providers/hotkey.tsx | 11 +++- desktop/src/providers/recording-shortcut.tsx | 10 +++- 6 files changed, 127 insertions(+), 12 deletions(-) diff --git a/desktop/src/lib/audio.test.ts b/desktop/src/lib/audio.test.ts index 0672631bf..14d7bdd9e 100644 --- a/desktop/src/lib/audio.test.ts +++ b/desktop/src/lib/audio.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { clampLevel, inputLevelColor } from './audio' +import { clampLevel, findInputDevice, inputLevelColor, type AudioDevice } from './audio' + +function input(id: string, overrides: Partial = {}): AudioDevice { + return { id, name: id, isInput: true, isDefault: false, ...overrides } +} describe('clampLevel', () => { it.each([ @@ -27,3 +31,40 @@ describe('inputLevelColor', () => { expect(inputLevelColor(0.5)).toBe('hsl(60 80% 45%)') }) }) + +describe('findInputDevice', () => { + it('returns null when there are no inputs', () => { + expect(findInputDevice([{ id: '0', name: 'out', isInput: false, isDefault: true }])).toBeNull() + }) + + it('prefers the saved id when that input is still present', () => { + const devices = [input('a'), input('b', { isDefault: true })] + expect(findInputDevice(devices, 'b')?.id).toBe('b') + expect(findInputDevice(devices, 'a')?.id).toBe('a') + }) + + it('ignores a stale saved id and falls through to default', () => { + const devices = [input('a'), input('b', { isDefault: true })] + expect(findInputDevice(devices, 'gone')?.id).toBe('b') + }) + + it('uses the device marked default when there is no saved id', () => { + const devices = [input('a'), input('b', { isDefault: true }), input('c')] + expect(findInputDevice(devices)?.id).toBe('b') + }) + + it('falls back to the most recently used input when no default is marked', () => { + const devices = [input('a'), input('b'), input('c')] + expect(findInputDevice(devices, null, { a: 100, c: 300, b: 200 })?.id).toBe('c') + }) + + it('falls back to the first input when nothing else applies', () => { + expect(findInputDevice([input('a'), input('b')])?.id).toBe('a') + }) + + it('ignores activity entries that are not finite numbers', () => { + const devices = [input('a'), input('b')] + expect(findInputDevice(devices, null, { a: Number.NaN, b: 5 })?.id).toBe('b') + expect(findInputDevice(devices, null, { a: Number.NaN, b: Number.NaN })?.id).toBe('a') + }) +}) diff --git a/desktop/src/lib/audio.ts b/desktop/src/lib/audio.ts index a7784bf04..0e0527952 100644 --- a/desktop/src/lib/audio.ts +++ b/desktop/src/lib/audio.ts @@ -1,4 +1,6 @@ import { invoke } from '@tauri-apps/api/core' +import { CONFIG_KEYS } from '~/lib/config-keys' +import { readConfig, writeConfig } from '~/lib/config-store' export interface AudioDevice { isDefault: boolean @@ -7,6 +9,63 @@ export interface AudioDevice { name: string } +/** deviceId → epoch ms of the last successful recording start that used that input. */ +export type RecentInputActivity = Record + +/** + * Pick the input device to record with when no explicit choice applies. + * + * Order: saved id (if still present) → device marked default → most recently + * used input → first available input. The last two steps cover hosts where + * `default_input_device()` never matches the enumerated list (common on ALSA + * via PipeWire), which otherwise surfaces as "No default microphone". + */ +export function findInputDevice( + devices: AudioDevice[], + savedId?: string | null, + recentActivity?: RecentInputActivity | null, +): AudioDevice | null { + const inputs = devices.filter((device) => device.isInput) + if (inputs.length === 0) return null + + if (savedId) { + const saved = inputs.find((device) => device.id === savedId) + if (saved) return saved + } + + const marked = inputs.find((device) => device.isDefault) + if (marked) return marked + + if (recentActivity) { + let best: AudioDevice | null = null + let bestAt = -1 + for (const device of inputs) { + const at = recentActivity[device.id] + if (typeof at === 'number' && at > bestAt) { + best = device + bestAt = at + } + } + if (best) return best + } + + return inputs[0] ?? null +} + +/** Stamp `deviceId` as the most recently used input so the next fallback prefers it. */ +export function noteInputDeviceUsed(deviceId: string) { + const activity = readConfig(CONFIG_KEYS.recentInputDeviceActivity, {}) + writeConfig(CONFIG_KEYS.recentInputDeviceActivity, { + ...activity, + [deviceId]: Date.now(), + }) +} + +/** Most-recent-first activity map for `findInputDevice`, read from the config store. */ +export function readRecentInputActivity(): RecentInputActivity { + return readConfig(CONFIG_KEYS.recentInputDeviceActivity, {}) +} + /** Payload of the backend `input_level` event: live 0..1 peak of one input device, ~10/s. */ export interface InputLevelPayload { deviceId: string diff --git a/desktop/src/lib/config-keys.ts b/desktop/src/lib/config-keys.ts index e04d5ad33..b0881c5c8 100644 --- a/desktop/src/lib/config-keys.ts +++ b/desktop/src/lib/config-keys.ts @@ -44,6 +44,8 @@ export const CONFIG_KEYS = { // Recording inputDeviceId: 'recording.inputDeviceId', outputDeviceId: 'recording.outputDeviceId', + /** deviceId → epoch ms of the last successful start, used when no default mic is marked. */ + recentInputDeviceActivity: 'recording.recentInputDeviceActivity', recordingShortcutEnabled: 'recording.shortcutEnabled', recordingShortcut: 'recording.shortcut', meetingDetectionEnabled: 'recording.meetingDetectionEnabled', diff --git a/desktop/src/pages/home/hooks/use-recording.ts b/desktop/src/pages/home/hooks/use-recording.ts index 4c2e3097b..513ddaf5d 100644 --- a/desktop/src/pages/home/hooks/use-recording.ts +++ b/desktop/src/pages/home/hooks/use-recording.ts @@ -2,6 +2,7 @@ import { emit } from '@tauri-apps/api/event' import { invoke } from '@tauri-apps/api/core' import { type SetStateAction, useContext, useEffect, useState } from 'react' import type { AudioDevice } from '~/lib/audio' +import { findInputDevice, noteInputDeviceUsed, readRecentInputActivity } from '~/lib/audio' import { CONFIG_KEYS } from '~/lib/config-keys' import { usePersisted } from '~/lib/config-store' import { KEEP_AWAKE, startKeepAwake, stopKeepAwake } from '~/lib/keep-awake' @@ -34,13 +35,13 @@ export function useRecording(onBeforeStart: () => void) { async function loadAudioDevices() { const newDevices = await invoke('get_audio_devices') - const inputs = newDevices.filter((device) => device.isInput) const outputs = newDevices.filter((device) => !device.isInput) - setInputDevice( - savedInputDeviceId === null - ? (inputs.find((device) => device.isDefault) ?? null) - : (inputs.find((device) => device.id === savedInputDeviceId) ?? null), - ) + // Empty string is an explicit "None" choice; null means never configured. + if (savedInputDeviceId === '') { + setInputDevice(null) + } else { + setInputDevice(findInputDevice(newDevices, savedInputDeviceId, readRecentInputActivity())) + } setOutputDevice( savedOutputDeviceId === null ? (outputs.find((device) => device.isDefault) ?? null) @@ -64,6 +65,7 @@ export function useRecording(onBeforeStart: () => void) { devices: selectedDevices, recordingName: recordingName.trim() || null, }) + if (inputDevice) noteInputDeviceUsed(inputDevice.id) } catch (error) { stopKeepAwake(KEEP_AWAKE.record) setIsRecording(false) diff --git a/desktop/src/providers/hotkey.tsx b/desktop/src/providers/hotkey.tsx index 5b927251d..6d339f60c 100644 --- a/desktop/src/providers/hotkey.tsx +++ b/desktop/src/providers/hotkey.tsx @@ -5,10 +5,10 @@ import { emit, listen } from '@tauri-apps/api/event' import { register, unregister, isRegistered } from '@tauri-apps/plugin-global-shortcut' import * as clipboard from '@tauri-apps/plugin-clipboard-manager' import { trackTranscribeFailed, trackTranscribeStarted, trackTranscribeSucceeded } from '~/lib/analytics' -import { AudioDevice } from '~/lib/audio' +import { AudioDevice, findInputDevice, noteInputDeviceUsed, readRecentInputActivity } from '~/lib/audio' import { CONFIG_KEYS } from '~/lib/config-keys' import { gpuOutOfMemoryBefore } from '~/lib/gpu-memory' -import { usePersisted } from '~/lib/config-store' +import { readConfig, usePersisted } from '~/lib/config-store' import { createClient, fillPrompt } from '~/lib/ai' import { withoutUnsupportedOptions } from '~/lib/model' import { isUserError } from '~/lib/server-errors' @@ -130,7 +130,11 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { showIndicator('recording') try { const devices = await invoke('get_audio_devices') - const defaultInput = devices.find((d) => d.isDefault && d.isInput) + const defaultInput = findInputDevice( + devices, + readConfig(CONFIG_KEYS.inputDeviceId, null), + readRecentInputActivity(), + ) if (!defaultInput) { throw new Error(m.noDefaultMicrophone()) } @@ -143,6 +147,7 @@ export function HotkeyProvider({ children }: { children: ReactNode }) { devices: [defaultInput], recordingName: null, }) + noteInputDeviceUsed(defaultInput.id) } catch (error) { console.error('Hotkey start_record error:', error) finishIndicator('error', { message: getErrorMessage(error) }) diff --git a/desktop/src/providers/recording-shortcut.tsx b/desktop/src/providers/recording-shortcut.tsx index 55992ec3a..f122fc842 100644 --- a/desktop/src/providers/recording-shortcut.tsx +++ b/desktop/src/providers/recording-shortcut.tsx @@ -4,8 +4,9 @@ import { isRegistered, register, unregister } from '@tauri-apps/plugin-global-sh import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import { toast } from 'sonner' import type { AudioDevice } from '~/lib/audio' +import { findInputDevice, noteInputDeviceUsed, readRecentInputActivity } from '~/lib/audio' import { CONFIG_KEYS } from '~/lib/config-keys' -import { usePersisted } from '~/lib/config-store' +import { readConfig, usePersisted } from '~/lib/config-store' import { getDefaultRecordingShortcut } from '~/lib/config' import type { MeetingRecordingOptions } from '~/lib/meeting-prompt' import { ensureSystemAudioPermission } from '~/lib/permissions' @@ -64,7 +65,11 @@ export function RecordingShortcutProvider({ children }: { children: ReactNode }) startingRef.current = true try { const devices = await invoke('get_audio_devices') - const microphone = devices.find((device) => device.isDefault && device.isInput) + const microphone = findInputDevice( + devices, + readConfig(CONFIG_KEYS.inputDeviceId, null), + readRecentInputActivity(), + ) const systemAudio = devices.find((device) => device.isDefault && !device.isInput) if (options.microphone && !microphone) throw new Error(m.noDefaultMicrophone()) if (options.systemAudio && !systemAudio) throw new Error(m.systemAudioPermissionInfo()) @@ -74,6 +79,7 @@ export function RecordingShortcutProvider({ children }: { children: ReactNode }) ) if (selectedDevices.length === 0) throw new Error(m.noDefaultMicrophone()) await invoke('start_record', { devices: selectedDevices, recordingName: null }) + if (microphone) noteInputDeviceUsed(microphone.id) recordingRef.current = true normalRecordingActiveRef.current = true setIsShortcutRecording(true)