From c5e28f9caac1d0de7b3c18425ef2eb1aff0c492e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 5 Mar 2026 06:15:47 -0800 Subject: [PATCH] feat: add microphone selection to settings Adds a dropdown in Settings to choose which audio input device to use for voice mode. Enumerates devices via the browser's mediaDevices API and persists the selection in AppSettings. The useVoice hook passes the selected deviceId to getUserMedia(). Co-Authored-By: Claude Opus 4.6 --- app/src-tauri/src/models/app_settings.rs | 3 + .../settings/MicrophoneSettings.tsx | 101 ++++++++++++++++++ app/src/components/settings/SettingsPanel.tsx | 3 + app/src/components/terminal/TerminalView.tsx | 4 +- app/src/hooks/useVoice.ts | 23 ++-- app/src/lib/types.ts | 1 + 6 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 app/src/components/settings/MicrophoneSettings.tsx diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 4311498..401a0f1 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -70,6 +70,8 @@ pub struct AppSettings { pub dismissed_update_version: Option, #[serde(default)] pub timezone: Option, + #[serde(default)] + pub default_microphone: Option, } impl Default for AppSettings { @@ -87,6 +89,7 @@ impl Default for AppSettings { auto_check_updates: true, dismissed_update_version: None, timezone: None, + default_microphone: None, } } } diff --git a/app/src/components/settings/MicrophoneSettings.tsx b/app/src/components/settings/MicrophoneSettings.tsx new file mode 100644 index 0000000..308b48d --- /dev/null +++ b/app/src/components/settings/MicrophoneSettings.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect, useCallback } from "react"; +import { useSettings } from "../../hooks/useSettings"; + +interface AudioDevice { + deviceId: string; + label: string; +} + +export default function MicrophoneSettings() { + const { appSettings, saveSettings } = useSettings(); + const [devices, setDevices] = useState([]); + const [selected, setSelected] = useState(appSettings?.default_microphone ?? ""); + const [loading, setLoading] = useState(false); + const [permissionNeeded, setPermissionNeeded] = useState(false); + + // Sync local state when appSettings change + useEffect(() => { + setSelected(appSettings?.default_microphone ?? ""); + }, [appSettings?.default_microphone]); + + const enumerateDevices = useCallback(async () => { + setLoading(true); + setPermissionNeeded(false); + try { + // Request mic permission first so device labels are available + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach((t) => t.stop()); + + const allDevices = await navigator.mediaDevices.enumerateDevices(); + const mics = allDevices + .filter((d) => d.kind === "audioinput") + .map((d) => ({ + deviceId: d.deviceId, + label: d.label || `Microphone (${d.deviceId.slice(0, 8)}...)`, + })); + setDevices(mics); + } catch { + setPermissionNeeded(true); + } finally { + setLoading(false); + } + }, []); + + // Enumerate devices on mount + useEffect(() => { + enumerateDevices(); + }, [enumerateDevices]); + + const handleChange = async (deviceId: string) => { + setSelected(deviceId); + if (appSettings) { + await saveSettings({ ...appSettings, default_microphone: deviceId || null }); + } + }; + + return ( +
+ +

+ Audio input device for Claude Code voice mode (/voice) +

+ {permissionNeeded ? ( +
+ + Microphone permission required + + +
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 347e9f0..6c53b71 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import ApiKeyInput from "./ApiKeyInput"; import DockerSettings from "./DockerSettings"; import AwsSettings from "./AwsSettings"; +import MicrophoneSettings from "./MicrophoneSettings"; import { useSettings } from "../../hooks/useSettings"; import { useUpdates } from "../../hooks/useUpdates"; import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal"; @@ -59,6 +60,8 @@ export default function SettingsPanel() { + + {/* Container Timezone */}
diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index fa14cb8..a50b50d 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -6,6 +6,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; +import { useSettings } from "../../hooks/useSettings"; import { useVoice } from "../../hooks/useVoice"; import { UrlDetector } from "../../lib/urlDetector"; import UrlToast from "./UrlToast"; @@ -23,8 +24,9 @@ export default function TerminalView({ sessionId, active }: Props) { const webglRef = useRef(null); const detectorRef = useRef(null); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); + const { appSettings } = useSettings(); - const voice = useVoice(sessionId); + const voice = useVoice(sessionId, appSettings?.default_microphone); const [detectedUrl, setDetectedUrl] = useState(null); const [imagePasteMsg, setImagePasteMsg] = useState(null); diff --git a/app/src/hooks/useVoice.ts b/app/src/hooks/useVoice.ts index b3ed916..b7800df 100644 --- a/app/src/hooks/useVoice.ts +++ b/app/src/hooks/useVoice.ts @@ -3,7 +3,7 @@ import * as commands from "../lib/tauri-commands"; type VoiceState = "inactive" | "starting" | "active" | "error"; -export function useVoice(sessionId: string) { +export function useVoice(sessionId: string, deviceId?: string | null) { const [state, setState] = useState("inactive"); const [error, setError] = useState(null); @@ -20,14 +20,19 @@ export function useVoice(sessionId: string) { // 1. Start the audio bridge in the container (creates FIFO writer) await commands.startAudioBridge(sessionId); - // 2. Get microphone access + // 2. Get microphone access (use specific device if configured) + const audioConstraints: MediaTrackConstraints = { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }; + if (deviceId) { + audioConstraints.deviceId = { exact: deviceId }; + } + const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, + audio: audioConstraints, }); streamRef.current = stream; @@ -62,7 +67,7 @@ export function useVoice(sessionId: string) { // Clean up on failure await commands.stopAudioBridge(sessionId).catch(() => {}); } - }, [sessionId, state]); + }, [sessionId, state, deviceId]); const stop = useCallback(async () => { // Tear down audio pipeline diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 593e055..c94fb28 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -100,6 +100,7 @@ export interface AppSettings { auto_check_updates: boolean; dismissed_update_version: string | null; timezone: string | null; + default_microphone: string | null; } export interface UpdateInfo {