@surfmate.team/digital-human-voice-call 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +64 -2
- package/dist/index.js +167 -35
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,56 @@ type CallReply = {
|
|
|
24
24
|
type BuildPrompt = (persona: CallPersona, history: readonly CallMessage[], userMessage: string) => string;
|
|
25
25
|
/** Pure: the LLM's raw completion → { text, mood? }. */
|
|
26
26
|
type ParseReply = (raw: string) => CallReply;
|
|
27
|
+
/**
|
|
28
|
+
* A streaming TTS sink. The call enqueues reply SENTENCES as they peel off the
|
|
29
|
+
* LLM token stream; the adapter fetches + schedules each for seamless playback,
|
|
30
|
+
* so audio starts on the first sentence (low latency) instead of after the whole
|
|
31
|
+
* reply. digital-human-voice's `createAzureStreamSpeak()` satisfies this
|
|
32
|
+
* structurally — pass it as the `speechQueue` prop to turn on streaming mode.
|
|
33
|
+
*/
|
|
34
|
+
type SpeechQueuePort = {
|
|
35
|
+
/** Unlock/resume the audio output inside a user gesture (call on mount). */
|
|
36
|
+
primeAudio(): void;
|
|
37
|
+
/** Begin a fresh assistant turn (resets the playback timeline). */
|
|
38
|
+
startQueue(): void;
|
|
39
|
+
/** Add one finished sentence; fetched immediately, played in order. */
|
|
40
|
+
enqueueSentence(text: string): void;
|
|
41
|
+
/** No more sentences this turn; `onIdle` fires once playback drains. */
|
|
42
|
+
finishQueue(onIdle: () => void): void;
|
|
43
|
+
/** Interrupt: drop the queue and stop playback now. */
|
|
44
|
+
stopQueue(): void;
|
|
45
|
+
/** Mute the output without stopping (speaker toggle). Optional. */
|
|
46
|
+
setMuted?(muted: boolean): void;
|
|
47
|
+
/** The AnalyserNode the audio flows through, for a reactive waveform. Optional. */
|
|
48
|
+
getAnalyser?(): AnalyserNode | null;
|
|
49
|
+
};
|
|
50
|
+
/** A running mic session: pause while the AI speaks (anti-echo), resume to listen
|
|
51
|
+
* again, stop to release the device. */
|
|
52
|
+
type SttSession = {
|
|
53
|
+
pause(): void;
|
|
54
|
+
resume(): void;
|
|
55
|
+
stop(): void;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Pluggable speech-to-text. Omit → the built-in browser Web Speech API (free but
|
|
59
|
+
* unreliable: stops itself, weak for continuous dialogue, CN-blocked via Google).
|
|
60
|
+
* Inject one (e.g. an Azure STT mic: capture PCM + VAD endpointing → POST a WAV to
|
|
61
|
+
* a recognizer) for robust recognition. `start` opens the mic and calls
|
|
62
|
+
* `onUtterance(text)` after each endpointed segment; the returned session lets the
|
|
63
|
+
* call pause it while the assistant speaks.
|
|
64
|
+
*/
|
|
65
|
+
type SttPort = {
|
|
66
|
+
start(handlers: {
|
|
67
|
+
onUtterance: (text: string) => void;
|
|
68
|
+
onError?: (msg: string) => void;
|
|
69
|
+
}): Promise<SttSession> | SttSession;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Streaming prompt builder — PLAIN TEXT output (no JSON wrapper), so reply tokens
|
|
73
|
+
* can be split into speakable sentences as they stream. Use this (not the JSON
|
|
74
|
+
* defaultBuildPrompt) whenever a `speechQueue` is wired.
|
|
75
|
+
*/
|
|
76
|
+
declare const defaultStreamPrompt: BuildPrompt;
|
|
27
77
|
/** Default prompt builder — mirrors mate's grokAI.buildPrompt (JSON {text, mood}). */
|
|
28
78
|
declare const defaultBuildPrompt: BuildPrompt;
|
|
29
79
|
/** Default reply parser — pulls the first JSON {text, mood}; falls back to raw + neutral. */
|
|
@@ -39,6 +89,18 @@ type Props = {
|
|
|
39
89
|
readonly buildPrompt?: BuildPrompt | undefined;
|
|
40
90
|
/** Override the reply parser (pure). Omit → built-in JSON {text, mood} default. */
|
|
41
91
|
readonly parseReply?: ParseReply | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Inject a streaming TTS sink to turn on STREAMING mode: the reply is streamed
|
|
94
|
+
* from the LLM (ChatPort.stream) and spoken sentence-by-sentence as it arrives
|
|
95
|
+
* (low latency) instead of synthesized whole. When set, the default prompt
|
|
96
|
+
* switches to PLAIN-TEXT (defaultStreamPrompt). Omit → classic synth path.
|
|
97
|
+
*/
|
|
98
|
+
readonly speechQueue?: SpeechQueuePort | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Pluggable speech-to-text. Omit → browser Web Speech API (free, but unreliable
|
|
101
|
+
* / CN-blocked). Inject (e.g. an Azure STT mic) for robust recognition.
|
|
102
|
+
*/
|
|
103
|
+
readonly stt?: SttPort | undefined;
|
|
42
104
|
/** Emitted after each completed turn (user said X → assistant replied Y). The
|
|
43
105
|
* host persists it if it wants — voice-call itself stays in-memory. */
|
|
44
106
|
readonly onTurn?: ((user: CallMessage, ai: CallReply) => void) | undefined;
|
|
@@ -54,6 +116,6 @@ type Props = {
|
|
|
54
116
|
* The reply audio is routed through a Web Audio AnalyserNode so the Waveform
|
|
55
117
|
* pulses with the actual voice. Pure ports/DI — chatPort + synth are injected.
|
|
56
118
|
*/
|
|
57
|
-
declare function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, onTurn, onClose, speechLang }: Props): react_jsx_runtime.JSX.Element;
|
|
119
|
+
declare function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, stt, onTurn, onClose, speechLang }: Props): react_jsx_runtime.JSX.Element;
|
|
58
120
|
|
|
59
|
-
export { type BuildPrompt, type CallMessage, type CallPersona, type CallReply, type ParseReply, VoiceCall, defaultBuildPrompt, defaultParseReply };
|
|
121
|
+
export { type BuildPrompt, type CallMessage, type CallPersona, type CallReply, type ParseReply, type SpeechQueuePort, type SttPort, type SttSession, VoiceCall, defaultBuildPrompt, defaultParseReply, defaultStreamPrompt };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
// src/ui/VoiceCall.tsx
|
|
2
|
-
import { useEffect as
|
|
2
|
+
import { useEffect as useEffect4, useRef as useRef4, useState as useState3 } from "react";
|
|
3
3
|
import { Mic, MicOff, Volume2, VolumeX, PhoneOff, Send, Subtitles, Loader2 } from "lucide-react";
|
|
4
|
+
import { streamOrComplete } from "@surfmate.team/digital-human-llm";
|
|
4
5
|
|
|
5
6
|
// src/chat.ts
|
|
6
7
|
var HISTORY_WINDOW = 5;
|
|
8
|
+
var defaultStreamPrompt = (persona, history, userMessage) => {
|
|
9
|
+
const historyContext = history.slice(-HISTORY_WINDOW).map((m) => `${m.role === "user" ? "User" : persona.name}: ${m.content}`).join("\n");
|
|
10
|
+
return `You are ${persona.name}.
|
|
11
|
+
|
|
12
|
+
Character background: ${persona.description ?? ""}
|
|
13
|
+
|
|
14
|
+
Personality traits: ${persona.personality ?? ""}
|
|
15
|
+
|
|
16
|
+
Conversation history:
|
|
17
|
+
${historyContext}
|
|
18
|
+
|
|
19
|
+
User: ${userMessage}
|
|
20
|
+
|
|
21
|
+
Reply as ${persona.name} in short, natural SPOKEN sentences (1-3 sentences unless more is clearly needed). Output PLAIN TEXT only \u2014 no JSON, no markdown, no surrounding quotes.`;
|
|
22
|
+
};
|
|
7
23
|
var defaultBuildPrompt = (persona, history, userMessage) => {
|
|
8
24
|
const historyContext = history.slice(-HISTORY_WINDOW).map((m) => `${m.role === "user" ? "User" : persona.name}: ${m.content}`).join("\n");
|
|
9
25
|
return `You are ${persona.name}.
|
|
@@ -41,6 +57,22 @@ var defaultParseReply = (raw) => {
|
|
|
41
57
|
return { text: raw, mood: "neutral" };
|
|
42
58
|
};
|
|
43
59
|
|
|
60
|
+
// src/calc/takeSentences.ts
|
|
61
|
+
var BOUNDARY = /([.!?。!?…]+["')\]」』】]*(?=\s))|(\n+)/g;
|
|
62
|
+
function takeSentences(buffer) {
|
|
63
|
+
const sentences = [];
|
|
64
|
+
let last = 0;
|
|
65
|
+
BOUNDARY.lastIndex = 0;
|
|
66
|
+
let m;
|
|
67
|
+
while (m = BOUNDARY.exec(buffer)) {
|
|
68
|
+
const end = m.index + m[0].length;
|
|
69
|
+
const chunk = buffer.slice(last, end).trim();
|
|
70
|
+
if (chunk) sentences.push(chunk);
|
|
71
|
+
last = end;
|
|
72
|
+
}
|
|
73
|
+
return { sentences, rest: buffer.slice(last) };
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
// src/ui/useCallSpeech.ts
|
|
45
77
|
import { useEffect, useRef, useState } from "react";
|
|
46
78
|
function getCtor() {
|
|
@@ -117,16 +149,59 @@ function useCallSpeech(onUtterance, lang = "zh-CN") {
|
|
|
117
149
|
return { supported, listening, draft, start, stop };
|
|
118
150
|
}
|
|
119
151
|
|
|
152
|
+
// src/ui/useSegmentSpeech.ts
|
|
153
|
+
import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
154
|
+
function useSegmentSpeech(stt, onUtterance) {
|
|
155
|
+
const [listening, setListening] = useState2(false);
|
|
156
|
+
const sessionRef = useRef2(null);
|
|
157
|
+
const openingRef = useRef2(false);
|
|
158
|
+
const wantRef = useRef2(false);
|
|
159
|
+
const onUttRef = useRef2(onUtterance);
|
|
160
|
+
onUttRef.current = onUtterance;
|
|
161
|
+
const start = () => {
|
|
162
|
+
if (!stt || wantRef.current) return;
|
|
163
|
+
wantRef.current = true;
|
|
164
|
+
setListening(true);
|
|
165
|
+
if (sessionRef.current) {
|
|
166
|
+
sessionRef.current.resume();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (openingRef.current) return;
|
|
170
|
+
openingRef.current = true;
|
|
171
|
+
Promise.resolve(stt.start({ onUtterance: (t) => onUttRef.current(t), onError: () => {
|
|
172
|
+
} })).then((session) => {
|
|
173
|
+
sessionRef.current = session;
|
|
174
|
+
if (!wantRef.current) session.pause();
|
|
175
|
+
}).catch(() => {
|
|
176
|
+
}).finally(() => {
|
|
177
|
+
openingRef.current = false;
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
const stop = () => {
|
|
181
|
+
wantRef.current = false;
|
|
182
|
+
setListening(false);
|
|
183
|
+
sessionRef.current?.pause();
|
|
184
|
+
};
|
|
185
|
+
useEffect2(
|
|
186
|
+
() => () => {
|
|
187
|
+
sessionRef.current?.stop();
|
|
188
|
+
sessionRef.current = null;
|
|
189
|
+
},
|
|
190
|
+
[]
|
|
191
|
+
);
|
|
192
|
+
return { supported: !!stt, listening, draft: "", start, stop };
|
|
193
|
+
}
|
|
194
|
+
|
|
120
195
|
// src/ui/Waveform.tsx
|
|
121
|
-
import { useEffect as
|
|
196
|
+
import { useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
122
197
|
import { jsx } from "react/jsx-runtime";
|
|
123
198
|
function Waveform({ analyser, phase }) {
|
|
124
|
-
const canvasRef =
|
|
125
|
-
const analyserRef =
|
|
126
|
-
const phaseRef =
|
|
199
|
+
const canvasRef = useRef3(null);
|
|
200
|
+
const analyserRef = useRef3(analyser);
|
|
201
|
+
const phaseRef = useRef3(phase);
|
|
127
202
|
analyserRef.current = analyser;
|
|
128
203
|
phaseRef.current = phase;
|
|
129
|
-
|
|
204
|
+
useEffect3(() => {
|
|
130
205
|
const canvas = canvasRef.current;
|
|
131
206
|
const ctx = canvas?.getContext("2d");
|
|
132
207
|
if (!canvas || !ctx) return;
|
|
@@ -186,24 +261,25 @@ var STATUS = {
|
|
|
186
261
|
var ctlBtn = "flex h-12 w-12 items-center justify-center rounded-full transition";
|
|
187
262
|
var ctlOn = "bg-white/90 text-slate-900 hover:bg-white";
|
|
188
263
|
var ctlOff = "bg-red-600/90 text-white hover:bg-red-600";
|
|
189
|
-
function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, onTurn, onClose, speechLang }) {
|
|
190
|
-
const
|
|
264
|
+
function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, stt, onTurn, onClose, speechLang }) {
|
|
265
|
+
const streaming = !!speechQueue;
|
|
266
|
+
const build = buildPrompt ?? (streaming ? defaultStreamPrompt : defaultBuildPrompt);
|
|
191
267
|
const parse = parseReply ?? defaultParseReply;
|
|
192
|
-
const [callState, setCallState] =
|
|
193
|
-
const [latestAi, setLatestAi] =
|
|
194
|
-
const [latestUser, setLatestUser] =
|
|
195
|
-
const [muted, setMuted] =
|
|
196
|
-
const [speakerOn, setSpeakerOn] =
|
|
197
|
-
const [showCaptions, setShowCaptions] =
|
|
198
|
-
const [typed, setTyped] =
|
|
199
|
-
const [analyser, setAnalyser] =
|
|
200
|
-
const historyRef =
|
|
201
|
-
const audioRef =
|
|
202
|
-
const audioCtxRef =
|
|
203
|
-
const gainRef =
|
|
204
|
-
const stateRef =
|
|
205
|
-
const speakerOnRef =
|
|
206
|
-
const bootedRef =
|
|
268
|
+
const [callState, setCallState] = useState3("connecting");
|
|
269
|
+
const [latestAi, setLatestAi] = useState3(persona.greeting ?? "");
|
|
270
|
+
const [latestUser, setLatestUser] = useState3("");
|
|
271
|
+
const [muted, setMuted] = useState3(false);
|
|
272
|
+
const [speakerOn, setSpeakerOn] = useState3(true);
|
|
273
|
+
const [showCaptions, setShowCaptions] = useState3(true);
|
|
274
|
+
const [typed, setTyped] = useState3("");
|
|
275
|
+
const [analyser, setAnalyser] = useState3(null);
|
|
276
|
+
const historyRef = useRef4([]);
|
|
277
|
+
const audioRef = useRef4(null);
|
|
278
|
+
const audioCtxRef = useRef4(null);
|
|
279
|
+
const gainRef = useRef4(null);
|
|
280
|
+
const stateRef = useRef4("connecting");
|
|
281
|
+
const speakerOnRef = useRef4(true);
|
|
282
|
+
const bootedRef = useRef4(false);
|
|
207
283
|
stateRef.current = callState;
|
|
208
284
|
speakerOnRef.current = speakerOn;
|
|
209
285
|
const playAudio = (bytes) => new Promise((resolve) => {
|
|
@@ -257,6 +333,38 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
257
333
|
await new Promise((r) => setTimeout(r, Math.min(6e3, 1200 + text.length * 60)));
|
|
258
334
|
}
|
|
259
335
|
};
|
|
336
|
+
const streamSpeak = async (prompt, onText) => {
|
|
337
|
+
const q = speechQueue;
|
|
338
|
+
q.startQueue();
|
|
339
|
+
setCallState("speaking");
|
|
340
|
+
let acc = "";
|
|
341
|
+
let buf = "";
|
|
342
|
+
let primed = false;
|
|
343
|
+
const primeWaveform = () => {
|
|
344
|
+
if (primed) return;
|
|
345
|
+
primed = true;
|
|
346
|
+
setTimeout(() => setAnalyser(q.getAnalyser?.() ?? null), 180);
|
|
347
|
+
};
|
|
348
|
+
for await (const delta of streamOrComplete(chatPort, prompt)) {
|
|
349
|
+
acc += delta;
|
|
350
|
+
buf += delta;
|
|
351
|
+
const { sentences, rest } = takeSentences(buf);
|
|
352
|
+
buf = rest;
|
|
353
|
+
for (const s of sentences) {
|
|
354
|
+
q.enqueueSentence(s);
|
|
355
|
+
primeWaveform();
|
|
356
|
+
}
|
|
357
|
+
onText(acc);
|
|
358
|
+
}
|
|
359
|
+
const tail = buf.trim();
|
|
360
|
+
if (tail) {
|
|
361
|
+
q.enqueueSentence(tail);
|
|
362
|
+
primeWaveform();
|
|
363
|
+
}
|
|
364
|
+
await new Promise((res) => q.finishQueue(res));
|
|
365
|
+
setAnalyser(null);
|
|
366
|
+
return acc.trim();
|
|
367
|
+
};
|
|
260
368
|
const handleUtterance = async (text) => {
|
|
261
369
|
const t = text.trim();
|
|
262
370
|
if (!t || !chatPort) return;
|
|
@@ -268,40 +376,61 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
268
376
|
const userMsg = { id: crypto.randomUUID(), role: "user", content: t };
|
|
269
377
|
historyRef.current = [...history, userMsg];
|
|
270
378
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
379
|
+
if (streaming) {
|
|
380
|
+
const replyText = await streamSpeak(build(persona, history, t), setLatestAi);
|
|
381
|
+
historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: "assistant", content: replyText }];
|
|
382
|
+
onTurn?.(userMsg, { text: replyText });
|
|
383
|
+
} else {
|
|
384
|
+
const raw = await chatPort.complete(build(persona, history, t));
|
|
385
|
+
const reply = parse(raw);
|
|
386
|
+
historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: "assistant", content: reply.text }];
|
|
387
|
+
setLatestAi(reply.text);
|
|
388
|
+
onTurn?.(userMsg, reply);
|
|
389
|
+
await speakReply(reply.text, reply.mood);
|
|
390
|
+
}
|
|
277
391
|
} catch (err) {
|
|
278
392
|
setLatestAi(`(\u51FA\u9519:${err instanceof Error ? err.message : String(err)})`);
|
|
279
393
|
} finally {
|
|
280
394
|
setCallState("listening");
|
|
281
395
|
}
|
|
282
396
|
};
|
|
283
|
-
const
|
|
284
|
-
|
|
397
|
+
const browserSpeech = useCallSpeech(handleUtterance, speechLang);
|
|
398
|
+
const azureSpeech = useSegmentSpeech(stt, handleUtterance);
|
|
399
|
+
const speech = stt ? azureSpeech : browserSpeech;
|
|
400
|
+
useEffect4(() => {
|
|
285
401
|
if (bootedRef.current) return;
|
|
286
402
|
bootedRef.current = true;
|
|
287
403
|
void (async () => {
|
|
288
404
|
try {
|
|
289
|
-
|
|
405
|
+
speechQueue?.primeAudio();
|
|
406
|
+
if (streaming && persona.greeting) {
|
|
407
|
+
const q = speechQueue;
|
|
408
|
+
q.startQueue();
|
|
409
|
+
setCallState("speaking");
|
|
410
|
+
const { sentences, rest } = takeSentences(persona.greeting + "\n");
|
|
411
|
+
[...sentences, rest.trim()].filter(Boolean).forEach((s) => q.enqueueSentence(s));
|
|
412
|
+
setTimeout(() => setAnalyser(q.getAnalyser?.() ?? null), 180);
|
|
413
|
+
await new Promise((res) => q.finishQueue(res));
|
|
414
|
+
setAnalyser(null);
|
|
415
|
+
} else if (persona.greeting && synth && voiceId) {
|
|
416
|
+
await speakReply(persona.greeting);
|
|
417
|
+
}
|
|
290
418
|
} finally {
|
|
291
419
|
setCallState("listening");
|
|
292
420
|
}
|
|
293
421
|
})();
|
|
294
422
|
}, []);
|
|
295
|
-
|
|
423
|
+
useEffect4(
|
|
296
424
|
() => () => {
|
|
297
425
|
audioRef.current?.pause();
|
|
426
|
+
speechQueue?.stopQueue();
|
|
298
427
|
speech.stop();
|
|
299
428
|
void audioCtxRef.current?.close();
|
|
300
429
|
},
|
|
301
430
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
302
431
|
[]
|
|
303
432
|
);
|
|
304
|
-
|
|
433
|
+
useEffect4(() => {
|
|
305
434
|
if (callState === "listening" && !muted) speech.start();
|
|
306
435
|
else speech.stop();
|
|
307
436
|
}, [callState, muted]);
|
|
@@ -310,12 +439,14 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
310
439
|
const next = !on;
|
|
311
440
|
if (gainRef.current) gainRef.current.gain.value = next ? 1 : 0;
|
|
312
441
|
if (audioRef.current) audioRef.current.muted = !next;
|
|
442
|
+
speechQueue?.setMuted?.(!next);
|
|
313
443
|
return next;
|
|
314
444
|
});
|
|
315
445
|
};
|
|
316
446
|
const endCall = () => {
|
|
317
447
|
setCallState("ended");
|
|
318
448
|
audioRef.current?.pause();
|
|
449
|
+
speechQueue?.stopQueue();
|
|
319
450
|
speech.stop();
|
|
320
451
|
onClose(historyRef.current);
|
|
321
452
|
};
|
|
@@ -388,6 +519,7 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
388
519
|
export {
|
|
389
520
|
VoiceCall,
|
|
390
521
|
defaultBuildPrompt,
|
|
391
|
-
defaultParseReply
|
|
522
|
+
defaultParseReply,
|
|
523
|
+
defaultStreamPrompt
|
|
392
524
|
};
|
|
393
525
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/ui/VoiceCall.tsx","../src/chat.ts","../src/ui/useCallSpeech.ts","../src/ui/Waveform.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from 'react'\nimport { Mic, MicOff, Volume2, VolumeX, PhoneOff, Send, Subtitles, Loader2 } from 'lucide-react'\nimport type { ChatPort, VoiceSynthesisPort } from '@surfmate.team/digital-human-ports'\nimport {\n defaultBuildPrompt,\n defaultParseReply,\n type CallPersona,\n type CallMessage,\n type CallReply,\n type BuildPrompt,\n type ParseReply,\n} from '../chat'\nimport { useCallSpeech } from './useCallSpeech'\nimport { Waveform } from './Waveform'\n\ntype CallState = 'connecting' | 'listening' | 'thinking' | 'speaking' | 'ended'\n\ntype Props = {\n readonly persona: CallPersona\n readonly chatPort?: ChatPort | undefined\n readonly synth?: VoiceSynthesisPort | undefined\n readonly voiceId?: string | undefined\n readonly voiceModelId?: string | undefined\n /** Override the prompt builder (pure). Omit → built-in mate-style default. */\n readonly buildPrompt?: BuildPrompt | undefined\n /** Override the reply parser (pure). Omit → built-in JSON {text, mood} default. */\n readonly parseReply?: ParseReply | undefined\n /** Emitted after each completed turn (user said X → assistant replied Y). The\n * host persists it if it wants — voice-call itself stays in-memory. */\n readonly onTurn?: ((user: CallMessage, ai: CallReply) => void) | undefined\n /** Called when the call ends; receives the full in-memory transcript so the\n * host can persist it in one shot. (Arg is ignorable for UI-only closers.) */\n readonly onClose: (history: readonly CallMessage[]) => void\n readonly speechLang?: string | undefined\n}\n\nconst STATUS: Record<CallState, string> = {\n connecting: '接通中…',\n listening: '聆听中…',\n thinking: '思考中…',\n speaking: '说话中…',\n ended: '已结束',\n}\n\nconst ctlBtn = 'flex h-12 w-12 items-center justify-center rounded-full transition'\nconst ctlOn = 'bg-white/90 text-slate-900 hover:bg-white'\nconst ctlOff = 'bg-red-600/90 text-white hover:bg-red-600'\n\n/**\n * Voice-only call — the lightweight sibling of digital-human-video-call. Same\n * turn loop (speech in → LLM via ChatPort → TTS via synth → back to listening),\n * but NO talking-head video / clip selection / waiting loop / playback engine.\n * The reply audio is routed through a Web Audio AnalyserNode so the Waveform\n * pulses with the actual voice. Pure ports/DI — chatPort + synth are injected.\n */\nexport function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, onTurn, onClose, speechLang }: Props) {\n // Injected calculations (default to the built-in mate-style strategy).\n const build = buildPrompt ?? defaultBuildPrompt\n const parse = parseReply ?? defaultParseReply\n\n const [callState, setCallState] = useState<CallState>('connecting')\n const [latestAi, setLatestAi] = useState(persona.greeting ?? '')\n const [latestUser, setLatestUser] = useState('')\n const [muted, setMuted] = useState(false)\n const [speakerOn, setSpeakerOn] = useState(true)\n const [showCaptions, setShowCaptions] = useState(true)\n const [typed, setTyped] = useState('')\n const [analyser, setAnalyser] = useState<AnalyserNode | null>(null)\n\n const historyRef = useRef<CallMessage[]>([])\n const audioRef = useRef<HTMLAudioElement | null>(null)\n const audioCtxRef = useRef<AudioContext | null>(null)\n const gainRef = useRef<GainNode | null>(null)\n const stateRef = useRef<CallState>('connecting')\n const speakerOnRef = useRef(true)\n const bootedRef = useRef(false)\n stateRef.current = callState\n speakerOnRef.current = speakerOn\n\n // Play reply audio routed through an AnalyserNode (so the waveform reacts) and\n // a GainNode (so the speaker toggle works even inside the Web Audio graph).\n const playAudio = (bytes: ArrayBuffer): Promise<void> =>\n new Promise((resolve) => {\n audioRef.current?.pause()\n const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }))\n const audio = new Audio(url)\n audioRef.current = audio\n try {\n const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext\n const ctx = (audioCtxRef.current ??= new Ctx())\n if (ctx.state === 'suspended') void ctx.resume()\n const src = ctx.createMediaElementSource(audio)\n const an = ctx.createAnalyser()\n an.fftSize = 128\n const gain = ctx.createGain()\n gain.gain.value = speakerOnRef.current ? 1 : 0\n gainRef.current = gain\n src.connect(an)\n an.connect(gain)\n gain.connect(ctx.destination)\n setAnalyser(an)\n } catch {\n audio.muted = !speakerOnRef.current\n }\n const done = () => {\n URL.revokeObjectURL(url)\n setAnalyser(null)\n gainRef.current = null\n resolve()\n }\n audio.onended = done\n audio.onerror = done\n void audio.play().catch(done)\n })\n\n const speakReply = async (text: string, emotion?: string) => {\n setCallState('speaking')\n if (synth && voiceId) {\n try {\n const bytes = await synth.synthesize({\n text,\n voiceId,\n ...(voiceModelId ? { modelId: voiceModelId } : {}),\n ...(emotion ? { emotion } : {}),\n })\n await playAudio(bytes)\n } catch (err) {\n console.warn('[voice-call] synth failed:', err)\n await new Promise((r) => setTimeout(r, Math.min(6000, 1200 + text.length * 60)))\n }\n } else {\n await new Promise((r) => setTimeout(r, Math.min(6000, 1200 + text.length * 60)))\n }\n }\n\n const handleUtterance = async (text: string) => {\n const t = text.trim()\n if (!t || !chatPort) return\n if (stateRef.current === 'thinking' || stateRef.current === 'speaking') return\n speech.stop()\n setLatestUser(t)\n setCallState('thinking')\n const history = historyRef.current\n const userMsg: CallMessage = { id: crypto.randomUUID(), role: 'user', content: t }\n historyRef.current = [...history, userMsg]\n try {\n const raw = await chatPort.complete(build(persona, history, t))\n const reply = parse(raw)\n historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: 'assistant', content: reply.text }]\n setLatestAi(reply.text)\n onTurn?.(userMsg, reply)\n await speakReply(reply.text, reply.mood)\n } catch (err) {\n setLatestAi(`(出错:${err instanceof Error ? err.message : String(err)})`)\n } finally {\n setCallState('listening')\n }\n }\n\n const speech = useCallSpeech(handleUtterance, speechLang)\n\n useEffect(() => {\n if (bootedRef.current) return\n bootedRef.current = true\n void (async () => {\n try {\n if (persona.greeting && synth && voiceId) await speakReply(persona.greeting)\n } finally {\n setCallState('listening')\n }\n })()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(\n () => () => {\n audioRef.current?.pause()\n speech.stop()\n void audioCtxRef.current?.close()\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n )\n\n useEffect(() => {\n if (callState === 'listening' && !muted) speech.start()\n else speech.stop()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [callState, muted])\n\n const toggleSpeaker = () => {\n setSpeakerOn((on) => {\n const next = !on\n if (gainRef.current) gainRef.current.gain.value = next ? 1 : 0\n if (audioRef.current) audioRef.current.muted = !next\n return next\n })\n }\n\n const endCall = () => {\n setCallState('ended')\n audioRef.current?.pause()\n speech.stop()\n onClose(historyRef.current)\n }\n\n const sendTyped = () => {\n const t = typed.trim()\n if (!t) return\n setTyped('')\n void handleUtterance(t)\n }\n\n const thinking = callState === 'thinking'\n\n return (\n <div className=\"fixed inset-0 z-50 flex flex-col bg-gradient-to-b from-slate-900 via-slate-950 to-black text-white\">\n {/* Top bar */}\n <div className=\"flex items-center justify-between p-4\">\n <div className=\"flex items-center gap-2 rounded-full bg-white/10 px-3 py-1.5 backdrop-blur\">\n <span className={`h-2 w-2 rounded-full ${thinking ? 'animate-pulse bg-amber-400' : callState === 'speaking' ? 'bg-green-400' : 'bg-white/60'}`} />\n <span className=\"text-sm font-medium\">{persona.name}</span>\n <span className=\"text-xs text-white/60\">· 语音通话 · {STATUS[callState]}</span>\n </div>\n <button type=\"button\" onClick={() => setShowCaptions((v) => !v)} title=\"字幕\" className={`rounded-full p-2 backdrop-blur transition ${showCaptions ? 'bg-white/20' : 'bg-white/5 text-white/60'}`}>\n <Subtitles className=\"h-4 w-4\" />\n </button>\n </div>\n\n {/* Center: avatar + waveform */}\n <div className=\"flex flex-1 flex-col items-center justify-center gap-6 px-6\">\n <div className=\"relative\">\n {persona.avatarUrl ? (\n <img src={persona.avatarUrl} alt={persona.name} className={`h-32 w-32 rounded-full object-cover ring-4 transition ${callState === 'speaking' ? 'ring-purple-500/70' : 'ring-white/15'}`} />\n ) : (\n <div className=\"flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-purple-500/40 to-slate-700 text-4xl font-bold\">{persona.name.slice(0, 1)}</div>\n )}\n {callState === 'speaking' ? <span className=\"absolute inset-0 animate-ping rounded-full ring-2 ring-purple-500/40\" /> : null}\n </div>\n <div className=\"w-full max-w-md\">\n <Waveform analyser={analyser} phase={callState} />\n </div>\n {callState === 'connecting' ? (\n <div className=\"flex items-center gap-2 text-white/70\">\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n <span className=\"text-sm\">接通 {persona.name}…</span>\n </div>\n ) : null}\n </div>\n\n {/* Captions */}\n {showCaptions ? (\n <div className=\"space-y-2 px-6 pb-2\">\n {latestUser ? (\n <p className=\"ml-auto max-w-[80%] text-right text-sm text-white/70\">\n <span className=\"rounded-2xl bg-white/10 px-3 py-1.5 backdrop-blur\">{latestUser}</span>\n </p>\n ) : null}\n {latestAi ? (\n <p className=\"max-w-[80%] text-sm\">\n <span className=\"inline-block rounded-2xl bg-black/40 px-3 py-1.5 leading-relaxed backdrop-blur\">\n {thinking ? <Loader2 className=\"inline h-3.5 w-3.5 animate-spin\" /> : `${persona.name}: ${latestAi}`}\n </span>\n </p>\n ) : null}\n </div>\n ) : null}\n\n {/* Controls */}\n <div className=\"flex flex-col gap-3 px-4 pb-5 pt-2\">\n <div className=\"flex items-center justify-center gap-4\">\n <button type=\"button\" onClick={() => setMuted((v) => !v)} title={muted ? '取消静音' : '麦克风静音'} className={`${ctlBtn} ${muted ? ctlOff : ctlOn}`}>\n {muted ? <MicOff className=\"h-5 w-5\" /> : <Mic className=\"h-5 w-5\" />}\n </button>\n <button type=\"button\" onClick={toggleSpeaker} title={speakerOn ? '关闭外放' : '开启外放'} className={`${ctlBtn} ${speakerOn ? ctlOn : ctlOff}`}>\n {speakerOn ? <Volume2 className=\"h-5 w-5\" /> : <VolumeX className=\"h-5 w-5\" />}\n </button>\n <button type=\"button\" onClick={endCall} title=\"挂断\" className=\"flex h-14 w-14 items-center justify-center rounded-full bg-red-600 text-white transition hover:bg-red-700\">\n <PhoneOff className=\"h-6 w-6\" />\n </button>\n </div>\n <div className=\"flex items-center gap-2\">\n <input\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n sendTyped()\n }\n }}\n disabled={!chatPort || thinking || callState === 'speaking'}\n placeholder={chatPort ? '说话,或在此输入…' : '需注入 ChatPort'}\n className=\"h-10 flex-1 rounded-full border border-white/20 bg-white/10 px-4 text-sm text-white placeholder-white/50 outline-none backdrop-blur focus:border-white/40 disabled:opacity-50\"\n />\n <button type=\"button\" onClick={sendTyped} disabled={!chatPort || thinking || callState === 'speaking' || !typed.trim()} className=\"flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-slate-900 transition disabled:opacity-40\">\n <Send className=\"h-4 w-4\" />\n </button>\n </div>\n </div>\n </div>\n )\n}\n","// Voice-call's OWN chat contracts + sensible default calculations. The package\n// depends only on the `ports` contract package — the prompt builder and reply\n// parser are pure strategy you can OVERRIDE via props; these defaults (mate's\n// JSON {text, mood} format) just make it work out of the box.\n//\n// Consumer-defined interfaces (Go-style): a host can pass any structurally\n// matching persona / message — e.g. conversation's CharacterPersona / ChatMessage\n// satisfy these without voice-call ever importing that package.\n\n/** The character context that shapes the prompt + the call header/avatar. */\nexport type CallPersona = {\n readonly name: string\n readonly description?: string | undefined\n readonly personality?: string | undefined\n readonly greeting?: string | undefined\n readonly avatarUrl?: string | undefined\n}\n\n/** One role-tagged turn of history. */\nexport type CallMessage = {\n readonly id: string\n readonly role: 'user' | 'assistant'\n readonly content: string\n}\n\n/** A parsed assistant reply — text + an optional mood (used as the TTS emotion). */\nexport type CallReply = {\n readonly text: string\n readonly mood?: string | undefined\n}\n\n/** Pure: persona + recent history + new message → the single prompt string. */\nexport type BuildPrompt = (persona: CallPersona, history: readonly CallMessage[], userMessage: string) => string\n\n/** Pure: the LLM's raw completion → { text, mood? }. */\nexport type ParseReply = (raw: string) => CallReply\n\n/** How many trailing messages of history the default prompt includes. */\nconst HISTORY_WINDOW = 5\n\n/** Default prompt builder — mirrors mate's grokAI.buildPrompt (JSON {text, mood}). */\nexport const defaultBuildPrompt: BuildPrompt = (persona, history, userMessage) => {\n const historyContext = history\n .slice(-HISTORY_WINDOW)\n .map((m) => `${m.role === 'user' ? 'User' : persona.name}: ${m.content}`)\n .join('\\n')\n\n return `You are ${persona.name}.\n\nCharacter background: ${persona.description ?? ''}\n\nPersonality traits: ${persona.personality ?? ''}\n\nYour greeting message: \"${persona.greeting ?? ''}\"\n\nConversation history:\n${historyContext}\n\nUser: ${userMessage}\n\nRespond as ${persona.name} would, staying in character with the background and personality described above. Keep responses natural, engaging, and consistent. Use 1-3 sentences unless a longer response is clearly needed.\n\nIMPORTANT: Respond in JSON format with your reply text and current mood:\n{\"text\": \"your response here\", \"mood\": \"happy|calm|angry|sad\"}\n\nChoose the mood that best matches the emotional tone of your response. Only use: happy, calm, angry, or sad.`\n}\n\nconst VALID_MOODS = ['happy', 'calm', 'angry', 'sad']\n\n/** Default reply parser — pulls the first JSON {text, mood}; falls back to raw + neutral. */\nexport const defaultParseReply: ParseReply = (raw) => {\n const match = raw.match(/\\{[\\s\\S]*\"text\"[\\s\\S]*\\}/)\n if (match) {\n try {\n const parsed = JSON.parse(match[0]) as { text?: unknown; mood?: unknown }\n const text = typeof parsed.text === 'string' && parsed.text ? parsed.text : raw\n const mood = typeof parsed.mood === 'string' && VALID_MOODS.includes(parsed.mood) ? parsed.mood : 'neutral'\n return { text, mood }\n } catch {\n /* malformed JSON — fall through */\n }\n }\n return { text: raw, mood: 'neutral' }\n}\n","import { useEffect, useRef, useState } from 'react'\n\n// Continuous speech recognition for the call: transcribes live, and after a\n// short silence fires onUtterance(text) — mate's \"2s silence = user finished\"\n// turn detection. Browser SpeechRecognition (Chrome/Edge), no backend.\n\ntype RecognitionLike = {\n lang: string\n continuous: boolean\n interimResults: boolean\n onresult: ((e: { results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null\n onend: (() => void) | null\n onerror: (() => void) | null\n start(): void\n stop(): void\n}\n\nfunction getCtor(): (new () => RecognitionLike) | null {\n if (typeof window === 'undefined') return null\n const w = window as unknown as Record<string, unknown>\n return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as (new () => RecognitionLike) | null\n}\n\nconst SILENCE_MS = 2000\n\nexport function useCallSpeech(onUtterance: (text: string) => void, lang = 'zh-CN') {\n const [listening, setListening] = useState(false)\n const [draft, setDraft] = useState('')\n const recRef = useRef<RecognitionLike | null>(null)\n const silenceRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n const wantRef = useRef(false)\n const supported = getCtor() !== null\n\n const clearSilence = () => {\n if (silenceRef.current) {\n clearTimeout(silenceRef.current)\n silenceRef.current = null\n }\n }\n\n const stop = () => {\n wantRef.current = false\n clearSilence()\n recRef.current?.stop()\n recRef.current = null\n setListening(false)\n setDraft('')\n }\n\n const start = () => {\n const Ctor = getCtor()\n if (!Ctor || wantRef.current) return\n const rec = new Ctor()\n rec.lang = lang\n rec.continuous = true\n rec.interimResults = true\n rec.onresult = (e) => {\n let text = ''\n for (let i = 0; i < e.results.length; i++) {\n const r = e.results[i]\n if (r) text += r[0].transcript\n }\n setDraft(text)\n clearSilence()\n if (text.trim()) {\n silenceRef.current = setTimeout(() => {\n const finalText = text.trim()\n setDraft('')\n onUtterance(finalText)\n }, SILENCE_MS)\n }\n }\n rec.onerror = () => {}\n rec.onend = () => {\n // Auto-restart while we still want to listen (recognition stops itself).\n if (wantRef.current) {\n try {\n rec.start()\n } catch {\n /* already started */\n }\n } else {\n setListening(false)\n }\n }\n try {\n rec.start()\n recRef.current = rec\n wantRef.current = true\n setListening(true)\n } catch {\n /* ignore */\n }\n }\n\n useEffect(() => () => stop(), [])\n\n return { supported, listening, draft, start, stop }\n}\n","import { useEffect, useRef } from 'react'\n\ntype Phase = 'connecting' | 'listening' | 'thinking' | 'speaking' | 'ended'\n\n/**\n * Audio-reactive waveform. When the reply audio is playing it draws the live\n * frequency spectrum from the injected AnalyserNode; otherwise it shows a gentle\n * idle pulse tuned per call phase (listening brighter, thinking calmer). Pure\n * canvas — no per-bar React state.\n */\nexport function Waveform({ analyser, phase }: { readonly analyser: AnalyserNode | null; readonly phase: Phase }) {\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const analyserRef = useRef(analyser)\n const phaseRef = useRef(phase)\n analyserRef.current = analyser\n phaseRef.current = phase\n\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext('2d')\n if (!canvas || !ctx) return\n const BARS = 40\n let raf = 0\n let t = 0\n let freq: Uint8Array<ArrayBuffer> | null = null\n\n const draw = () => {\n raf = requestAnimationFrame(draw)\n t += 0.06\n const a = analyserRef.current\n const p = phaseRef.current\n const W = canvas.width\n const H = canvas.height\n ctx.clearRect(0, 0, W, H)\n\n const live = a && p === 'speaking'\n if (live) {\n if (!freq || freq.length !== a.frequencyBinCount) freq = new Uint8Array(a.frequencyBinCount)\n a.getByteFrequencyData(freq)\n }\n const bw = W / BARS\n const idleBase = p === 'listening' ? 0.22 : p === 'thinking' ? 0.12 : p === 'connecting' ? 0.08 : 0.05\n\n for (let i = 0; i < BARS; i++) {\n let amp: number\n if (live && freq) {\n // sample low→mid frequencies (where speech energy is), mirror outward\n const m = Math.abs(i - BARS / 2) / (BARS / 2) // 0 center → 1 edges\n const idx = Math.floor((1 - m) * (freq.length * 0.6))\n amp = ((freq[idx] ?? 0) / 255) * (1 - m * 0.35)\n } else {\n amp = idleBase * (0.6 + 0.4 * Math.sin(t + i * 0.4))\n }\n const h = Math.max(3, amp * H)\n const x = i * bw + bw * 0.22\n const y = (H - h) / 2\n ctx.fillStyle = `rgba(168, 85, 247, ${0.45 + amp * 0.55})`\n const r = Math.min(bw * 0.28, h / 2)\n ctx.beginPath()\n ctx.roundRect(x, y, bw * 0.56, h, r)\n ctx.fill()\n }\n }\n draw()\n return () => cancelAnimationFrame(raf)\n }, [])\n\n return <canvas ref={canvasRef} width={640} height={160} className=\"h-40 w-full\" />\n}\n"],"mappings":"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","names":["useEffect","useRef","useState","useEffect","useRef","jsx","useState","useRef","useEffect"]}
|
|
1
|
+
{"version":3,"sources":["../src/ui/VoiceCall.tsx","../src/chat.ts","../src/calc/takeSentences.ts","../src/ui/useCallSpeech.ts","../src/ui/useSegmentSpeech.ts","../src/ui/Waveform.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from 'react'\nimport { Mic, MicOff, Volume2, VolumeX, PhoneOff, Send, Subtitles, Loader2 } from 'lucide-react'\nimport type { ChatPort, VoiceSynthesisPort } from '@surfmate.team/digital-human-ports'\nimport { streamOrComplete } from '@surfmate.team/digital-human-llm'\nimport {\n defaultBuildPrompt,\n defaultParseReply,\n defaultStreamPrompt,\n type CallPersona,\n type CallMessage,\n type CallReply,\n type BuildPrompt,\n type ParseReply,\n type SpeechQueuePort,\n type SttPort,\n} from '../chat'\nimport { takeSentences } from '../calc/takeSentences'\nimport { useCallSpeech } from './useCallSpeech'\nimport { useSegmentSpeech } from './useSegmentSpeech'\nimport { Waveform } from './Waveform'\n\ntype CallState = 'connecting' | 'listening' | 'thinking' | 'speaking' | 'ended'\n\ntype Props = {\n readonly persona: CallPersona\n readonly chatPort?: ChatPort | undefined\n readonly synth?: VoiceSynthesisPort | undefined\n readonly voiceId?: string | undefined\n readonly voiceModelId?: string | undefined\n /** Override the prompt builder (pure). Omit → built-in mate-style default. */\n readonly buildPrompt?: BuildPrompt | undefined\n /** Override the reply parser (pure). Omit → built-in JSON {text, mood} default. */\n readonly parseReply?: ParseReply | undefined\n /**\n * Inject a streaming TTS sink to turn on STREAMING mode: the reply is streamed\n * from the LLM (ChatPort.stream) and spoken sentence-by-sentence as it arrives\n * (low latency) instead of synthesized whole. When set, the default prompt\n * switches to PLAIN-TEXT (defaultStreamPrompt). Omit → classic synth path.\n */\n readonly speechQueue?: SpeechQueuePort | undefined\n /**\n * Pluggable speech-to-text. Omit → browser Web Speech API (free, but unreliable\n * / CN-blocked). Inject (e.g. an Azure STT mic) for robust recognition.\n */\n readonly stt?: SttPort | undefined\n /** Emitted after each completed turn (user said X → assistant replied Y). The\n * host persists it if it wants — voice-call itself stays in-memory. */\n readonly onTurn?: ((user: CallMessage, ai: CallReply) => void) | undefined\n /** Called when the call ends; receives the full in-memory transcript so the\n * host can persist it in one shot. (Arg is ignorable for UI-only closers.) */\n readonly onClose: (history: readonly CallMessage[]) => void\n readonly speechLang?: string | undefined\n}\n\nconst STATUS: Record<CallState, string> = {\n connecting: '接通中…',\n listening: '聆听中…',\n thinking: '思考中…',\n speaking: '说话中…',\n ended: '已结束',\n}\n\nconst ctlBtn = 'flex h-12 w-12 items-center justify-center rounded-full transition'\nconst ctlOn = 'bg-white/90 text-slate-900 hover:bg-white'\nconst ctlOff = 'bg-red-600/90 text-white hover:bg-red-600'\n\n/**\n * Voice-only call — the lightweight sibling of digital-human-video-call. Same\n * turn loop (speech in → LLM via ChatPort → TTS via synth → back to listening),\n * but NO talking-head video / clip selection / waiting loop / playback engine.\n * The reply audio is routed through a Web Audio AnalyserNode so the Waveform\n * pulses with the actual voice. Pure ports/DI — chatPort + synth are injected.\n */\nexport function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, stt, onTurn, onClose, speechLang }: Props) {\n const streaming = !!speechQueue\n // Injected calculations. Streaming needs PLAIN-TEXT replies (split into spoken\n // sentences), so its default prompt differs from the classic JSON one.\n const build = buildPrompt ?? (streaming ? defaultStreamPrompt : defaultBuildPrompt)\n const parse = parseReply ?? defaultParseReply\n\n const [callState, setCallState] = useState<CallState>('connecting')\n const [latestAi, setLatestAi] = useState(persona.greeting ?? '')\n const [latestUser, setLatestUser] = useState('')\n const [muted, setMuted] = useState(false)\n const [speakerOn, setSpeakerOn] = useState(true)\n const [showCaptions, setShowCaptions] = useState(true)\n const [typed, setTyped] = useState('')\n const [analyser, setAnalyser] = useState<AnalyserNode | null>(null)\n\n const historyRef = useRef<CallMessage[]>([])\n const audioRef = useRef<HTMLAudioElement | null>(null)\n const audioCtxRef = useRef<AudioContext | null>(null)\n const gainRef = useRef<GainNode | null>(null)\n const stateRef = useRef<CallState>('connecting')\n const speakerOnRef = useRef(true)\n const bootedRef = useRef(false)\n stateRef.current = callState\n speakerOnRef.current = speakerOn\n\n // Play reply audio routed through an AnalyserNode (so the waveform reacts) and\n // a GainNode (so the speaker toggle works even inside the Web Audio graph).\n const playAudio = (bytes: ArrayBuffer): Promise<void> =>\n new Promise((resolve) => {\n audioRef.current?.pause()\n const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }))\n const audio = new Audio(url)\n audioRef.current = audio\n try {\n const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext\n const ctx = (audioCtxRef.current ??= new Ctx())\n if (ctx.state === 'suspended') void ctx.resume()\n const src = ctx.createMediaElementSource(audio)\n const an = ctx.createAnalyser()\n an.fftSize = 128\n const gain = ctx.createGain()\n gain.gain.value = speakerOnRef.current ? 1 : 0\n gainRef.current = gain\n src.connect(an)\n an.connect(gain)\n gain.connect(ctx.destination)\n setAnalyser(an)\n } catch {\n audio.muted = !speakerOnRef.current\n }\n const done = () => {\n URL.revokeObjectURL(url)\n setAnalyser(null)\n gainRef.current = null\n resolve()\n }\n audio.onended = done\n audio.onerror = done\n void audio.play().catch(done)\n })\n\n const speakReply = async (text: string, emotion?: string) => {\n setCallState('speaking')\n if (synth && voiceId) {\n try {\n const bytes = await synth.synthesize({\n text,\n voiceId,\n ...(voiceModelId ? { modelId: voiceModelId } : {}),\n ...(emotion ? { emotion } : {}),\n })\n await playAudio(bytes)\n } catch (err) {\n console.warn('[voice-call] synth failed:', err)\n await new Promise((r) => setTimeout(r, Math.min(6000, 1200 + text.length * 60)))\n }\n } else {\n await new Promise((r) => setTimeout(r, Math.min(6000, 1200 + text.length * 60)))\n }\n }\n\n // STREAMING turn: LLM tokens → split into sentences → enqueue to the TTS queue,\n // which fetches+plays each as it arrives (audio starts on sentence 1). Returns\n // the full reply text. Only used when speechQueue is wired.\n const streamSpeak = async (prompt: string, onText: (full: string) => void): Promise<string> => {\n const q = speechQueue!\n q.startQueue()\n setCallState('speaking')\n let acc = ''\n let buf = ''\n let primed = false\n const primeWaveform = () => {\n if (primed) return\n primed = true\n setTimeout(() => setAnalyser(q.getAnalyser?.() ?? null), 180)\n }\n for await (const delta of streamOrComplete(chatPort!, prompt)) {\n acc += delta\n buf += delta\n const { sentences, rest } = takeSentences(buf)\n buf = rest\n for (const s of sentences) {\n q.enqueueSentence(s)\n primeWaveform()\n }\n onText(acc)\n }\n const tail = buf.trim()\n if (tail) {\n q.enqueueSentence(tail)\n primeWaveform()\n }\n await new Promise<void>((res) => q.finishQueue(res))\n setAnalyser(null)\n return acc.trim()\n }\n\n const handleUtterance = async (text: string) => {\n const t = text.trim()\n if (!t || !chatPort) return\n if (stateRef.current === 'thinking' || stateRef.current === 'speaking') return\n speech.stop()\n setLatestUser(t)\n setCallState('thinking')\n const history = historyRef.current\n const userMsg: CallMessage = { id: crypto.randomUUID(), role: 'user', content: t }\n historyRef.current = [...history, userMsg]\n try {\n if (streaming) {\n const replyText = await streamSpeak(build(persona, history, t), setLatestAi)\n historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: 'assistant', content: replyText }]\n onTurn?.(userMsg, { text: replyText })\n } else {\n const raw = await chatPort.complete(build(persona, history, t))\n const reply = parse(raw)\n historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: 'assistant', content: reply.text }]\n setLatestAi(reply.text)\n onTurn?.(userMsg, reply)\n await speakReply(reply.text, reply.mood)\n }\n } catch (err) {\n setLatestAi(`(出错:${err instanceof Error ? err.message : String(err)})`)\n } finally {\n setCallState('listening')\n }\n }\n\n // Both hooks are always called (no conditional hooks); we drive only the chosen\n // one (the unused stays inert until its start()). Inject `stt` → Azure mic;\n // otherwise the browser Web Speech API.\n const browserSpeech = useCallSpeech(handleUtterance, speechLang)\n const azureSpeech = useSegmentSpeech(stt, handleUtterance)\n const speech = stt ? azureSpeech : browserSpeech\n\n useEffect(() => {\n if (bootedRef.current) return\n bootedRef.current = true\n void (async () => {\n try {\n speechQueue?.primeAudio()\n if (streaming && persona.greeting) {\n const q = speechQueue!\n q.startQueue()\n setCallState('speaking')\n const { sentences, rest } = takeSentences(persona.greeting + '\\n')\n ;[...sentences, rest.trim()].filter(Boolean).forEach((s) => q.enqueueSentence(s))\n setTimeout(() => setAnalyser(q.getAnalyser?.() ?? null), 180)\n await new Promise<void>((res) => q.finishQueue(res))\n setAnalyser(null)\n } else if (persona.greeting && synth && voiceId) {\n await speakReply(persona.greeting)\n }\n } finally {\n setCallState('listening')\n }\n })()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(\n () => () => {\n audioRef.current?.pause()\n speechQueue?.stopQueue()\n speech.stop()\n void audioCtxRef.current?.close()\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n )\n\n useEffect(() => {\n if (callState === 'listening' && !muted) speech.start()\n else speech.stop()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [callState, muted])\n\n const toggleSpeaker = () => {\n setSpeakerOn((on) => {\n const next = !on\n if (gainRef.current) gainRef.current.gain.value = next ? 1 : 0\n if (audioRef.current) audioRef.current.muted = !next\n speechQueue?.setMuted?.(!next)\n return next\n })\n }\n\n const endCall = () => {\n setCallState('ended')\n audioRef.current?.pause()\n speechQueue?.stopQueue()\n speech.stop()\n onClose(historyRef.current)\n }\n\n const sendTyped = () => {\n const t = typed.trim()\n if (!t) return\n setTyped('')\n void handleUtterance(t)\n }\n\n const thinking = callState === 'thinking'\n\n return (\n <div className=\"fixed inset-0 z-50 flex flex-col bg-gradient-to-b from-slate-900 via-slate-950 to-black text-white\">\n {/* Top bar */}\n <div className=\"flex items-center justify-between p-4\">\n <div className=\"flex items-center gap-2 rounded-full bg-white/10 px-3 py-1.5 backdrop-blur\">\n <span className={`h-2 w-2 rounded-full ${thinking ? 'animate-pulse bg-amber-400' : callState === 'speaking' ? 'bg-green-400' : 'bg-white/60'}`} />\n <span className=\"text-sm font-medium\">{persona.name}</span>\n <span className=\"text-xs text-white/60\">· 语音通话 · {STATUS[callState]}</span>\n </div>\n <button type=\"button\" onClick={() => setShowCaptions((v) => !v)} title=\"字幕\" className={`rounded-full p-2 backdrop-blur transition ${showCaptions ? 'bg-white/20' : 'bg-white/5 text-white/60'}`}>\n <Subtitles className=\"h-4 w-4\" />\n </button>\n </div>\n\n {/* Center: avatar + waveform */}\n <div className=\"flex flex-1 flex-col items-center justify-center gap-6 px-6\">\n <div className=\"relative\">\n {persona.avatarUrl ? (\n <img src={persona.avatarUrl} alt={persona.name} className={`h-32 w-32 rounded-full object-cover ring-4 transition ${callState === 'speaking' ? 'ring-purple-500/70' : 'ring-white/15'}`} />\n ) : (\n <div className=\"flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-purple-500/40 to-slate-700 text-4xl font-bold\">{persona.name.slice(0, 1)}</div>\n )}\n {callState === 'speaking' ? <span className=\"absolute inset-0 animate-ping rounded-full ring-2 ring-purple-500/40\" /> : null}\n </div>\n <div className=\"w-full max-w-md\">\n <Waveform analyser={analyser} phase={callState} />\n </div>\n {callState === 'connecting' ? (\n <div className=\"flex items-center gap-2 text-white/70\">\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n <span className=\"text-sm\">接通 {persona.name}…</span>\n </div>\n ) : null}\n </div>\n\n {/* Captions */}\n {showCaptions ? (\n <div className=\"space-y-2 px-6 pb-2\">\n {latestUser ? (\n <p className=\"ml-auto max-w-[80%] text-right text-sm text-white/70\">\n <span className=\"rounded-2xl bg-white/10 px-3 py-1.5 backdrop-blur\">{latestUser}</span>\n </p>\n ) : null}\n {latestAi ? (\n <p className=\"max-w-[80%] text-sm\">\n <span className=\"inline-block rounded-2xl bg-black/40 px-3 py-1.5 leading-relaxed backdrop-blur\">\n {thinking ? <Loader2 className=\"inline h-3.5 w-3.5 animate-spin\" /> : `${persona.name}: ${latestAi}`}\n </span>\n </p>\n ) : null}\n </div>\n ) : null}\n\n {/* Controls */}\n <div className=\"flex flex-col gap-3 px-4 pb-5 pt-2\">\n <div className=\"flex items-center justify-center gap-4\">\n <button type=\"button\" onClick={() => setMuted((v) => !v)} title={muted ? '取消静音' : '麦克风静音'} className={`${ctlBtn} ${muted ? ctlOff : ctlOn}`}>\n {muted ? <MicOff className=\"h-5 w-5\" /> : <Mic className=\"h-5 w-5\" />}\n </button>\n <button type=\"button\" onClick={toggleSpeaker} title={speakerOn ? '关闭外放' : '开启外放'} className={`${ctlBtn} ${speakerOn ? ctlOn : ctlOff}`}>\n {speakerOn ? <Volume2 className=\"h-5 w-5\" /> : <VolumeX className=\"h-5 w-5\" />}\n </button>\n <button type=\"button\" onClick={endCall} title=\"挂断\" className=\"flex h-14 w-14 items-center justify-center rounded-full bg-red-600 text-white transition hover:bg-red-700\">\n <PhoneOff className=\"h-6 w-6\" />\n </button>\n </div>\n <div className=\"flex items-center gap-2\">\n <input\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n sendTyped()\n }\n }}\n disabled={!chatPort || thinking || callState === 'speaking'}\n placeholder={chatPort ? '说话,或在此输入…' : '需注入 ChatPort'}\n className=\"h-10 flex-1 rounded-full border border-white/20 bg-white/10 px-4 text-sm text-white placeholder-white/50 outline-none backdrop-blur focus:border-white/40 disabled:opacity-50\"\n />\n <button type=\"button\" onClick={sendTyped} disabled={!chatPort || thinking || callState === 'speaking' || !typed.trim()} className=\"flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-slate-900 transition disabled:opacity-40\">\n <Send className=\"h-4 w-4\" />\n </button>\n </div>\n </div>\n </div>\n )\n}\n","// Voice-call's OWN chat contracts + sensible default calculations. The package\n// depends only on the `ports` contract package — the prompt builder and reply\n// parser are pure strategy you can OVERRIDE via props; these defaults (mate's\n// JSON {text, mood} format) just make it work out of the box.\n//\n// Consumer-defined interfaces (Go-style): a host can pass any structurally\n// matching persona / message — e.g. conversation's CharacterPersona / ChatMessage\n// satisfy these without voice-call ever importing that package.\n\n/** The character context that shapes the prompt + the call header/avatar. */\nexport type CallPersona = {\n readonly name: string\n readonly description?: string | undefined\n readonly personality?: string | undefined\n readonly greeting?: string | undefined\n readonly avatarUrl?: string | undefined\n}\n\n/** One role-tagged turn of history. */\nexport type CallMessage = {\n readonly id: string\n readonly role: 'user' | 'assistant'\n readonly content: string\n}\n\n/** A parsed assistant reply — text + an optional mood (used as the TTS emotion). */\nexport type CallReply = {\n readonly text: string\n readonly mood?: string | undefined\n}\n\n/** Pure: persona + recent history + new message → the single prompt string. */\nexport type BuildPrompt = (persona: CallPersona, history: readonly CallMessage[], userMessage: string) => string\n\n/** Pure: the LLM's raw completion → { text, mood? }. */\nexport type ParseReply = (raw: string) => CallReply\n\n/**\n * A streaming TTS sink. The call enqueues reply SENTENCES as they peel off the\n * LLM token stream; the adapter fetches + schedules each for seamless playback,\n * so audio starts on the first sentence (low latency) instead of after the whole\n * reply. digital-human-voice's `createAzureStreamSpeak()` satisfies this\n * structurally — pass it as the `speechQueue` prop to turn on streaming mode.\n */\nexport type SpeechQueuePort = {\n /** Unlock/resume the audio output inside a user gesture (call on mount). */\n primeAudio(): void\n /** Begin a fresh assistant turn (resets the playback timeline). */\n startQueue(): void\n /** Add one finished sentence; fetched immediately, played in order. */\n enqueueSentence(text: string): void\n /** No more sentences this turn; `onIdle` fires once playback drains. */\n finishQueue(onIdle: () => void): void\n /** Interrupt: drop the queue and stop playback now. */\n stopQueue(): void\n /** Mute the output without stopping (speaker toggle). Optional. */\n setMuted?(muted: boolean): void\n /** The AnalyserNode the audio flows through, for a reactive waveform. Optional. */\n getAnalyser?(): AnalyserNode | null\n}\n\n/** A running mic session: pause while the AI speaks (anti-echo), resume to listen\n * again, stop to release the device. */\nexport type SttSession = {\n pause(): void\n resume(): void\n stop(): void\n}\n\n/**\n * Pluggable speech-to-text. Omit → the built-in browser Web Speech API (free but\n * unreliable: stops itself, weak for continuous dialogue, CN-blocked via Google).\n * Inject one (e.g. an Azure STT mic: capture PCM + VAD endpointing → POST a WAV to\n * a recognizer) for robust recognition. `start` opens the mic and calls\n * `onUtterance(text)` after each endpointed segment; the returned session lets the\n * call pause it while the assistant speaks.\n */\nexport type SttPort = {\n start(handlers: {\n onUtterance: (text: string) => void\n onError?: (msg: string) => void\n }): Promise<SttSession> | SttSession\n}\n\n/** How many trailing messages of history the default prompt includes. */\nconst HISTORY_WINDOW = 5\n\n/**\n * Streaming prompt builder — PLAIN TEXT output (no JSON wrapper), so reply tokens\n * can be split into speakable sentences as they stream. Use this (not the JSON\n * defaultBuildPrompt) whenever a `speechQueue` is wired.\n */\nexport const defaultStreamPrompt: BuildPrompt = (persona, history, userMessage) => {\n const historyContext = history\n .slice(-HISTORY_WINDOW)\n .map((m) => `${m.role === 'user' ? 'User' : persona.name}: ${m.content}`)\n .join('\\n')\n\n return `You are ${persona.name}.\n\nCharacter background: ${persona.description ?? ''}\n\nPersonality traits: ${persona.personality ?? ''}\n\nConversation history:\n${historyContext}\n\nUser: ${userMessage}\n\nReply as ${persona.name} in short, natural SPOKEN sentences (1-3 sentences unless more is clearly needed). Output PLAIN TEXT only — no JSON, no markdown, no surrounding quotes.`\n}\n\n/** Default prompt builder — mirrors mate's grokAI.buildPrompt (JSON {text, mood}). */\nexport const defaultBuildPrompt: BuildPrompt = (persona, history, userMessage) => {\n const historyContext = history\n .slice(-HISTORY_WINDOW)\n .map((m) => `${m.role === 'user' ? 'User' : persona.name}: ${m.content}`)\n .join('\\n')\n\n return `You are ${persona.name}.\n\nCharacter background: ${persona.description ?? ''}\n\nPersonality traits: ${persona.personality ?? ''}\n\nYour greeting message: \"${persona.greeting ?? ''}\"\n\nConversation history:\n${historyContext}\n\nUser: ${userMessage}\n\nRespond as ${persona.name} would, staying in character with the background and personality described above. Keep responses natural, engaging, and consistent. Use 1-3 sentences unless a longer response is clearly needed.\n\nIMPORTANT: Respond in JSON format with your reply text and current mood:\n{\"text\": \"your response here\", \"mood\": \"happy|calm|angry|sad\"}\n\nChoose the mood that best matches the emotional tone of your response. Only use: happy, calm, angry, or sad.`\n}\n\nconst VALID_MOODS = ['happy', 'calm', 'angry', 'sad']\n\n/** Default reply parser — pulls the first JSON {text, mood}; falls back to raw + neutral. */\nexport const defaultParseReply: ParseReply = (raw) => {\n const match = raw.match(/\\{[\\s\\S]*\"text\"[\\s\\S]*\\}/)\n if (match) {\n try {\n const parsed = JSON.parse(match[0]) as { text?: unknown; mood?: unknown }\n const text = typeof parsed.text === 'string' && parsed.text ? parsed.text : raw\n const mood = typeof parsed.mood === 'string' && VALID_MOODS.includes(parsed.mood) ? parsed.mood : 'neutral'\n return { text, mood }\n } catch {\n /* malformed JSON — fall through */\n }\n }\n return { text: raw, mood: 'neutral' }\n}\n","// Pure: peel complete sentences off the front of a streaming buffer, keeping the\n// incomplete tail in `rest`. Drives sentence-at-a-time TTS as LLM tokens arrive.\n//\n// A boundary is sentence punctuation (. ! ? 。!?…) plus any trailing closing\n// quote/bracket, FOLLOWED BY WHITESPACE — not end-of-buffer. Requiring a space\n// after means \"3.5\" / \"Mr.\" mid-token don't split, and a sentence whose final\n// \".\" is the last char so far waits in `rest` until the next delta proves it's a\n// real boundary (the caller flushes the final `rest` once the stream ends).\n// Newlines always split.\n\nconst BOUNDARY = /([.!?。!?…]+[\"')\\]」』】]*(?=\\s))|(\\n+)/g\n\nexport function takeSentences(buffer: string): { sentences: string[]; rest: string } {\n const sentences: string[] = []\n let last = 0\n BOUNDARY.lastIndex = 0\n let m: RegExpExecArray | null\n while ((m = BOUNDARY.exec(buffer))) {\n const end = m.index + m[0].length\n const chunk = buffer.slice(last, end).trim()\n if (chunk) sentences.push(chunk)\n last = end\n }\n return { sentences, rest: buffer.slice(last) }\n}\n","import { useEffect, useRef, useState } from 'react'\n\n// Continuous speech recognition for the call: transcribes live, and after a\n// short silence fires onUtterance(text) — mate's \"2s silence = user finished\"\n// turn detection. Browser SpeechRecognition (Chrome/Edge), no backend.\n\ntype RecognitionLike = {\n lang: string\n continuous: boolean\n interimResults: boolean\n onresult: ((e: { results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null\n onend: (() => void) | null\n onerror: (() => void) | null\n start(): void\n stop(): void\n}\n\nfunction getCtor(): (new () => RecognitionLike) | null {\n if (typeof window === 'undefined') return null\n const w = window as unknown as Record<string, unknown>\n return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as (new () => RecognitionLike) | null\n}\n\nconst SILENCE_MS = 2000\n\nexport function useCallSpeech(onUtterance: (text: string) => void, lang = 'zh-CN') {\n const [listening, setListening] = useState(false)\n const [draft, setDraft] = useState('')\n const recRef = useRef<RecognitionLike | null>(null)\n const silenceRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n const wantRef = useRef(false)\n const supported = getCtor() !== null\n\n const clearSilence = () => {\n if (silenceRef.current) {\n clearTimeout(silenceRef.current)\n silenceRef.current = null\n }\n }\n\n const stop = () => {\n wantRef.current = false\n clearSilence()\n recRef.current?.stop()\n recRef.current = null\n setListening(false)\n setDraft('')\n }\n\n const start = () => {\n const Ctor = getCtor()\n if (!Ctor || wantRef.current) return\n const rec = new Ctor()\n rec.lang = lang\n rec.continuous = true\n rec.interimResults = true\n rec.onresult = (e) => {\n let text = ''\n for (let i = 0; i < e.results.length; i++) {\n const r = e.results[i]\n if (r) text += r[0].transcript\n }\n setDraft(text)\n clearSilence()\n if (text.trim()) {\n silenceRef.current = setTimeout(() => {\n const finalText = text.trim()\n setDraft('')\n onUtterance(finalText)\n }, SILENCE_MS)\n }\n }\n rec.onerror = () => {}\n rec.onend = () => {\n // Auto-restart while we still want to listen (recognition stops itself).\n if (wantRef.current) {\n try {\n rec.start()\n } catch {\n /* already started */\n }\n } else {\n setListening(false)\n }\n }\n try {\n rec.start()\n recRef.current = rec\n wantRef.current = true\n setListening(true)\n } catch {\n /* ignore */\n }\n }\n\n useEffect(() => () => stop(), [])\n\n return { supported, listening, draft, start, stop }\n}\n","import { useEffect, useRef, useState } from 'react'\nimport type { SttPort, SttSession } from '../chat'\n\n// The Azure-style sibling of useCallSpeech: same {start, stop, listening, draft}\n// surface, but driven by an injected SttPort (mic capture + VAD endpointing →\n// recognizer) instead of the browser's Web Speech API. The mic is opened ONCE and\n// kept alive across turns — start()=resume, stop()=pause (anti-echo while the AI\n// speaks) — so there's no per-turn getUserMedia re-prompt or clipped onset. Full\n// release happens on unmount. A no-op (supported=false) when no port is injected.\nexport function useSegmentSpeech(stt: SttPort | undefined, onUtterance: (text: string) => void) {\n const [listening, setListening] = useState(false)\n const sessionRef = useRef<SttSession | null>(null)\n const openingRef = useRef(false)\n const wantRef = useRef(false)\n const onUttRef = useRef(onUtterance)\n onUttRef.current = onUtterance\n\n const start = () => {\n if (!stt || wantRef.current) return\n wantRef.current = true\n setListening(true)\n if (sessionRef.current) {\n sessionRef.current.resume()\n return\n }\n if (openingRef.current) return\n openingRef.current = true\n Promise.resolve(stt.start({ onUtterance: (t) => onUttRef.current(t), onError: () => {} }))\n .then((session) => {\n sessionRef.current = session\n // If stop() was called before the async open finished, don't leave it live.\n if (!wantRef.current) session.pause()\n })\n .catch(() => {})\n .finally(() => {\n openingRef.current = false\n })\n }\n\n const stop = () => {\n wantRef.current = false\n setListening(false)\n sessionRef.current?.pause()\n }\n\n useEffect(\n () => () => {\n sessionRef.current?.stop()\n sessionRef.current = null\n },\n [],\n )\n\n return { supported: !!stt, listening, draft: '', start, stop }\n}\n","import { useEffect, useRef } from 'react'\n\ntype Phase = 'connecting' | 'listening' | 'thinking' | 'speaking' | 'ended'\n\n/**\n * Audio-reactive waveform. When the reply audio is playing it draws the live\n * frequency spectrum from the injected AnalyserNode; otherwise it shows a gentle\n * idle pulse tuned per call phase (listening brighter, thinking calmer). Pure\n * canvas — no per-bar React state.\n */\nexport function Waveform({ analyser, phase }: { readonly analyser: AnalyserNode | null; readonly phase: Phase }) {\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const analyserRef = useRef(analyser)\n const phaseRef = useRef(phase)\n analyserRef.current = analyser\n phaseRef.current = phase\n\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext('2d')\n if (!canvas || !ctx) return\n const BARS = 40\n let raf = 0\n let t = 0\n let freq: Uint8Array<ArrayBuffer> | null = null\n\n const draw = () => {\n raf = requestAnimationFrame(draw)\n t += 0.06\n const a = analyserRef.current\n const p = phaseRef.current\n const W = canvas.width\n const H = canvas.height\n ctx.clearRect(0, 0, W, H)\n\n const live = a && p === 'speaking'\n if (live) {\n if (!freq || freq.length !== a.frequencyBinCount) freq = new Uint8Array(a.frequencyBinCount)\n a.getByteFrequencyData(freq)\n }\n const bw = W / BARS\n const idleBase = p === 'listening' ? 0.22 : p === 'thinking' ? 0.12 : p === 'connecting' ? 0.08 : 0.05\n\n for (let i = 0; i < BARS; i++) {\n let amp: number\n if (live && freq) {\n // sample low→mid frequencies (where speech energy is), mirror outward\n const m = Math.abs(i - BARS / 2) / (BARS / 2) // 0 center → 1 edges\n const idx = Math.floor((1 - m) * (freq.length * 0.6))\n amp = ((freq[idx] ?? 0) / 255) * (1 - m * 0.35)\n } else {\n amp = idleBase * (0.6 + 0.4 * Math.sin(t + i * 0.4))\n }\n const h = Math.max(3, amp * H)\n const x = i * bw + bw * 0.22\n const y = (H - h) / 2\n ctx.fillStyle = `rgba(168, 85, 247, ${0.45 + amp * 0.55})`\n const r = Math.min(bw * 0.28, h / 2)\n ctx.beginPath()\n ctx.roundRect(x, y, bw * 0.56, h, r)\n ctx.fill()\n }\n }\n draw()\n return () => cancelAnimationFrame(raf)\n }, [])\n\n return <canvas ref={canvasRef} width={640} height={160} className=\"h-40 w-full\" />\n}\n"],"mappings":";AAAA,SAAS,aAAAA,YAAW,UAAAC,SAAQ,YAAAC,iBAAgB;AAC5C,SAAS,KAAK,QAAQ,SAAS,SAAS,UAAU,MAAM,WAAW,eAAe;AAElF,SAAS,wBAAwB;;;ACkFjC,IAAM,iBAAiB;AAOhB,IAAM,sBAAmC,CAAC,SAAS,SAAS,gBAAgB;AACjF,QAAM,iBAAiB,QACpB,MAAM,CAAC,cAAc,EACrB,IAAI,CAAC,MAAM,GAAG,EAAE,SAAS,SAAS,SAAS,QAAQ,IAAI,KAAK,EAAE,OAAO,EAAE,EACvE,KAAK,IAAI;AAEZ,SAAO,WAAW,QAAQ,IAAI;AAAA;AAAA,wBAER,QAAQ,eAAe,EAAE;AAAA;AAAA,sBAE3B,QAAQ,eAAe,EAAE;AAAA;AAAA;AAAA,EAG7C,cAAc;AAAA;AAAA,QAER,WAAW;AAAA;AAAA,WAER,QAAQ,IAAI;AACvB;AAGO,IAAM,qBAAkC,CAAC,SAAS,SAAS,gBAAgB;AAChF,QAAM,iBAAiB,QACpB,MAAM,CAAC,cAAc,EACrB,IAAI,CAAC,MAAM,GAAG,EAAE,SAAS,SAAS,SAAS,QAAQ,IAAI,KAAK,EAAE,OAAO,EAAE,EACvE,KAAK,IAAI;AAEZ,SAAO,WAAW,QAAQ,IAAI;AAAA;AAAA,wBAER,QAAQ,eAAe,EAAE;AAAA;AAAA,sBAE3B,QAAQ,eAAe,EAAE;AAAA;AAAA,0BAErB,QAAQ,YAAY,EAAE;AAAA;AAAA;AAAA,EAG9C,cAAc;AAAA;AAAA,QAER,WAAW;AAAA;AAAA,aAEN,QAAQ,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAMzB;AAEA,IAAM,cAAc,CAAC,SAAS,QAAQ,SAAS,KAAK;AAG7C,IAAM,oBAAgC,CAAC,QAAQ;AACpD,QAAM,QAAQ,IAAI,MAAM,0BAA0B;AAClD,MAAI,OAAO;AACT,QAAI;AACF,YAAM,SAAS,KAAK,MAAM,MAAM,CAAC,CAAC;AAClC,YAAM,OAAO,OAAO,OAAO,SAAS,YAAY,OAAO,OAAO,OAAO,OAAO;AAC5E,YAAM,OAAO,OAAO,OAAO,SAAS,YAAY,YAAY,SAAS,OAAO,IAAI,IAAI,OAAO,OAAO;AAClG,aAAO,EAAE,MAAM,KAAK;AAAA,IACtB,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO,EAAE,MAAM,KAAK,MAAM,UAAU;AACtC;;;AClJA,IAAM,WAAW;AAEV,SAAS,cAAc,QAAuD;AACnF,QAAM,YAAsB,CAAC;AAC7B,MAAI,OAAO;AACX,WAAS,YAAY;AACrB,MAAI;AACJ,SAAQ,IAAI,SAAS,KAAK,MAAM,GAAI;AAClC,UAAM,MAAM,EAAE,QAAQ,EAAE,CAAC,EAAE;AAC3B,UAAM,QAAQ,OAAO,MAAM,MAAM,GAAG,EAAE,KAAK;AAC3C,QAAI,MAAO,WAAU,KAAK,KAAK;AAC/B,WAAO;AAAA,EACT;AACA,SAAO,EAAE,WAAW,MAAM,OAAO,MAAM,IAAI,EAAE;AAC/C;;;ACxBA,SAAS,WAAW,QAAQ,gBAAgB;AAiB5C,SAAS,UAA8C;AACrD,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAM,IAAI;AACV,SAAQ,EAAE,qBAAqB,EAAE,2BAA2B;AAC9D;AAEA,IAAM,aAAa;AAEZ,SAAS,cAAc,aAAqC,OAAO,SAAS;AACjF,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAChD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,EAAE;AACrC,QAAM,SAAS,OAA+B,IAAI;AAClD,QAAM,aAAa,OAA6C,IAAI;AACpE,QAAM,UAAU,OAAO,KAAK;AAC5B,QAAM,YAAY,QAAQ,MAAM;AAEhC,QAAM,eAAe,MAAM;AACzB,QAAI,WAAW,SAAS;AACtB,mBAAa,WAAW,OAAO;AAC/B,iBAAW,UAAU;AAAA,IACvB;AAAA,EACF;AAEA,QAAM,OAAO,MAAM;AACjB,YAAQ,UAAU;AAClB,iBAAa;AACb,WAAO,SAAS,KAAK;AACrB,WAAO,UAAU;AACjB,iBAAa,KAAK;AAClB,aAAS,EAAE;AAAA,EACb;AAEA,QAAM,QAAQ,MAAM;AAClB,UAAM,OAAO,QAAQ;AACrB,QAAI,CAAC,QAAQ,QAAQ,QAAS;AAC9B,UAAM,MAAM,IAAI,KAAK;AACrB,QAAI,OAAO;AACX,QAAI,aAAa;AACjB,QAAI,iBAAiB;AACrB,QAAI,WAAW,CAAC,MAAM;AACpB,UAAI,OAAO;AACX,eAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,QAAQ,KAAK;AACzC,cAAM,IAAI,EAAE,QAAQ,CAAC;AACrB,YAAI,EAAG,SAAQ,EAAE,CAAC,EAAE;AAAA,MACtB;AACA,eAAS,IAAI;AACb,mBAAa;AACb,UAAI,KAAK,KAAK,GAAG;AACf,mBAAW,UAAU,WAAW,MAAM;AACpC,gBAAM,YAAY,KAAK,KAAK;AAC5B,mBAAS,EAAE;AACX,sBAAY,SAAS;AAAA,QACvB,GAAG,UAAU;AAAA,MACf;AAAA,IACF;AACA,QAAI,UAAU,MAAM;AAAA,IAAC;AACrB,QAAI,QAAQ,MAAM;AAEhB,UAAI,QAAQ,SAAS;AACnB,YAAI;AACF,cAAI,MAAM;AAAA,QACZ,QAAQ;AAAA,QAER;AAAA,MACF,OAAO;AACL,qBAAa,KAAK;AAAA,MACpB;AAAA,IACF;AACA,QAAI;AACF,UAAI,MAAM;AACV,aAAO,UAAU;AACjB,cAAQ,UAAU;AAClB,mBAAa,IAAI;AAAA,IACnB,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,YAAU,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC;AAEhC,SAAO,EAAE,WAAW,WAAW,OAAO,OAAO,KAAK;AACpD;;;AClGA,SAAS,aAAAC,YAAW,UAAAC,SAAQ,YAAAC,iBAAgB;AASrC,SAAS,iBAAiB,KAA0B,aAAqC;AAC9F,QAAM,CAAC,WAAW,YAAY,IAAIA,UAAS,KAAK;AAChD,QAAM,aAAaD,QAA0B,IAAI;AACjD,QAAM,aAAaA,QAAO,KAAK;AAC/B,QAAM,UAAUA,QAAO,KAAK;AAC5B,QAAM,WAAWA,QAAO,WAAW;AACnC,WAAS,UAAU;AAEnB,QAAM,QAAQ,MAAM;AAClB,QAAI,CAAC,OAAO,QAAQ,QAAS;AAC7B,YAAQ,UAAU;AAClB,iBAAa,IAAI;AACjB,QAAI,WAAW,SAAS;AACtB,iBAAW,QAAQ,OAAO;AAC1B;AAAA,IACF;AACA,QAAI,WAAW,QAAS;AACxB,eAAW,UAAU;AACrB,YAAQ,QAAQ,IAAI,MAAM,EAAE,aAAa,CAAC,MAAM,SAAS,QAAQ,CAAC,GAAG,SAAS,MAAM;AAAA,IAAC,EAAE,CAAC,CAAC,EACtF,KAAK,CAAC,YAAY;AACjB,iBAAW,UAAU;AAErB,UAAI,CAAC,QAAQ,QAAS,SAAQ,MAAM;AAAA,IACtC,CAAC,EACA,MAAM,MAAM;AAAA,IAAC,CAAC,EACd,QAAQ,MAAM;AACb,iBAAW,UAAU;AAAA,IACvB,CAAC;AAAA,EACL;AAEA,QAAM,OAAO,MAAM;AACjB,YAAQ,UAAU;AAClB,iBAAa,KAAK;AAClB,eAAW,SAAS,MAAM;AAAA,EAC5B;AAEA,EAAAD;AAAA,IACE,MAAM,MAAM;AACV,iBAAW,SAAS,KAAK;AACzB,iBAAW,UAAU;AAAA,IACvB;AAAA,IACA,CAAC;AAAA,EACH;AAEA,SAAO,EAAE,WAAW,CAAC,CAAC,KAAK,WAAW,OAAO,IAAI,OAAO,KAAK;AAC/D;;;ACtDA,SAAS,aAAAG,YAAW,UAAAC,eAAc;AAmEzB;AAzDF,SAAS,SAAS,EAAE,UAAU,MAAM,GAAsE;AAC/G,QAAM,YAAYA,QAA0B,IAAI;AAChD,QAAM,cAAcA,QAAO,QAAQ;AACnC,QAAM,WAAWA,QAAO,KAAK;AAC7B,cAAY,UAAU;AACtB,WAAS,UAAU;AAEnB,EAAAD,WAAU,MAAM;AACd,UAAM,SAAS,UAAU;AACzB,UAAM,MAAM,QAAQ,WAAW,IAAI;AACnC,QAAI,CAAC,UAAU,CAAC,IAAK;AACrB,UAAM,OAAO;AACb,QAAI,MAAM;AACV,QAAI,IAAI;AACR,QAAI,OAAuC;AAE3C,UAAM,OAAO,MAAM;AACjB,YAAM,sBAAsB,IAAI;AAChC,WAAK;AACL,YAAM,IAAI,YAAY;AACtB,YAAM,IAAI,SAAS;AACnB,YAAM,IAAI,OAAO;AACjB,YAAM,IAAI,OAAO;AACjB,UAAI,UAAU,GAAG,GAAG,GAAG,CAAC;AAExB,YAAM,OAAO,KAAK,MAAM;AACxB,UAAI,MAAM;AACR,YAAI,CAAC,QAAQ,KAAK,WAAW,EAAE,kBAAmB,QAAO,IAAI,WAAW,EAAE,iBAAiB;AAC3F,UAAE,qBAAqB,IAAI;AAAA,MAC7B;AACA,YAAM,KAAK,IAAI;AACf,YAAM,WAAW,MAAM,cAAc,OAAO,MAAM,aAAa,OAAO,MAAM,eAAe,OAAO;AAElG,eAAS,IAAI,GAAG,IAAI,MAAM,KAAK;AAC7B,YAAI;AACJ,YAAI,QAAQ,MAAM;AAEhB,gBAAM,IAAI,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,OAAO;AAC3C,gBAAM,MAAM,KAAK,OAAO,IAAI,MAAM,KAAK,SAAS,IAAI;AACpD,iBAAQ,KAAK,GAAG,KAAK,KAAK,OAAQ,IAAI,IAAI;AAAA,QAC5C,OAAO;AACL,gBAAM,YAAY,MAAM,MAAM,KAAK,IAAI,IAAI,IAAI,GAAG;AAAA,QACpD;AACA,cAAM,IAAI,KAAK,IAAI,GAAG,MAAM,CAAC;AAC7B,cAAM,IAAI,IAAI,KAAK,KAAK;AACxB,cAAM,KAAK,IAAI,KAAK;AACpB,YAAI,YAAY,sBAAsB,OAAO,MAAM,IAAI;AACvD,cAAM,IAAI,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;AACnC,YAAI,UAAU;AACd,YAAI,UAAU,GAAG,GAAG,KAAK,MAAM,GAAG,CAAC;AACnC,YAAI,KAAK;AAAA,MACX;AAAA,IACF;AACA,SAAK;AACL,WAAO,MAAM,qBAAqB,GAAG;AAAA,EACvC,GAAG,CAAC,CAAC;AAEL,SAAO,oBAAC,YAAO,KAAK,WAAW,OAAO,KAAK,QAAQ,KAAK,WAAU,eAAc;AAClF;;;AL0OU,gBAAAE,MAEA,YAFA;AAxPV,IAAM,SAAoC;AAAA,EACxC,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,UAAU;AAAA,EACV,UAAU;AAAA,EACV,OAAO;AACT;AAEA,IAAM,SAAS;AACf,IAAM,QAAQ;AACd,IAAM,SAAS;AASR,SAAS,UAAU,EAAE,SAAS,UAAU,OAAO,SAAS,cAAc,aAAa,YAAY,aAAa,KAAK,QAAQ,SAAS,WAAW,GAAU;AAC5J,QAAM,YAAY,CAAC,CAAC;AAGpB,QAAM,QAAQ,gBAAgB,YAAY,sBAAsB;AAChE,QAAM,QAAQ,cAAc;AAE5B,QAAM,CAAC,WAAW,YAAY,IAAIC,UAAoB,YAAY;AAClE,QAAM,CAAC,UAAU,WAAW,IAAIA,UAAS,QAAQ,YAAY,EAAE;AAC/D,QAAM,CAAC,YAAY,aAAa,IAAIA,UAAS,EAAE;AAC/C,QAAM,CAAC,OAAO,QAAQ,IAAIA,UAAS,KAAK;AACxC,QAAM,CAAC,WAAW,YAAY,IAAIA,UAAS,IAAI;AAC/C,QAAM,CAAC,cAAc,eAAe,IAAIA,UAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAIA,UAAS,EAAE;AACrC,QAAM,CAAC,UAAU,WAAW,IAAIA,UAA8B,IAAI;AAElE,QAAM,aAAaC,QAAsB,CAAC,CAAC;AAC3C,QAAM,WAAWA,QAAgC,IAAI;AACrD,QAAM,cAAcA,QAA4B,IAAI;AACpD,QAAM,UAAUA,QAAwB,IAAI;AAC5C,QAAM,WAAWA,QAAkB,YAAY;AAC/C,QAAM,eAAeA,QAAO,IAAI;AAChC,QAAM,YAAYA,QAAO,KAAK;AAC9B,WAAS,UAAU;AACnB,eAAa,UAAU;AAIvB,QAAM,YAAY,CAAC,UACjB,IAAI,QAAQ,CAAC,YAAY;AACvB,aAAS,SAAS,MAAM;AACxB,UAAM,MAAM,IAAI,gBAAgB,IAAI,KAAK,CAAC,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC,CAAC;AACzE,UAAM,QAAQ,IAAI,MAAM,GAAG;AAC3B,aAAS,UAAU;AACnB,QAAI;AACF,YAAM,MAAM,OAAO,gBAAiB,OAAkE;AACtG,YAAM,MAAO,YAAY,YAAY,IAAI,IAAI;AAC7C,UAAI,IAAI,UAAU,YAAa,MAAK,IAAI,OAAO;AAC/C,YAAM,MAAM,IAAI,yBAAyB,KAAK;AAC9C,YAAM,KAAK,IAAI,eAAe;AAC9B,SAAG,UAAU;AACb,YAAM,OAAO,IAAI,WAAW;AAC5B,WAAK,KAAK,QAAQ,aAAa,UAAU,IAAI;AAC7C,cAAQ,UAAU;AAClB,UAAI,QAAQ,EAAE;AACd,SAAG,QAAQ,IAAI;AACf,WAAK,QAAQ,IAAI,WAAW;AAC5B,kBAAY,EAAE;AAAA,IAChB,QAAQ;AACN,YAAM,QAAQ,CAAC,aAAa;AAAA,IAC9B;AACA,UAAM,OAAO,MAAM;AACjB,UAAI,gBAAgB,GAAG;AACvB,kBAAY,IAAI;AAChB,cAAQ,UAAU;AAClB,cAAQ;AAAA,IACV;AACA,UAAM,UAAU;AAChB,UAAM,UAAU;AAChB,SAAK,MAAM,KAAK,EAAE,MAAM,IAAI;AAAA,EAC9B,CAAC;AAEH,QAAM,aAAa,OAAO,MAAc,YAAqB;AAC3D,iBAAa,UAAU;AACvB,QAAI,SAAS,SAAS;AACpB,UAAI;AACF,cAAM,QAAQ,MAAM,MAAM,WAAW;AAAA,UACnC;AAAA,UACA;AAAA,UACA,GAAI,eAAe,EAAE,SAAS,aAAa,IAAI,CAAC;AAAA,UAChD,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;AAAA,QAC/B,CAAC;AACD,cAAM,UAAU,KAAK;AAAA,MACvB,SAAS,KAAK;AACZ,gBAAQ,KAAK,8BAA8B,GAAG;AAC9C,cAAM,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,KAAK,IAAI,KAAM,OAAO,KAAK,SAAS,EAAE,CAAC,CAAC;AAAA,MACjF;AAAA,IACF,OAAO;AACL,YAAM,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,KAAK,IAAI,KAAM,OAAO,KAAK,SAAS,EAAE,CAAC,CAAC;AAAA,IACjF;AAAA,EACF;AAKA,QAAM,cAAc,OAAO,QAAgB,WAAoD;AAC7F,UAAM,IAAI;AACV,MAAE,WAAW;AACb,iBAAa,UAAU;AACvB,QAAI,MAAM;AACV,QAAI,MAAM;AACV,QAAI,SAAS;AACb,UAAM,gBAAgB,MAAM;AAC1B,UAAI,OAAQ;AACZ,eAAS;AACT,iBAAW,MAAM,YAAY,EAAE,cAAc,KAAK,IAAI,GAAG,GAAG;AAAA,IAC9D;AACA,qBAAiB,SAAS,iBAAiB,UAAW,MAAM,GAAG;AAC7D,aAAO;AACP,aAAO;AACP,YAAM,EAAE,WAAW,KAAK,IAAI,cAAc,GAAG;AAC7C,YAAM;AACN,iBAAW,KAAK,WAAW;AACzB,UAAE,gBAAgB,CAAC;AACnB,sBAAc;AAAA,MAChB;AACA,aAAO,GAAG;AAAA,IACZ;AACA,UAAM,OAAO,IAAI,KAAK;AACtB,QAAI,MAAM;AACR,QAAE,gBAAgB,IAAI;AACtB,oBAAc;AAAA,IAChB;AACA,UAAM,IAAI,QAAc,CAAC,QAAQ,EAAE,YAAY,GAAG,CAAC;AACnD,gBAAY,IAAI;AAChB,WAAO,IAAI,KAAK;AAAA,EAClB;AAEA,QAAM,kBAAkB,OAAO,SAAiB;AAC9C,UAAM,IAAI,KAAK,KAAK;AACpB,QAAI,CAAC,KAAK,CAAC,SAAU;AACrB,QAAI,SAAS,YAAY,cAAc,SAAS,YAAY,WAAY;AACxE,WAAO,KAAK;AACZ,kBAAc,CAAC;AACf,iBAAa,UAAU;AACvB,UAAM,UAAU,WAAW;AAC3B,UAAM,UAAuB,EAAE,IAAI,OAAO,WAAW,GAAG,MAAM,QAAQ,SAAS,EAAE;AACjF,eAAW,UAAU,CAAC,GAAG,SAAS,OAAO;AACzC,QAAI;AACF,UAAI,WAAW;AACb,cAAM,YAAY,MAAM,YAAY,MAAM,SAAS,SAAS,CAAC,GAAG,WAAW;AAC3E,mBAAW,UAAU,CAAC,GAAG,WAAW,SAAS,EAAE,IAAI,OAAO,WAAW,GAAG,MAAM,aAAa,SAAS,UAAU,CAAC;AAC/G,iBAAS,SAAS,EAAE,MAAM,UAAU,CAAC;AAAA,MACvC,OAAO;AACL,cAAM,MAAM,MAAM,SAAS,SAAS,MAAM,SAAS,SAAS,CAAC,CAAC;AAC9D,cAAM,QAAQ,MAAM,GAAG;AACvB,mBAAW,UAAU,CAAC,GAAG,WAAW,SAAS,EAAE,IAAI,OAAO,WAAW,GAAG,MAAM,aAAa,SAAS,MAAM,KAAK,CAAC;AAChH,oBAAY,MAAM,IAAI;AACtB,iBAAS,SAAS,KAAK;AACvB,cAAM,WAAW,MAAM,MAAM,MAAM,IAAI;AAAA,MACzC;AAAA,IACF,SAAS,KAAK;AACZ,kBAAY,iBAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC,GAAG;AAAA,IACxE,UAAE;AACA,mBAAa,WAAW;AAAA,IAC1B;AAAA,EACF;AAKA,QAAM,gBAAgB,cAAc,iBAAiB,UAAU;AAC/D,QAAM,cAAc,iBAAiB,KAAK,eAAe;AACzD,QAAM,SAAS,MAAM,cAAc;AAEnC,EAAAC,WAAU,MAAM;AACd,QAAI,UAAU,QAAS;AACvB,cAAU,UAAU;AACpB,UAAM,YAAY;AAChB,UAAI;AACF,qBAAa,WAAW;AACxB,YAAI,aAAa,QAAQ,UAAU;AACjC,gBAAM,IAAI;AACV,YAAE,WAAW;AACb,uBAAa,UAAU;AACvB,gBAAM,EAAE,WAAW,KAAK,IAAI,cAAc,QAAQ,WAAW,IAAI;AAChE,WAAC,GAAG,WAAW,KAAK,KAAK,CAAC,EAAE,OAAO,OAAO,EAAE,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;AAChF,qBAAW,MAAM,YAAY,EAAE,cAAc,KAAK,IAAI,GAAG,GAAG;AAC5D,gBAAM,IAAI,QAAc,CAAC,QAAQ,EAAE,YAAY,GAAG,CAAC;AACnD,sBAAY,IAAI;AAAA,QAClB,WAAW,QAAQ,YAAY,SAAS,SAAS;AAC/C,gBAAM,WAAW,QAAQ,QAAQ;AAAA,QACnC;AAAA,MACF,UAAE;AACA,qBAAa,WAAW;AAAA,MAC1B;AAAA,IACF,GAAG;AAAA,EAEL,GAAG,CAAC,CAAC;AAEL,EAAAA;AAAA,IACE,MAAM,MAAM;AACV,eAAS,SAAS,MAAM;AACxB,mBAAa,UAAU;AACvB,aAAO,KAAK;AACZ,WAAK,YAAY,SAAS,MAAM;AAAA,IAClC;AAAA;AAAA,IAEA,CAAC;AAAA,EACH;AAEA,EAAAA,WAAU,MAAM;AACd,QAAI,cAAc,eAAe,CAAC,MAAO,QAAO,MAAM;AAAA,QACjD,QAAO,KAAK;AAAA,EAEnB,GAAG,CAAC,WAAW,KAAK,CAAC;AAErB,QAAM,gBAAgB,MAAM;AAC1B,iBAAa,CAAC,OAAO;AACnB,YAAM,OAAO,CAAC;AACd,UAAI,QAAQ,QAAS,SAAQ,QAAQ,KAAK,QAAQ,OAAO,IAAI;AAC7D,UAAI,SAAS,QAAS,UAAS,QAAQ,QAAQ,CAAC;AAChD,mBAAa,WAAW,CAAC,IAAI;AAC7B,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAEA,QAAM,UAAU,MAAM;AACpB,iBAAa,OAAO;AACpB,aAAS,SAAS,MAAM;AACxB,iBAAa,UAAU;AACvB,WAAO,KAAK;AACZ,YAAQ,WAAW,OAAO;AAAA,EAC5B;AAEA,QAAM,YAAY,MAAM;AACtB,UAAM,IAAI,MAAM,KAAK;AACrB,QAAI,CAAC,EAAG;AACR,aAAS,EAAE;AACX,SAAK,gBAAgB,CAAC;AAAA,EACxB;AAEA,QAAM,WAAW,cAAc;AAE/B,SACE,qBAAC,SAAI,WAAU,sGAEb;AAAA,yBAAC,SAAI,WAAU,yCACb;AAAA,2BAAC,SAAI,WAAU,8EACb;AAAA,wBAAAH,KAAC,UAAK,WAAW,wBAAwB,WAAW,+BAA+B,cAAc,aAAa,iBAAiB,aAAa,IAAI;AAAA,QAChJ,gBAAAA,KAAC,UAAK,WAAU,uBAAuB,kBAAQ,MAAK;AAAA,QACpD,qBAAC,UAAK,WAAU,yBAAwB;AAAA;AAAA,UAAU,OAAO,SAAS;AAAA,WAAE;AAAA,SACtE;AAAA,MACA,gBAAAA,KAAC,YAAO,MAAK,UAAS,SAAS,MAAM,gBAAgB,CAAC,MAAM,CAAC,CAAC,GAAG,OAAM,gBAAK,WAAW,6CAA6C,eAAe,gBAAgB,0BAA0B,IAC3L,0BAAAA,KAAC,aAAU,WAAU,WAAU,GACjC;AAAA,OACF;AAAA,IAGA,qBAAC,SAAI,WAAU,+DACb;AAAA,2BAAC,SAAI,WAAU,YACZ;AAAA,gBAAQ,YACP,gBAAAA,KAAC,SAAI,KAAK,QAAQ,WAAW,KAAK,QAAQ,MAAM,WAAW,yDAAyD,cAAc,aAAa,uBAAuB,eAAe,IAAI,IAEzL,gBAAAA,KAAC,SAAI,WAAU,gIAAgI,kBAAQ,KAAK,MAAM,GAAG,CAAC,GAAE;AAAA,QAEzK,cAAc,aAAa,gBAAAA,KAAC,UAAK,WAAU,wEAAuE,IAAK;AAAA,SAC1H;AAAA,MACA,gBAAAA,KAAC,SAAI,WAAU,mBACb,0BAAAA,KAAC,YAAS,UAAoB,OAAO,WAAW,GAClD;AAAA,MACC,cAAc,eACb,qBAAC,SAAI,WAAU,yCACb;AAAA,wBAAAA,KAAC,WAAQ,WAAU,wBAAuB;AAAA,QAC1C,qBAAC,UAAK,WAAU,WAAU;AAAA;AAAA,UAAI,QAAQ;AAAA,UAAK;AAAA,WAAC;AAAA,SAC9C,IACE;AAAA,OACN;AAAA,IAGC,eACC,qBAAC,SAAI,WAAU,uBACZ;AAAA,mBACC,gBAAAA,KAAC,OAAE,WAAU,wDACX,0BAAAA,KAAC,UAAK,WAAU,qDAAqD,sBAAW,GAClF,IACE;AAAA,MACH,WACC,gBAAAA,KAAC,OAAE,WAAU,uBACX,0BAAAA,KAAC,UAAK,WAAU,kFACb,qBAAW,gBAAAA,KAAC,WAAQ,WAAU,mCAAkC,IAAK,GAAG,QAAQ,IAAI,KAAK,QAAQ,IACpG,GACF,IACE;AAAA,OACN,IACE;AAAA,IAGJ,qBAAC,SAAI,WAAU,sCACb;AAAA,2BAAC,SAAI,WAAU,0CACb;AAAA,wBAAAA,KAAC,YAAO,MAAK,UAAS,SAAS,MAAM,SAAS,CAAC,MAAM,CAAC,CAAC,GAAG,OAAO,QAAQ,6BAAS,kCAAS,WAAW,GAAG,MAAM,IAAI,QAAQ,SAAS,KAAK,IACtI,kBAAQ,gBAAAA,KAAC,UAAO,WAAU,WAAU,IAAK,gBAAAA,KAAC,OAAI,WAAU,WAAU,GACrE;AAAA,QACA,gBAAAA,KAAC,YAAO,MAAK,UAAS,SAAS,eAAe,OAAO,YAAY,6BAAS,4BAAQ,WAAW,GAAG,MAAM,IAAI,YAAY,QAAQ,MAAM,IACjI,sBAAY,gBAAAA,KAAC,WAAQ,WAAU,WAAU,IAAK,gBAAAA,KAAC,WAAQ,WAAU,WAAU,GAC9E;AAAA,QACA,gBAAAA,KAAC,YAAO,MAAK,UAAS,SAAS,SAAS,OAAM,gBAAK,WAAU,6GAC3D,0BAAAA,KAAC,YAAS,WAAU,WAAU,GAChC;AAAA,SACF;AAAA,MACA,qBAAC,SAAI,WAAU,2BACb;AAAA,wBAAAA;AAAA,UAAC;AAAA;AAAA,YACC,OAAO;AAAA,YACP,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK;AAAA,YACxC,WAAW,CAAC,MAAM;AAChB,kBAAI,EAAE,QAAQ,SAAS;AACrB,kBAAE,eAAe;AACjB,0BAAU;AAAA,cACZ;AAAA,YACF;AAAA,YACA,UAAU,CAAC,YAAY,YAAY,cAAc;AAAA,YACjD,aAAa,WAAW,sDAAc;AAAA,YACtC,WAAU;AAAA;AAAA,QACZ;AAAA,QACA,gBAAAA,KAAC,YAAO,MAAK,UAAS,SAAS,WAAW,UAAU,CAAC,YAAY,YAAY,cAAc,cAAc,CAAC,MAAM,KAAK,GAAG,WAAU,qHAChI,0BAAAA,KAAC,QAAK,WAAU,WAAU,GAC5B;AAAA,SACF;AAAA,OACF;AAAA,KACF;AAEJ;","names":["useEffect","useRef","useState","useEffect","useRef","useState","useEffect","useRef","jsx","useState","useRef","useEffect"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@surfmate.team/digital-human-voice-call",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Voice-only call (no talking-head video): speech in → LLM (ChatPort) → TTS, with an audio-reactive waveform UI. The lightweight sibling of digital-human-video-call — no clip/waiting/playback engine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voice",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"lucide-react": "^0.460.0",
|
|
36
|
-
"@surfmate.team/digital-human-ports": "0.
|
|
36
|
+
"@surfmate.team/digital-human-ports": "0.2.0",
|
|
37
|
+
"@surfmate.team/digital-human-llm": "0.3.1"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@types/react": "^18.3.0",
|