ishaq101 commited on
Commit
8172156
·
1 Parent(s): 5ca6cf1

Feat: Integrate STT and TTS, Audio Buffer

Browse files
.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
- Leverage AI to analyze data, learn from patterns, and identify root causes automatically. Maintiva Agent acts as a virtual assistant, helping teams diagnose issues faster and make smarter decisions.
 
 
 
 
 
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) return;
 
 
 
 
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, PLAYBACK_SAMPLE_RATE);
28
  audioBuffer.copyToChannel(float32, 0);
29
 
30
  this.pendingBytes += rawPcm.byteLength;
31
 
32
- if (!this.started && this.pendingBytes >= BUFFER_THRESHOLD_BYTES) {
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
- function getWsUrl(): string {
 
 
 
 
 
 
35
  return (import.meta as unknown as { env: Record<string, string> }).env
36
- .VITE_API_BASE_VOICE_WS_URL ?? "wss://localhost:7861/ws/voice";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const ws = new WebSocket(getWsUrl());
 
132
  ws.binaryType = "arraybuffer";
133
  wsRef.current = ws;
134
 
135
  ws.onopen = () => {
136
  reconnectAttemptRef.current = 0;
137
- setState("LISTENING");
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:7860";
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,