@surfmate.team/digital-human-voice-call 0.2.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 CHANGED
@@ -47,6 +47,27 @@ type SpeechQueuePort = {
47
47
  /** The AnalyserNode the audio flows through, for a reactive waveform. Optional. */
48
48
  getAnalyser?(): AnalyserNode | null;
49
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
+ };
50
71
  /**
51
72
  * Streaming prompt builder — PLAIN TEXT output (no JSON wrapper), so reply tokens
52
73
  * can be split into speakable sentences as they stream. Use this (not the JSON
@@ -75,6 +96,11 @@ type Props = {
75
96
  * switches to PLAIN-TEXT (defaultStreamPrompt). Omit → classic synth path.
76
97
  */
77
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;
78
104
  /** Emitted after each completed turn (user said X → assistant replied Y). The
79
105
  * host persists it if it wants — voice-call itself stays in-memory. */
80
106
  readonly onTurn?: ((user: CallMessage, ai: CallReply) => void) | undefined;
@@ -90,6 +116,6 @@ type Props = {
90
116
  * The reply audio is routed through a Web Audio AnalyserNode so the Waveform
91
117
  * pulses with the actual voice. Pure ports/DI — chatPort + synth are injected.
92
118
  */
93
- declare function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, 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;
94
120
 
95
- export { type BuildPrompt, type CallMessage, type CallPersona, type CallReply, type ParseReply, type SpeechQueuePort, VoiceCall, defaultBuildPrompt, defaultParseReply, defaultStreamPrompt };
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,5 +1,5 @@
1
1
  // src/ui/VoiceCall.tsx
2
- import { useEffect as useEffect3, useRef as useRef3, useState as useState2 } from "react";
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
4
  import { streamOrComplete } from "@surfmate.team/digital-human-llm";
5
5
 
@@ -149,16 +149,59 @@ function useCallSpeech(onUtterance, lang = "zh-CN") {
149
149
  return { supported, listening, draft, start, stop };
150
150
  }
151
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
+
152
195
  // src/ui/Waveform.tsx
153
- import { useEffect as useEffect2, useRef as useRef2 } from "react";
196
+ import { useEffect as useEffect3, useRef as useRef3 } from "react";
154
197
  import { jsx } from "react/jsx-runtime";
155
198
  function Waveform({ analyser, phase }) {
156
- const canvasRef = useRef2(null);
157
- const analyserRef = useRef2(analyser);
158
- const phaseRef = useRef2(phase);
199
+ const canvasRef = useRef3(null);
200
+ const analyserRef = useRef3(analyser);
201
+ const phaseRef = useRef3(phase);
159
202
  analyserRef.current = analyser;
160
203
  phaseRef.current = phase;
161
- useEffect2(() => {
204
+ useEffect3(() => {
162
205
  const canvas = canvasRef.current;
163
206
  const ctx = canvas?.getContext("2d");
164
207
  if (!canvas || !ctx) return;
@@ -218,25 +261,25 @@ var STATUS = {
218
261
  var ctlBtn = "flex h-12 w-12 items-center justify-center rounded-full transition";
219
262
  var ctlOn = "bg-white/90 text-slate-900 hover:bg-white";
220
263
  var ctlOff = "bg-red-600/90 text-white hover:bg-red-600";
221
- function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, onTurn, onClose, speechLang }) {
264
+ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, speechQueue, stt, onTurn, onClose, speechLang }) {
222
265
  const streaming = !!speechQueue;
223
266
  const build = buildPrompt ?? (streaming ? defaultStreamPrompt : defaultBuildPrompt);
224
267
  const parse = parseReply ?? defaultParseReply;
225
- const [callState, setCallState] = useState2("connecting");
226
- const [latestAi, setLatestAi] = useState2(persona.greeting ?? "");
227
- const [latestUser, setLatestUser] = useState2("");
228
- const [muted, setMuted] = useState2(false);
229
- const [speakerOn, setSpeakerOn] = useState2(true);
230
- const [showCaptions, setShowCaptions] = useState2(true);
231
- const [typed, setTyped] = useState2("");
232
- const [analyser, setAnalyser] = useState2(null);
233
- const historyRef = useRef3([]);
234
- const audioRef = useRef3(null);
235
- const audioCtxRef = useRef3(null);
236
- const gainRef = useRef3(null);
237
- const stateRef = useRef3("connecting");
238
- const speakerOnRef = useRef3(true);
239
- const bootedRef = useRef3(false);
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);
240
283
  stateRef.current = callState;
241
284
  speakerOnRef.current = speakerOn;
242
285
  const playAudio = (bytes) => new Promise((resolve) => {
@@ -351,8 +394,10 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
351
394
  setCallState("listening");
352
395
  }
353
396
  };
354
- const speech = useCallSpeech(handleUtterance, speechLang);
355
- useEffect3(() => {
397
+ const browserSpeech = useCallSpeech(handleUtterance, speechLang);
398
+ const azureSpeech = useSegmentSpeech(stt, handleUtterance);
399
+ const speech = stt ? azureSpeech : browserSpeech;
400
+ useEffect4(() => {
356
401
  if (bootedRef.current) return;
357
402
  bootedRef.current = true;
358
403
  void (async () => {
@@ -375,7 +420,7 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
375
420
  }
376
421
  })();
377
422
  }, []);
378
- useEffect3(
423
+ useEffect4(
379
424
  () => () => {
380
425
  audioRef.current?.pause();
381
426
  speechQueue?.stopQueue();
@@ -385,7 +430,7 @@ function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPromp
385
430
  // eslint-disable-next-line react-hooks/exhaustive-deps
386
431
  []
387
432
  );
388
- useEffect3(() => {
433
+ useEffect4(() => {
389
434
  if (callState === "listening" && !muted) speech.start();
390
435
  else speech.stop();
391
436
  }, [callState, muted]);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
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"]}
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":"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","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.2.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",