mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 13:35:49 +08:00
fix(voice): play read-aloud through an app-level player to stop cutoffs
Read-aloud now runs in a single module-level player outside the React tree instead of per-message component state. Switching chats or re-rendering a message no longer revokes the blob URL mid-play (the 'Invalid URI' cutoff). Adds content-keyed caching so re-listening doesn't regenerate, and reuses one audio element (also unlocks iOS once).
This commit is contained in:
@@ -1,119 +1,33 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { voiceConfigHeaders } from '../../../hooks/useVoiceConfig';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { voicePlayer, voiceId, type VoiceSnapshot } from '../../../lib/voicePlayer';
|
||||
|
||||
// Only one message speaks at a time across the whole app.
|
||||
let stopActive: (() => void) | null = null;
|
||||
|
||||
export type TtsState = 'idle' | 'loading' | 'playing';
|
||||
export type TtsState = VoiceSnapshot['state'];
|
||||
|
||||
/**
|
||||
* Tap-to-speak for a single message. Sends raw markdown to /api/voice/tts and plays
|
||||
* the returned audio. Manual-gesture only (v1) to satisfy iOS autoplay. Exposes the
|
||||
* last error (e.g. a backend timeout) so the control can surface it.
|
||||
* Thin adapter over the app-level voicePlayer. Playback lives outside React (see
|
||||
* lib/voicePlayer), so switching chats or re-rendering a message no longer cuts the
|
||||
* audio off. This hook just reflects the player's state for one message and forwards taps.
|
||||
*/
|
||||
export function useTts(getText: () => string) {
|
||||
const [state, setState] = useState<TtsState>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const content = getText();
|
||||
const id = voiceId(content);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
audioRef.current = null;
|
||||
}
|
||||
if (urlRef.current) {
|
||||
URL.revokeObjectURL(urlRef.current);
|
||||
urlRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const [snap, setSnap] = useState<VoiceSnapshot>(() => voicePlayer.getSnapshot(id));
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reset();
|
||||
setState('idle');
|
||||
if (stopActive) stopActive = null;
|
||||
}, [reset]);
|
||||
|
||||
const showError = useCallback((msg: string) => {
|
||||
setError(msg);
|
||||
if (errorTimer.current) clearTimeout(errorTimer.current);
|
||||
errorTimer.current = setTimeout(() => setError(null), 6000);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount: drop the global stop handler if it points at us, then reset.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (stopActive === stop) stopActive = null;
|
||||
if (errorTimer.current) clearTimeout(errorTimer.current);
|
||||
reset();
|
||||
},
|
||||
[reset, stop],
|
||||
);
|
||||
|
||||
const play = useCallback(async () => {
|
||||
if (stopActive) stopActive();
|
||||
const text = getText();
|
||||
if (!text || !text.trim()) return;
|
||||
setError(null);
|
||||
|
||||
// Create + "unlock" the audio element synchronously inside the click gesture,
|
||||
// so iOS Safari lets us play it after the async fetch resolves.
|
||||
const audio = new Audio();
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => stop();
|
||||
audio.onerror = () => stop();
|
||||
try {
|
||||
audio.play().catch(() => {});
|
||||
audio.pause();
|
||||
} catch {
|
||||
/* unlock attempt; ignore */
|
||||
}
|
||||
stopActive = stop;
|
||||
setState('loading');
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/voice/tts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
headers: voiceConfigHeaders(),
|
||||
useEffect(() => {
|
||||
const update = () =>
|
||||
setSnap((prev) => {
|
||||
const next = voicePlayer.getSnapshot(id);
|
||||
return prev.state === next.state && prev.error === next.error ? prev : next;
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = `Read-aloud failed (${res.status})`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j?.error) msg = String(j.error);
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
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;
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
await audio.play();
|
||||
setState('playing');
|
||||
} catch (e) {
|
||||
reset();
|
||||
setState('idle');
|
||||
showError(e instanceof Error ? e.message : 'Read-aloud failed');
|
||||
}
|
||||
}, [getText, reset, stop, showError]);
|
||||
update();
|
||||
return voicePlayer.subscribe(update);
|
||||
}, [id]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (state === 'playing' || state === 'loading') stop();
|
||||
else play();
|
||||
}, [state, play, stop]);
|
||||
voicePlayer.unlock(); // synchronous, within the click gesture (iOS)
|
||||
voicePlayer.toggle(content);
|
||||
}, [content]);
|
||||
|
||||
return { state, toggle, error };
|
||||
return { state: snap.state, toggle, error: snap.error };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user