| import { useState, useRef, useEffect, useCallback } from "react"; |
| import { AudioRecorder } from "../audio/AudioRecorder"; |
| import { AudioPlayer } from "../audio/AudioPlayer"; |
| import { createWavBlob, speechToText } from "../services/voiceApi"; |
|
|
| export type VoiceState = |
| | "IDLE" |
| | "CONNECTING" |
| | "LISTENING" |
| | "PROCESSING" |
| | "SPEAKING" |
| | "ERROR"; |
|
|
| export interface VoiceSessionParams { |
| sttProvider?: string; |
| ttsProvider?: string; |
| } |
|
|
| interface UseVoiceSessionOptions { |
| onTranscript: (text: string) => void; |
| onError?: (code: string, message: string) => void; |
| sessionParams?: VoiceSessionParams; |
| } |
|
|
| export interface UseVoiceSessionReturn { |
| voiceState: VoiceState; |
| start: () => Promise<void>; |
| stop: () => void; |
| stopRecording: () => void; |
| setStateExternal: (s: VoiceState) => void; |
| isActive: boolean; |
| } |
|
|
| const BUFFER_SOUNDS = [ |
| "/sounds/01_Baik_Saya_sedang_memproses_pertanyaanmu.wav", |
| "/sounds/02_Oke_mohon_ditunggu_saya_sedang_siapkan_P.wav", |
| "/sounds/03_Sip_saya_terima_Sedang_saya_proses_Pesan.wav", |
| ]; |
|
|
| const RECORDER_SAMPLE_RATE = 16000; |
|
|
| function getVoiceHttpBaseUrl(): string { |
| const w = window as unknown as { __APP_CONFIG__?: { VOICE_API_URL?: string } }; |
| return ( |
| w.__APP_CONFIG__?.VOICE_API_URL || |
| (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_VOICE_URL || |
| "http://localhost:7861" |
| ); |
| } |
|
|
| export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionReturn { |
| const [voiceState, setVoiceState] = useState<VoiceState>("IDLE"); |
| const stateRef = useRef<VoiceState>("IDLE"); |
|
|
| const recorderRef = useRef<AudioRecorder | null>(null); |
| const playerRef = useRef<AudioPlayer | null>(null); |
| const chunksRef = useRef<ArrayBuffer[]>([]); |
|
|
| const bufferAudioRef = useRef<HTMLAudioElement | null>(null); |
| const lastBufferIndexRef = useRef<number>(-1); |
|
|
| const optsRef = useRef(opts); |
| useEffect(() => { optsRef.current = opts; }); |
|
|
| |
| const stopBufferSound = useCallback(() => { |
| if (bufferAudioRef.current) { |
| bufferAudioRef.current.pause(); |
| bufferAudioRef.current.currentTime = 0; |
| bufferAudioRef.current = null; |
| } |
| }, []); |
|
|
| |
| const setState = useCallback((s: VoiceState) => { |
| if (s === "SPEAKING" || s === "IDLE" || s === "ERROR") { |
| stopBufferSound(); |
| } |
| stateRef.current = s; |
| setVoiceState(s); |
| }, [stopBufferSound]); |
|
|
| const playBufferSound = useCallback(() => { |
| stopBufferSound(); |
| let idx: number; |
| do { |
| idx = Math.floor(Math.random() * BUFFER_SOUNDS.length); |
| } while (BUFFER_SOUNDS.length > 1 && idx === lastBufferIndexRef.current); |
| lastBufferIndexRef.current = idx; |
| const audio = new Audio(BUFFER_SOUNDS[idx]); |
| bufferAudioRef.current = audio; |
| audio.play().catch(() => {}); |
| }, [stopBufferSound]); |
|
|
| const stopSession = useCallback(() => { |
| recorderRef.current?.stop(); |
| playerRef.current?.stopImmediately(); |
| chunksRef.current = []; |
| setState("IDLE"); |
| }, [setState]); |
|
|
| const stopRecording = useCallback(() => { |
| if (stateRef.current !== "LISTENING") return; |
| setState("PROCESSING"); |
| recorderRef.current?.stop(); |
| |
| |
| playBufferSound(); |
|
|
| const chunks = chunksRef.current; |
| chunksRef.current = []; |
|
|
| void (async () => { |
| try { |
| if (chunks.length === 0) { |
| setState("IDLE"); |
| return; |
| } |
|
|
| const wav = createWavBlob(chunks, RECORDER_SAMPLE_RATE); |
| const { text } = await speechToText(wav, optsRef.current.sessionParams?.sttProvider ?? "chirp3"); |
|
|
| |
| if (stateRef.current !== "PROCESSING") return; |
|
|
| if (text.trim()) { |
| console.log("[Voice] STT transcript →", text); |
| |
| |
| optsRef.current.onTranscript(text); |
| } else { |
| setState("IDLE"); |
| } |
| } catch (err) { |
| console.error("[STT] Request failed:", err); |
| optsRef.current.onError?.("STT_ERROR", (err as Error).message); |
| setState("ERROR"); |
| } |
| })(); |
| }, [playBufferSound, setState]); |
|
|
| const start = useCallback(async () => { |
| if (stateRef.current !== "IDLE" && stateRef.current !== "ERROR") return; |
| setState("CONNECTING"); |
|
|
| try { |
| const res = await fetch(`${getVoiceHttpBaseUrl()}/health`); |
| if (res.ok) { |
| const data: { status: string; message?: string } = await res.json(); |
| if (data.status !== "ok") { |
| setState("ERROR"); |
| optsRef.current.onError?.("HEALTH_CHECK_FAILED", data.message ?? "Service not ready"); |
| return; |
| } |
| } |
| } catch { |
| |
| } |
|
|
| try { |
| chunksRef.current = []; |
| if (!recorderRef.current) recorderRef.current = new AudioRecorder(); |
| if (!playerRef.current) playerRef.current = new AudioPlayer(); |
|
|
| await recorderRef.current.start((chunk: ArrayBuffer) => { |
| if (stateRef.current === "LISTENING") { |
| chunksRef.current.push(chunk); |
| } |
| }); |
|
|
| setState("LISTENING"); |
| } catch (err) { |
| console.error("[Voice] Failed to start recorder:", err); |
| recorderRef.current?.stop(); |
| optsRef.current.onError?.("MIC_ERROR", (err as Error).message ?? "Failed to access microphone"); |
| setState("ERROR"); |
| } |
| }, [setState]); |
|
|
| useEffect(() => { |
| return () => { |
| stopSession(); |
| }; |
| |
| }, []); |
|
|
| return { |
| voiceState, |
| start, |
| stop: stopSession, |
| stopRecording, |
| setStateExternal: setState, |
| isActive: voiceState !== "IDLE" && voiceState !== "ERROR", |
| }; |
| } |
|
|