mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-25 12:16:00 +08:00
refactor(voice): provider-agnostic backend and in-app config
Switches the voice proxy to the OpenAI audio API (/v1/audio/transcriptions and /v1/audio/speech) so it works with OpenAI, Groq, or a local server. Adds a Settings -> Voice tab (base URL, API key, models, voice) plus a Quick Settings toggle, and removes the bundled Python sidecar. Review fixes: stop mic tracks on unmount, clear the global TTS stop handler and revoke leaked blob URLs, add fetch timeouts in the proxy, surface mic errors in the button, trim before appending transcripts, and drop the repo-wide wav ignore.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { voiceConfigHeaders } from '../../../hooks/useVoiceConfig';
|
||||
|
||||
// Only one message speaks at a time across the whole app.
|
||||
let stopActive: (() => void) | null = null;
|
||||
@@ -36,8 +37,14 @@ export function useTts(getText: () => string) {
|
||||
if (stopActive) stopActive = null;
|
||||
}, [reset]);
|
||||
|
||||
// Cleanup on unmount.
|
||||
useEffect(() => () => reset(), [reset]);
|
||||
// Cleanup on unmount: drop the global stop handler if it points at us, then reset.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (stopActive === stop) stopActive = null;
|
||||
reset();
|
||||
},
|
||||
[reset, stop],
|
||||
);
|
||||
|
||||
const play = useCallback(async () => {
|
||||
if (stopActive) stopActive();
|
||||
@@ -63,12 +70,16 @@ export function useTts(getText: () => string) {
|
||||
const res = await authenticatedFetch('/api/voice/tts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
headers: voiceConfigHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`tts ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (audioRef.current !== audio) {
|
||||
URL.revokeObjectURL(url); // stopped while loading; don't leak the blob URL
|
||||
return;
|
||||
}
|
||||
urlRef.current = url;
|
||||
if (audioRef.current !== audio) return; // stopped while loading
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
await audio.play();
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
// Whether the optional voice feature is configured on the server (VOICE_SIDECAR_URL set).
|
||||
// Probed once and cached app-wide so the mic/speak controls can hide themselves when off.
|
||||
let cached: boolean | null = null;
|
||||
let inflight: Promise<boolean> | null = null;
|
||||
// Voice UI is gated on the `voiceEnabled` UI preference (toggled in Quick Settings /
|
||||
// the Settings modal). This is a lightweight read-only view of that preference so the
|
||||
// mic/speak controls can hide themselves, kept in sync via the same events
|
||||
// useUiPreferences emits. No server probe.
|
||||
const STORAGE_KEY = 'uiPreferences';
|
||||
const SYNC_EVENT = 'ui-preferences:sync';
|
||||
|
||||
function probe(): Promise<boolean> {
|
||||
if (cached !== null) return Promise.resolve(cached);
|
||||
if (!inflight) {
|
||||
inflight = authenticatedFetch('/api/voice/health')
|
||||
.then((r) => (r.ok ? r.json() : { enabled: false }))
|
||||
.then((d) => {
|
||||
cached = Boolean(d?.enabled);
|
||||
return cached;
|
||||
})
|
||||
.catch(() => {
|
||||
cached = false;
|
||||
return false;
|
||||
});
|
||||
function readVoiceEnabled(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.voiceEnabled === true || parsed?.voiceEnabled === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function useVoiceAvailable(): boolean {
|
||||
const [available, setAvailable] = useState<boolean>(cached ?? false);
|
||||
const [enabled, setEnabled] = useState<boolean>(() =>
|
||||
typeof window === 'undefined' ? false : readVoiceEnabled(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
probe().then((v) => {
|
||||
if (mounted) setAvailable(v);
|
||||
});
|
||||
const update = () => setEnabled(readVoiceEnabled());
|
||||
window.addEventListener('storage', update);
|
||||
window.addEventListener(SYNC_EVENT, update as EventListener);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.removeEventListener('storage', update);
|
||||
window.removeEventListener(SYNC_EVENT, update as EventListener);
|
||||
};
|
||||
}, []);
|
||||
return available;
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { voiceConfigHeaders } from '../../../hooks/useVoiceConfig';
|
||||
|
||||
// Mobile-safe recording: iOS Safari 18.4+ supports webm/opus; older iOS needs mp4.
|
||||
const MIME_CANDIDATES = [
|
||||
@@ -39,6 +40,15 @@ export function useVoiceInput(onTranscript: (text: string) => void, onError?: (m
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
// Stop the mic if the component unmounts mid-recording.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -68,7 +78,11 @@ export function useVoiceInput(onTranscript: (text: string) => void, onError?: (m
|
||||
const ext = type.includes('mp4') ? 'm4a' : type.includes('ogg') ? 'ogg' : 'webm';
|
||||
const fd = new FormData();
|
||||
fd.append('audio', blob, `recording.${ext}`);
|
||||
const res = await authenticatedFetch('/api/voice/transcribe', { method: 'POST', body: fd });
|
||||
const res = await authenticatedFetch('/api/voice/transcribe', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: voiceConfigHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`transcribe ${res.status}`);
|
||||
const data = await res.json();
|
||||
const text = String(data?.text || '').trim();
|
||||
|
||||
Reference in New Issue
Block a user