Feat: Integrate STT and TTS, Audio Buffer
Browse files- .gitattributes +1 -0
- .gitignore +1 -0
- public/sounds/01_Pertanyaan_bagus_mohon_ditunggu_sebentar.wav +3 -0
- public/sounds/02_Oke_menararik_banget_Sebentar_ya_saya_se.wav +3 -0
- public/sounds/03_Sip_aku_sudah_dengar_pertanyaanmu_Tunggu.wav +3 -0
- src/app/components/Main.tsx +1 -0
- src/app/components/chat/ChatWindow.tsx +7 -1
- src/audio/AudioPlayer.ts +9 -4
- src/hooks/useVoiceSession.ts +95 -7
- src/services/voiceApi.ts +1 -1
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
public/sounds/* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -39,6 +39,7 @@ vite.config.ts.timestamp-*
|
|
| 39 |
API_CONTRACT_CHATBOT.md
|
| 40 |
API_CONTRACT_VOICE.md
|
| 41 |
STYLE.md
|
|
|
|
| 42 |
|
| 43 |
# Database logos (served via CDN)
|
| 44 |
public/databases/
|
|
|
|
| 39 |
API_CONTRACT_CHATBOT.md
|
| 40 |
API_CONTRACT_VOICE.md
|
| 41 |
STYLE.md
|
| 42 |
+
HIGHLIGHT_VOICE.md
|
| 43 |
|
| 44 |
# Database logos (served via CDN)
|
| 45 |
public/databases/
|
public/sounds/01_Pertanyaan_bagus_mohon_ditunggu_sebentar.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:30cabfb4f3cd0b60cc70ab5802eb355eaf589b415304b17726777b70ceadec1d
|
| 3 |
+
size 264598
|
public/sounds/02_Oke_menararik_banget_Sebentar_ya_saya_se.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:296acd82a7242aff7c845db5acd3ab3a93dc41ff1e6efd4db72e586d1a7e1925
|
| 3 |
+
size 306838
|
public/sounds/03_Sip_aku_sudah_dengar_pertanyaanmu_Tunggu.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fab98d3c44a0eedc18d4818e7061e11505af4d701897c9d098ff2bb4fe300ef5
|
| 3 |
+
size 291478
|
src/app/components/Main.tsx
CHANGED
|
@@ -371,6 +371,7 @@ export default function Main() {
|
|
| 371 |
onTranscript: handleVoiceTranscript,
|
| 372 |
onReply: () => { /* WebSocket reply ignored — chatbot SSE handles response */ },
|
| 373 |
bypassWsTts: true,
|
|
|
|
| 374 |
});
|
| 375 |
|
| 376 |
// Keep isVoiceActiveRef in sync so handleSend can read it synchronously
|
|
|
|
| 371 |
onTranscript: handleVoiceTranscript,
|
| 372 |
onReply: () => { /* WebSocket reply ignored — chatbot SSE handles response */ },
|
| 373 |
bypassWsTts: true,
|
| 374 |
+
sessionParams: user ? { userId: user.user_id, fullname: user.name } : undefined,
|
| 375 |
});
|
| 376 |
|
| 377 |
// Keep isVoiceActiveRef in sync so handleSend can read it synchronously
|
src/app/components/chat/ChatWindow.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { motion } from "motion/react";
|
|
| 3 |
import { Bot } from "lucide-react";
|
| 4 |
import type { Message } from "./types";
|
| 5 |
import MessageBubble from "./MessageBubble";
|
|
|
|
| 6 |
|
| 7 |
const WELCOME_FEATURES = [
|
| 8 |
{ emoji: "📊", title: "Data Analysis", desc: "Get actionable insights and root cause" },
|
|
@@ -44,7 +45,12 @@ export default function ChatWindow({ messages, isLoading, streamingMsgId, userNa
|
|
| 44 |
{firstName ? `Hi, ${firstName}! 👋` : "Hello! 👋"}
|
| 45 |
</h2>
|
| 46 |
<p className="mt-2 text-neutral-500 text-sm xl:text-base leading-relaxed">
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</p>
|
| 49 |
</div>
|
| 50 |
|
|
|
|
| 3 |
import { Bot } from "lucide-react";
|
| 4 |
import type { Message } from "./types";
|
| 5 |
import MessageBubble from "./MessageBubble";
|
| 6 |
+
import maintivalogo from "@/assets/maintiva-logo.jpg";
|
| 7 |
|
| 8 |
const WELCOME_FEATURES = [
|
| 9 |
{ emoji: "📊", title: "Data Analysis", desc: "Get actionable insights and root cause" },
|
|
|
|
| 45 |
{firstName ? `Hi, ${firstName}! 👋` : "Hello! 👋"}
|
| 46 |
</h2>
|
| 47 |
<p className="mt-2 text-neutral-500 text-sm xl:text-base leading-relaxed">
|
| 48 |
+
Welcome to{" "}
|
| 49 |
+
<span className="inline-flex items-center gap-1 font-bold text-neutral-800 align-middle">
|
| 50 |
+
<img src={maintivalogo} alt="Maintiva" className="h-4 w-4 xl:h-5 xl:w-5 rounded-sm object-contain" />
|
| 51 |
+
Maintiva Agent
|
| 52 |
+
</span>{" "}
|
| 53 |
+
Turn your data into fast, intelligent decisions. Instantly uncover root causes, spot hidden patterns, and resolve issues before they escalate.
|
| 54 |
</p>
|
| 55 |
</div>
|
| 56 |
|
src/audio/AudioPlayer.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
| 1 |
const DEFAULT_SAMPLE_RATE = 16000;
|
| 2 |
-
const BUFFER_THRESHOLD_BYTES = 6400; // 200ms = 2 chunks before starting playback
|
| 3 |
|
| 4 |
export class AudioPlayer {
|
| 5 |
private context: AudioContext | null = null;
|
| 6 |
private nextPlayTime = 0;
|
| 7 |
private started = false;
|
| 8 |
private pendingBytes = 0;
|
|
|
|
| 9 |
|
| 10 |
init(sampleRate = DEFAULT_SAMPLE_RATE): void {
|
| 11 |
-
if (this.context)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
this.context = new AudioContext({ sampleRate });
|
|
|
|
| 13 |
this.nextPlayTime = 0;
|
| 14 |
this.started = false;
|
| 15 |
this.pendingBytes = 0;
|
|
@@ -24,12 +29,12 @@ export class AudioPlayer {
|
|
| 24 |
float32[i] = int16[i] / 32768;
|
| 25 |
}
|
| 26 |
|
| 27 |
-
const audioBuffer = this.context.createBuffer(1, float32.length,
|
| 28 |
audioBuffer.copyToChannel(float32, 0);
|
| 29 |
|
| 30 |
this.pendingBytes += rawPcm.byteLength;
|
| 31 |
|
| 32 |
-
if (!this.started && this.pendingBytes >=
|
| 33 |
this.started = true;
|
| 34 |
this.nextPlayTime = this.context.currentTime;
|
| 35 |
}
|
|
|
|
| 1 |
const DEFAULT_SAMPLE_RATE = 16000;
|
|
|
|
| 2 |
|
| 3 |
export class AudioPlayer {
|
| 4 |
private context: AudioContext | null = null;
|
| 5 |
private nextPlayTime = 0;
|
| 6 |
private started = false;
|
| 7 |
private pendingBytes = 0;
|
| 8 |
+
private bufferThresholdBytes = 6400; // 200ms at 16kHz default
|
| 9 |
|
| 10 |
init(sampleRate = DEFAULT_SAMPLE_RATE): void {
|
| 11 |
+
if (this.context) {
|
| 12 |
+
if (this.context.sampleRate === sampleRate) return;
|
| 13 |
+
this.context.close();
|
| 14 |
+
this.context = null;
|
| 15 |
+
}
|
| 16 |
this.context = new AudioContext({ sampleRate });
|
| 17 |
+
this.bufferThresholdBytes = Math.floor(sampleRate * 0.2) * 2; // 200ms
|
| 18 |
this.nextPlayTime = 0;
|
| 19 |
this.started = false;
|
| 20 |
this.pendingBytes = 0;
|
|
|
|
| 29 |
float32[i] = int16[i] / 32768;
|
| 30 |
}
|
| 31 |
|
| 32 |
+
const audioBuffer = this.context.createBuffer(1, float32.length, this.context.sampleRate);
|
| 33 |
audioBuffer.copyToChannel(float32, 0);
|
| 34 |
|
| 35 |
this.pendingBytes += rawPcm.byteLength;
|
| 36 |
|
| 37 |
+
if (!this.started && this.pendingBytes >= this.bufferThresholdBytes) {
|
| 38 |
this.started = true;
|
| 39 |
this.nextPlayTime = this.context.currentTime;
|
| 40 |
}
|
src/hooks/useVoiceSession.ts
CHANGED
|
@@ -11,12 +11,26 @@ export type VoiceState =
|
|
| 11 |
| "RECONNECTING"
|
| 12 |
| "ERROR";
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
interface UseVoiceSessionOptions {
|
| 15 |
onTranscript: (text: string) => void;
|
| 16 |
onReply: (text: string) => void;
|
| 17 |
onError?: (code: string, message: string) => void;
|
| 18 |
/** When true, binary TTS audio frames from the WebSocket are ignored. */
|
| 19 |
bypassWsTts?: boolean;
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
export interface UseVoiceSessionReturn {
|
|
@@ -31,9 +45,33 @@ const MAX_RECONNECT_ATTEMPTS = 10;
|
|
| 31 |
const HEARTBEAT_INTERVAL_MS = 20_000;
|
| 32 |
const PONG_TIMEOUT_MS = 5_000;
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return (import.meta as unknown as { env: Record<string, string> }).env
|
| 36 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionReturn {
|
|
@@ -49,6 +87,9 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 49 |
const rafRef = useRef<number | null>(null);
|
| 50 |
const reconnectAttemptRef = useRef(0);
|
| 51 |
|
|
|
|
|
|
|
|
|
|
| 52 |
// Keep opts in a ref so callbacks never go stale
|
| 53 |
const optsRef = useRef(opts);
|
| 54 |
useEffect(() => { optsRef.current = opts; });
|
|
@@ -58,6 +99,26 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 58 |
setVoiceState(s);
|
| 59 |
}, []);
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
const clearHeartbeat = useCallback(() => {
|
| 62 |
if (heartbeatTimerRef.current) clearInterval(heartbeatTimerRef.current);
|
| 63 |
if (pongTimeoutRef.current) clearTimeout(pongTimeoutRef.current);
|
|
@@ -86,6 +147,7 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 86 |
const stopSession = useCallback(() => {
|
| 87 |
stopBargeInLoop();
|
| 88 |
clearHeartbeat();
|
|
|
|
| 89 |
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 90 |
wsRef.current.send(JSON.stringify({ action: "stop" }));
|
| 91 |
}
|
|
@@ -94,7 +156,7 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 94 |
playerRef.current?.stopImmediately();
|
| 95 |
reconnectAttemptRef.current = 0;
|
| 96 |
setState("IDLE");
|
| 97 |
-
}, [clearHeartbeat, closeWs, setState, stopBargeInLoop]);
|
| 98 |
|
| 99 |
const startBargeInLoop = useCallback(() => {
|
| 100 |
const check = () => {
|
|
@@ -128,13 +190,14 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 128 |
|
| 129 |
const openWebSocket = useCallback(() => {
|
| 130 |
closeWs();
|
| 131 |
-
|
|
|
|
| 132 |
ws.binaryType = "arraybuffer";
|
| 133 |
wsRef.current = ws;
|
| 134 |
|
| 135 |
ws.onopen = () => {
|
| 136 |
reconnectAttemptRef.current = 0;
|
| 137 |
-
|
| 138 |
startHeartbeat();
|
| 139 |
};
|
| 140 |
|
|
@@ -144,6 +207,7 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 144 |
if (!optsRef.current.bypassWsTts) {
|
| 145 |
if (stateRef.current === "SPEAKING" || stateRef.current === "PROCESSING") {
|
| 146 |
if (stateRef.current === "PROCESSING") {
|
|
|
|
| 147 |
setState("SPEAKING");
|
| 148 |
startBargeInLoop();
|
| 149 |
}
|
|
@@ -157,17 +221,25 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 157 |
const msg = JSON.parse(event.data as string);
|
| 158 |
|
| 159 |
switch (msg.event) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
case "transcript":
|
| 161 |
if (!msg.is_partial) {
|
| 162 |
setState("PROCESSING");
|
| 163 |
stopBargeInLoop();
|
|
|
|
| 164 |
optsRef.current.onTranscript(msg.text as string);
|
| 165 |
}
|
| 166 |
break;
|
| 167 |
|
| 168 |
case "reply":
|
| 169 |
optsRef.current.onReply(msg.text as string);
|
| 170 |
-
playerRef.current?.init();
|
| 171 |
break;
|
| 172 |
|
| 173 |
case "tts_end":
|
|
@@ -177,6 +249,7 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 177 |
break;
|
| 178 |
|
| 179 |
case "interrupted":
|
|
|
|
| 180 |
playerRef.current?.stopImmediately();
|
| 181 |
setState("LISTENING");
|
| 182 |
stopBargeInLoop();
|
|
@@ -231,12 +304,27 @@ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionRe
|
|
| 231 |
}
|
| 232 |
}, delay);
|
| 233 |
};
|
| 234 |
-
}, [clearHeartbeat, closeWs, setState, startBargeInLoop, startHeartbeat, stopBargeInLoop]);
|
| 235 |
|
| 236 |
const start = useCallback(async () => {
|
| 237 |
if (stateRef.current !== "IDLE" && stateRef.current !== "ERROR") return;
|
| 238 |
setState("CONNECTING");
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
try {
|
| 241 |
if (!recorderRef.current) recorderRef.current = new AudioRecorder();
|
| 242 |
if (!playerRef.current) playerRef.current = new AudioPlayer();
|
|
|
|
| 11 |
| "RECONNECTING"
|
| 12 |
| "ERROR";
|
| 13 |
|
| 14 |
+
export interface VoiceSessionParams {
|
| 15 |
+
userId?: string;
|
| 16 |
+
fullname?: string;
|
| 17 |
+
company?: string;
|
| 18 |
+
function?: string;
|
| 19 |
+
site?: string;
|
| 20 |
+
role?: string;
|
| 21 |
+
agent?: string;
|
| 22 |
+
sttProvider?: string;
|
| 23 |
+
ttsProvider?: string;
|
| 24 |
+
wakeWordEnabled?: boolean;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
interface UseVoiceSessionOptions {
|
| 28 |
onTranscript: (text: string) => void;
|
| 29 |
onReply: (text: string) => void;
|
| 30 |
onError?: (code: string, message: string) => void;
|
| 31 |
/** When true, binary TTS audio frames from the WebSocket are ignored. */
|
| 32 |
bypassWsTts?: boolean;
|
| 33 |
+
sessionParams?: VoiceSessionParams;
|
| 34 |
}
|
| 35 |
|
| 36 |
export interface UseVoiceSessionReturn {
|
|
|
|
| 45 |
const HEARTBEAT_INTERVAL_MS = 20_000;
|
| 46 |
const PONG_TIMEOUT_MS = 5_000;
|
| 47 |
|
| 48 |
+
const BUFFER_SOUNDS = [
|
| 49 |
+
"/sounds/01_Pertanyaan_bagus_mohon_ditunggu_sebentar.wav",
|
| 50 |
+
"/sounds/02_Oke_menararik_banget_Sebentar_ya_saya_se.wav",
|
| 51 |
+
"/sounds/03_Sip_aku_sudah_dengar_pertanyaanmu_Tunggu.wav",
|
| 52 |
+
];
|
| 53 |
+
|
| 54 |
+
function getVoiceHttpBaseUrl(): string {
|
| 55 |
return (import.meta as unknown as { env: Record<string, string> }).env
|
| 56 |
+
.VITE_API_BASE_VOICE_URL ?? "http://localhost:7861";
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function buildWsUrl(params: VoiceSessionParams): string {
|
| 60 |
+
const base = (import.meta as unknown as { env: Record<string, string> }).env
|
| 61 |
+
.VITE_API_BASE_VOICE_WS_URL ?? "ws://localhost:7861";
|
| 62 |
+
const p = new URLSearchParams({
|
| 63 |
+
user_id: params.userId ?? "anonymous",
|
| 64 |
+
fullname: params.fullname ?? "",
|
| 65 |
+
company: params.company ?? "",
|
| 66 |
+
function: params.function ?? "",
|
| 67 |
+
site: params.site ?? "HO",
|
| 68 |
+
role: params.role ?? "engineer",
|
| 69 |
+
agent: params.agent ?? "analysis",
|
| 70 |
+
stt_provider: params.sttProvider ?? "gemini",
|
| 71 |
+
tts_provider: params.ttsProvider ?? "gemini",
|
| 72 |
+
wake_word_enabled: String(params.wakeWordEnabled ?? false),
|
| 73 |
+
});
|
| 74 |
+
return `${base}/ws/voice?${p}`;
|
| 75 |
}
|
| 76 |
|
| 77 |
export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionReturn {
|
|
|
|
| 87 |
const rafRef = useRef<number | null>(null);
|
| 88 |
const reconnectAttemptRef = useRef(0);
|
| 89 |
|
| 90 |
+
const bufferAudioRef = useRef<HTMLAudioElement | null>(null);
|
| 91 |
+
const lastBufferIndexRef = useRef<number>(-1);
|
| 92 |
+
|
| 93 |
// Keep opts in a ref so callbacks never go stale
|
| 94 |
const optsRef = useRef(opts);
|
| 95 |
useEffect(() => { optsRef.current = opts; });
|
|
|
|
| 99 |
setVoiceState(s);
|
| 100 |
}, []);
|
| 101 |
|
| 102 |
+
const stopBufferSound = useCallback(() => {
|
| 103 |
+
if (bufferAudioRef.current) {
|
| 104 |
+
bufferAudioRef.current.pause();
|
| 105 |
+
bufferAudioRef.current.currentTime = 0;
|
| 106 |
+
bufferAudioRef.current = null;
|
| 107 |
+
}
|
| 108 |
+
}, []);
|
| 109 |
+
|
| 110 |
+
const playBufferSound = useCallback(() => {
|
| 111 |
+
stopBufferSound();
|
| 112 |
+
let idx: number;
|
| 113 |
+
do {
|
| 114 |
+
idx = Math.floor(Math.random() * BUFFER_SOUNDS.length);
|
| 115 |
+
} while (BUFFER_SOUNDS.length > 1 && idx === lastBufferIndexRef.current);
|
| 116 |
+
lastBufferIndexRef.current = idx;
|
| 117 |
+
const audio = new Audio(BUFFER_SOUNDS[idx]);
|
| 118 |
+
bufferAudioRef.current = audio;
|
| 119 |
+
audio.play().catch(() => {});
|
| 120 |
+
}, [stopBufferSound]);
|
| 121 |
+
|
| 122 |
const clearHeartbeat = useCallback(() => {
|
| 123 |
if (heartbeatTimerRef.current) clearInterval(heartbeatTimerRef.current);
|
| 124 |
if (pongTimeoutRef.current) clearTimeout(pongTimeoutRef.current);
|
|
|
|
| 147 |
const stopSession = useCallback(() => {
|
| 148 |
stopBargeInLoop();
|
| 149 |
clearHeartbeat();
|
| 150 |
+
stopBufferSound();
|
| 151 |
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 152 |
wsRef.current.send(JSON.stringify({ action: "stop" }));
|
| 153 |
}
|
|
|
|
| 156 |
playerRef.current?.stopImmediately();
|
| 157 |
reconnectAttemptRef.current = 0;
|
| 158 |
setState("IDLE");
|
| 159 |
+
}, [clearHeartbeat, closeWs, setState, stopBargeInLoop, stopBufferSound]);
|
| 160 |
|
| 161 |
const startBargeInLoop = useCallback(() => {
|
| 162 |
const check = () => {
|
|
|
|
| 190 |
|
| 191 |
const openWebSocket = useCallback(() => {
|
| 192 |
closeWs();
|
| 193 |
+
|
| 194 |
+
const ws = new WebSocket(buildWsUrl(optsRef.current.sessionParams ?? {}));
|
| 195 |
ws.binaryType = "arraybuffer";
|
| 196 |
wsRef.current = ws;
|
| 197 |
|
| 198 |
ws.onopen = () => {
|
| 199 |
reconnectAttemptRef.current = 0;
|
| 200 |
+
// Stay in CONNECTING until tts_config is received
|
| 201 |
startHeartbeat();
|
| 202 |
};
|
| 203 |
|
|
|
|
| 207 |
if (!optsRef.current.bypassWsTts) {
|
| 208 |
if (stateRef.current === "SPEAKING" || stateRef.current === "PROCESSING") {
|
| 209 |
if (stateRef.current === "PROCESSING") {
|
| 210 |
+
stopBufferSound();
|
| 211 |
setState("SPEAKING");
|
| 212 |
startBargeInLoop();
|
| 213 |
}
|
|
|
|
| 221 |
const msg = JSON.parse(event.data as string);
|
| 222 |
|
| 223 |
switch (msg.event) {
|
| 224 |
+
case "tts_config": {
|
| 225 |
+
const sampleRate = (msg.sample_rate as number) ?? 16000;
|
| 226 |
+
playerRef.current?.stopImmediately();
|
| 227 |
+
playerRef.current?.init(sampleRate);
|
| 228 |
+
setState("LISTENING");
|
| 229 |
+
break;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
case "transcript":
|
| 233 |
if (!msg.is_partial) {
|
| 234 |
setState("PROCESSING");
|
| 235 |
stopBargeInLoop();
|
| 236 |
+
playBufferSound();
|
| 237 |
optsRef.current.onTranscript(msg.text as string);
|
| 238 |
}
|
| 239 |
break;
|
| 240 |
|
| 241 |
case "reply":
|
| 242 |
optsRef.current.onReply(msg.text as string);
|
|
|
|
| 243 |
break;
|
| 244 |
|
| 245 |
case "tts_end":
|
|
|
|
| 249 |
break;
|
| 250 |
|
| 251 |
case "interrupted":
|
| 252 |
+
stopBufferSound();
|
| 253 |
playerRef.current?.stopImmediately();
|
| 254 |
setState("LISTENING");
|
| 255 |
stopBargeInLoop();
|
|
|
|
| 304 |
}
|
| 305 |
}, delay);
|
| 306 |
};
|
| 307 |
+
}, [clearHeartbeat, closeWs, playBufferSound, setState, startBargeInLoop, startHeartbeat, stopBargeInLoop, stopBufferSound]);
|
| 308 |
|
| 309 |
const start = useCallback(async () => {
|
| 310 |
if (stateRef.current !== "IDLE" && stateRef.current !== "ERROR") return;
|
| 311 |
setState("CONNECTING");
|
| 312 |
|
| 313 |
+
// Health check — best-effort: don't block connect if endpoint unreachable
|
| 314 |
+
try {
|
| 315 |
+
const res = await fetch(`${getVoiceHttpBaseUrl()}/health`);
|
| 316 |
+
if (res.ok) {
|
| 317 |
+
const data: { status: string; message?: string } = await res.json();
|
| 318 |
+
if (data.status !== "ok") {
|
| 319 |
+
setState("ERROR");
|
| 320 |
+
optsRef.current.onError?.("HEALTH_CHECK_FAILED", data.message ?? "Service not ready");
|
| 321 |
+
return;
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
} catch {
|
| 325 |
+
// Network error or CORS — proceed with connect attempt
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
try {
|
| 329 |
if (!recorderRef.current) recorderRef.current = new AudioRecorder();
|
| 330 |
if (!playerRef.current) playerRef.current = new AudioPlayer();
|
src/services/voiceApi.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
const VOICE_BASE_URL =
|
| 2 |
(import.meta as unknown as { env: Record<string, string> }).env
|
| 3 |
-
.VITE_API_BASE_VOICE_URL ?? "http://localhost:
|
| 4 |
|
| 5 |
export async function textToSpeech(
|
| 6 |
text: string,
|
|
|
|
| 1 |
const VOICE_BASE_URL =
|
| 2 |
(import.meta as unknown as { env: Record<string, string> }).env
|
| 3 |
+
.VITE_API_BASE_VOICE_URL ?? "http://localhost:7861";
|
| 4 |
|
| 5 |
export async function textToSpeech(
|
| 6 |
text: string,
|