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 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,8 @@ pub struct AppSettings {
|
|||||||
pub dismissed_update_version: Option<String>,
|
pub dismissed_update_version: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_microphone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -87,6 +89,7 @@ impl Default for AppSettings {
|
|||||||
auto_check_updates: true,
|
auto_check_updates: true,
|
||||||
dismissed_update_version: None,
|
dismissed_update_version: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
|
default_microphone: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
@@ -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<AudioDevice[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Microphone</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Audio input device for Claude Code voice mode (/voice)
|
||||||
|
</p>
|
||||||
|
{permissionNeeded ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Microphone permission required
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={enumerateDevices}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Grant Access
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<option value="">System Default</option>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={enumerateDevices}
|
||||||
|
disabled={loading}
|
||||||
|
title="Refresh microphone list"
|
||||||
|
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import ApiKeyInput from "./ApiKeyInput";
|
import ApiKeyInput from "./ApiKeyInput";
|
||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
|
import MicrophoneSettings from "./MicrophoneSettings";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { useUpdates } from "../../hooks/useUpdates";
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
@@ -59,6 +60,8 @@ export default function SettingsPanel() {
|
|||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
|
||||||
|
<MicrophoneSettings />
|
||||||
|
|
||||||
{/* Container Timezone */}
|
{/* Container Timezone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { useVoice } from "../../hooks/useVoice";
|
import { useVoice } from "../../hooks/useVoice";
|
||||||
import { UrlDetector } from "../../lib/urlDetector";
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
import UrlToast from "./UrlToast";
|
import UrlToast from "./UrlToast";
|
||||||
@@ -23,8 +24,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
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<string | null>(null);
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as commands from "../lib/tauri-commands";
|
|||||||
|
|
||||||
type VoiceState = "inactive" | "starting" | "active" | "error";
|
type VoiceState = "inactive" | "starting" | "active" | "error";
|
||||||
|
|
||||||
export function useVoice(sessionId: string) {
|
export function useVoice(sessionId: string, deviceId?: string | null) {
|
||||||
const [state, setState] = useState<VoiceState>("inactive");
|
const [state, setState] = useState<VoiceState>("inactive");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -20,14 +20,19 @@ export function useVoice(sessionId: string) {
|
|||||||
// 1. Start the audio bridge in the container (creates FIFO writer)
|
// 1. Start the audio bridge in the container (creates FIFO writer)
|
||||||
await commands.startAudioBridge(sessionId);
|
await commands.startAudioBridge(sessionId);
|
||||||
|
|
||||||
// 2. Get microphone access
|
// 2. Get microphone access (use specific device if configured)
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
audio: {
|
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
autoGainControl: true,
|
autoGainControl: true,
|
||||||
},
|
};
|
||||||
|
if (deviceId) {
|
||||||
|
audioConstraints.deviceId = { exact: deviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: audioConstraints,
|
||||||
});
|
});
|
||||||
streamRef.current = stream;
|
streamRef.current = stream;
|
||||||
|
|
||||||
@@ -62,7 +67,7 @@ export function useVoice(sessionId: string) {
|
|||||||
// Clean up on failure
|
// Clean up on failure
|
||||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [sessionId, state]);
|
}, [sessionId, state, deviceId]);
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
// Tear down audio pipeline
|
// Tear down audio pipeline
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export interface AppSettings {
|
|||||||
auto_check_updates: boolean;
|
auto_check_updates: boolean;
|
||||||
dismissed_update_version: string | null;
|
dismissed_update_version: string | null;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
|
default_microphone: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user