텍스트 응답 음성 재생 연결

This commit is contained in:
Yun Chan 2026-07-13 16:09:34 +09:00
parent 64e06a1185
commit d80e33da5e
9 changed files with 524 additions and 16 deletions

View file

@ -886,6 +886,8 @@ export default function Session() {
const audioContextRef = useRef<AudioContext | null>(null);
const ttsChunksRef = useRef<BlobPart[]>([]);
const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null);
const ttsPlaybackRequestRef = useRef(0);
const playTtsAudioRef = useRef<(() => Promise<void>) | null>(null);
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
const pendingVoiceLearnerTextRef = useRef<string>("");
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
@ -930,6 +932,7 @@ export default function Session() {
}, [ensureVoiceAudioContext]);
const stopTtsPlayback = useCallback(() => {
ttsPlaybackRequestRef.current += 1;
const cleanup = ttsPlaybackCleanupRef.current;
ttsPlaybackCleanupRef.current = null;
if (cleanup) cleanup();
@ -1405,6 +1408,30 @@ export default function Session() {
}
}, [acceptConsent, consentChecked, pushSignal]);
const speakTextClientTurn = useCallback(
async (sessionId: string, turnSeq: number) => {
const requestId = ttsPlaybackRequestRef.current + 1;
ttsPlaybackRequestRef.current = requestId;
setVoiceStatus("speaking");
setVoiceDetail("OpenAI 음성을 생성하고 있습니다.");
try {
const audio = await sessionApi.speakClientTurn(sessionId, turnSeq);
if (ttsPlaybackRequestRef.current !== requestId) return;
ttsChunksRef.current = [audio];
const play = playTtsAudioRef.current;
if (!play) throw new Error("voice playback is not ready");
await play();
} catch {
if (ttsPlaybackRequestRef.current !== requestId) return;
setAvatarState("listening");
setVoiceStatus("degraded");
setVoiceDetail("OpenAI 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다.");
pushSignal("warn", "AI 음성 재생 실패");
}
},
[pushSignal],
);
const appendServerClientReply = useCallback(
(replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => {
setClientReplyPending(false);
@ -1450,6 +1477,8 @@ export default function Session() {
setClientReplyPending(true);
setAvatarState("thinking");
setTurnError(null);
stopTtsPlayback();
void primeVoicePlayback();
let clientId: number | null = null;
let clientReply = "";
@ -1539,6 +1568,9 @@ export default function Session() {
}
setAvatarState("listening");
if (!conversationStopped && !qualityRetryable) {
if (clientReply && typeof done.turn_seq === "number") {
void speakTextClientTurn(liveSessionId, done.turn_seq);
}
void requestLiveCoach({
learnerText: text,
clientReply,
@ -1580,7 +1612,10 @@ export default function Session() {
liveSessionId,
elapsed,
pushSignal,
primeVoicePlayback,
requestLiveCoach,
speakTextClientTurn,
stopTtsPlayback,
]);
const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
@ -1752,6 +1787,15 @@ export default function Session() {
}
}, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]);
useEffect(() => {
playTtsAudioRef.current = playTtsAudio;
return () => {
if (playTtsAudioRef.current === playTtsAudio) {
playTtsAudioRef.current = null;
}
};
}, [playTtsAudio]);
const startVoiceCapture = useCallback(async () => {
if (!liveSessionId || paused || sending || sessionEnded) return;
if (!navigator.mediaDevices?.getUserMedia) {