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 {