feat: add voice mode support via mic passthrough to container
Some checks failed
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m24s
Build App / sync-to-github (push) Has been cancelled
Build App / build-linux (push) Has been cancelled
Build Container / build-container (push) Successful in 54s

Enables Claude Code's /voice command inside Docker containers by
capturing microphone audio in the Tauri webview and streaming it
into the container via a FIFO pipe.

Container: fake rec/arecord shims read PCM from a FIFO instead of
a real mic. Audio bridge exec writes PCM from Tauri into the FIFO.
Frontend: getUserMedia() + AudioWorklet captures 16kHz mono PCM
and streams it to the container via invoke("send_audio_data").
UI: "Mic Off/On" toggle button in the terminal view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 06:11:33 -08:00
parent 58a10c65e9
commit 86176d8830
9 changed files with 249 additions and 1 deletions

View File

@@ -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 { useVoice } from "../../hooks/useVoice";
import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast";
@@ -23,6 +24,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const voice = useVoice(sessionId);
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
@@ -200,6 +203,7 @@ export default function TerminalView({ sessionId, active }: Props) {
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null;
term.dispose();
voice.stop();
};
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -284,6 +288,32 @@ export default function TerminalView({ sessionId, active }: Props) {
{imagePasteMsg}
</div>
)}
<button
onClick={voice.toggle}
title={
voice.state === "active"
? "Voice active — click to stop"
: voice.error
? `Voice error: ${voice.error}`
: "Enable voice input for /voice mode"
}
className={`absolute bottom-4 left-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium border shadow-lg transition-colors cursor-pointer ${
voice.state === "active"
? "bg-[#1a3a2a] text-[#3fb950] border-[#238636] hover:bg-[#243b2a]"
: voice.state === "starting"
? "bg-[#1f2937] text-[#d29922] border-[#30363d] opacity-75"
: voice.state === "error"
? "bg-[#3a1a1a] text-[#ff7b72] border-[#da3633] hover:bg-[#4a2020]"
: "bg-[#1f2937] text-[#b1bac4] border-[#30363d] hover:bg-[#2d3748] hover:text-[#e6edf3]"
}`}
disabled={voice.state === "starting"}
>
{voice.state === "active"
? "Mic On"
: voice.state === "starting"
? "Mic..."
: "Mic Off"}
</button>
{!isAtBottom && (
<button
onClick={handleScrollToBottom}