@surfmate.team/digital-human-voice-call 0.1.0 → 0.2.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 +38 -2
- package/dist/index.js +97 -10
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,35 @@ 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
|
+
/**
|
|
51
|
+
* Streaming prompt builder — PLAIN TEXT output (no JSON wrapper), so reply tokens
|
|
52
|
+
* can be split into speakable sentences as they stream. Use this (not the JSON
|
|
53
|
+
* defaultBuildPrompt) whenever a `speechQueue` is wired.
|
|
54
|
+
*/
|
|
55
|
+
declare const defaultStreamPrompt: BuildPrompt;
|
|
27
56
|
/** Default prompt builder — mirrors mate's grokAI.buildPrompt (JSON {text, mood}). */
|
|
28
57
|
declare const defaultBuildPrompt: BuildPrompt;
|
|
29
58
|
/** Default reply parser — pulls the first JSON {text, mood}; falls back to raw + neutral. */
|
|
@@ -39,6 +68,13 @@ type Props = {
|
|
|
39
68
|
readonly buildPrompt?: BuildPrompt | undefined;
|
|
40
69
|
/** Override the reply parser (pure). Omit → built-in JSON {text, mood} default. */
|
|
41
70
|
readonly parseReply?: ParseReply | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Inject a streaming TTS sink to turn on STREAMING mode: the reply is streamed
|
|
73
|
+
* from the LLM (ChatPort.stream) and spoken sentence-by-sentence as it arrives
|
|
74
|
+
* (low latency) instead of synthesized whole. When set, the default prompt
|
|
75
|
+
* switches to PLAIN-TEXT (defaultStreamPrompt). Omit → classic synth path.
|
|
76
|
+
*/
|
|
77
|
+
readonly speechQueue?: SpeechQueuePort | undefined;
|
|
42
78
|
/** Emitted after each completed turn (user said X → assistant replied Y). The
|
|
43
79
|
* host persists it if it wants — voice-call itself stays in-memory. */
|
|
44
80
|
readonly onTurn?: ((user: CallMessage, ai: CallReply) => void) | undefined;
|
|
@@ -54,6 +90,6 @@ type Props = {
|
|
|
54
90
|
* The reply audio is routed through a Web Audio AnalyserNode so the Waveform
|
|
55
91
|
* pulses with the actual voice. Pure ports/DI — chatPort + synth are injected.
|
|
56
92
|
*/
|
|
57
|
-
declare function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, onTurn, onClose, speechLang }: Props): react_jsx_runtime.JSX.Element;
|
|
93
|
+
declare function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, onTurn, onClose, speechLang }: Props): react_jsx_runtime.JSX.Element;
|
|
58
94
|
|
|
59
|
-
export { type BuildPrompt, type CallMessage, type CallPersona, type CallReply, type ParseReply, VoiceCall, defaultBuildPrompt, defaultParseReply };
|
|
95
|
+
export { type BuildPrompt, type CallMessage, type CallPersona, type CallReply, type ParseReply, type SpeechQueuePort, VoiceCall, defaultBuildPrompt, defaultParseReply, defaultStreamPrompt };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
// src/ui/VoiceCall.tsx
|
|
2
2
|
import { useEffect as useEffect3, useRef as useRef3, useState as useState2 } 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() {
|
|
@@ -186,8 +218,9 @@ var STATUS = {
|
|
|
186
218
|
var ctlBtn = "flex h-12 w-12 items-center justify-center rounded-full transition";
|
|
187
219
|
var ctlOn = "bg-white/90 text-slate-900 hover:bg-white";
|
|
188
220
|
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
|
|
221
|
+
function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, onTurn, onClose, speechLang }) {
|
|
222
|
+
const streaming = !!speechQueue;
|
|
223
|
+
const build = buildPrompt ?? (streaming ? defaultStreamPrompt : defaultBuildPrompt);
|
|
191
224
|
const parse = parseReply ?? defaultParseReply;
|
|
192
225
|
const [callState, setCallState] = useState2("connecting");
|
|
193
226
|
const [latestAi, setLatestAi] = useState2(persona.greeting ?? "");
|
|
@@ -257,6 +290,38 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
257
290
|
await new Promise((r) => setTimeout(r, Math.min(6e3, 1200 + text.length * 60)));
|
|
258
291
|
}
|
|
259
292
|
};
|
|
293
|
+
const streamSpeak = async (prompt, onText) => {
|
|
294
|
+
const q = speechQueue;
|
|
295
|
+
q.startQueue();
|
|
296
|
+
setCallState("speaking");
|
|
297
|
+
let acc = "";
|
|
298
|
+
let buf = "";
|
|
299
|
+
let primed = false;
|
|
300
|
+
const primeWaveform = () => {
|
|
301
|
+
if (primed) return;
|
|
302
|
+
primed = true;
|
|
303
|
+
setTimeout(() => setAnalyser(q.getAnalyser?.() ?? null), 180);
|
|
304
|
+
};
|
|
305
|
+
for await (const delta of streamOrComplete(chatPort, prompt)) {
|
|
306
|
+
acc += delta;
|
|
307
|
+
buf += delta;
|
|
308
|
+
const { sentences, rest } = takeSentences(buf);
|
|
309
|
+
buf = rest;
|
|
310
|
+
for (const s of sentences) {
|
|
311
|
+
q.enqueueSentence(s);
|
|
312
|
+
primeWaveform();
|
|
313
|
+
}
|
|
314
|
+
onText(acc);
|
|
315
|
+
}
|
|
316
|
+
const tail = buf.trim();
|
|
317
|
+
if (tail) {
|
|
318
|
+
q.enqueueSentence(tail);
|
|
319
|
+
primeWaveform();
|
|
320
|
+
}
|
|
321
|
+
await new Promise((res) => q.finishQueue(res));
|
|
322
|
+
setAnalyser(null);
|
|
323
|
+
return acc.trim();
|
|
324
|
+
};
|
|
260
325
|
const handleUtterance = async (text) => {
|
|
261
326
|
const t = text.trim();
|
|
262
327
|
if (!t || !chatPort) return;
|
|
@@ -268,12 +333,18 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
268
333
|
const userMsg = { id: crypto.randomUUID(), role: "user", content: t };
|
|
269
334
|
historyRef.current = [...history, userMsg];
|
|
270
335
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
336
|
+
if (streaming) {
|
|
337
|
+
const replyText = await streamSpeak(build(persona, history, t), setLatestAi);
|
|
338
|
+
historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: "assistant", content: replyText }];
|
|
339
|
+
onTurn?.(userMsg, { text: replyText });
|
|
340
|
+
} else {
|
|
341
|
+
const raw = await chatPort.complete(build(persona, history, t));
|
|
342
|
+
const reply = parse(raw);
|
|
343
|
+
historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: "assistant", content: reply.text }];
|
|
344
|
+
setLatestAi(reply.text);
|
|
345
|
+
onTurn?.(userMsg, reply);
|
|
346
|
+
await speakReply(reply.text, reply.mood);
|
|
347
|
+
}
|
|
277
348
|
} catch (err) {
|
|
278
349
|
setLatestAi(`(\u51FA\u9519:${err instanceof Error ? err.message : String(err)})`);
|
|
279
350
|
} finally {
|
|
@@ -286,7 +357,19 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
286
357
|
bootedRef.current = true;
|
|
287
358
|
void (async () => {
|
|
288
359
|
try {
|
|
289
|
-
|
|
360
|
+
speechQueue?.primeAudio();
|
|
361
|
+
if (streaming && persona.greeting) {
|
|
362
|
+
const q = speechQueue;
|
|
363
|
+
q.startQueue();
|
|
364
|
+
setCallState("speaking");
|
|
365
|
+
const { sentences, rest } = takeSentences(persona.greeting + "\n");
|
|
366
|
+
[...sentences, rest.trim()].filter(Boolean).forEach((s) => q.enqueueSentence(s));
|
|
367
|
+
setTimeout(() => setAnalyser(q.getAnalyser?.() ?? null), 180);
|
|
368
|
+
await new Promise((res) => q.finishQueue(res));
|
|
369
|
+
setAnalyser(null);
|
|
370
|
+
} else if (persona.greeting && synth && voiceId) {
|
|
371
|
+
await speakReply(persona.greeting);
|
|
372
|
+
}
|
|
290
373
|
} finally {
|
|
291
374
|
setCallState("listening");
|
|
292
375
|
}
|
|
@@ -295,6 +378,7 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
295
378
|
useEffect3(
|
|
296
379
|
() => () => {
|
|
297
380
|
audioRef.current?.pause();
|
|
381
|
+
speechQueue?.stopQueue();
|
|
298
382
|
speech.stop();
|
|
299
383
|
void audioCtxRef.current?.close();
|
|
300
384
|
},
|
|
@@ -310,12 +394,14 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
310
394
|
const next = !on;
|
|
311
395
|
if (gainRef.current) gainRef.current.gain.value = next ? 1 : 0;
|
|
312
396
|
if (audioRef.current) audioRef.current.muted = !next;
|
|
397
|
+
speechQueue?.setMuted?.(!next);
|
|
313
398
|
return next;
|
|
314
399
|
});
|
|
315
400
|
};
|
|
316
401
|
const endCall = () => {
|
|
317
402
|
setCallState("ended");
|
|
318
403
|
audioRef.current?.pause();
|
|
404
|
+
speechQueue?.stopQueue();
|
|
319
405
|
speech.stop();
|
|
320
406
|
onClose(historyRef.current);
|
|
321
407
|
};
|
|
@@ -388,6 +474,7 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
|
|
|
388
474
|
export {
|
|
389
475
|
VoiceCall,
|
|
390
476
|
defaultBuildPrompt,
|
|
391
|
-
defaultParseReply
|
|
477
|
+
defaultParseReply,
|
|
478
|
+
defaultStreamPrompt
|
|
392
479
|
};
|
|
393
480
|
//# 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/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} from '../chat'\nimport { takeSentences } from '../calc/takeSentences'\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 /**\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 /** 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, 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 const speech = useCallSpeech(handleUtterance, speechLang)\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/** 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 } 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"]}
|
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.2.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",
|