@surfmate.team/digital-human-voice-call 0.1.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.
@@ -0,0 +1,59 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ChatPort, VoiceSynthesisPort } from '@surfmate.team/digital-human-ports';
3
+
4
+ /** The character context that shapes the prompt + the call header/avatar. */
5
+ type CallPersona = {
6
+ readonly name: string;
7
+ readonly description?: string | undefined;
8
+ readonly personality?: string | undefined;
9
+ readonly greeting?: string | undefined;
10
+ readonly avatarUrl?: string | undefined;
11
+ };
12
+ /** One role-tagged turn of history. */
13
+ type CallMessage = {
14
+ readonly id: string;
15
+ readonly role: 'user' | 'assistant';
16
+ readonly content: string;
17
+ };
18
+ /** A parsed assistant reply — text + an optional mood (used as the TTS emotion). */
19
+ type CallReply = {
20
+ readonly text: string;
21
+ readonly mood?: string | undefined;
22
+ };
23
+ /** Pure: persona + recent history + new message → the single prompt string. */
24
+ type BuildPrompt = (persona: CallPersona, history: readonly CallMessage[], userMessage: string) => string;
25
+ /** Pure: the LLM's raw completion → { text, mood? }. */
26
+ type ParseReply = (raw: string) => CallReply;
27
+ /** Default prompt builder — mirrors mate's grokAI.buildPrompt (JSON {text, mood}). */
28
+ declare const defaultBuildPrompt: BuildPrompt;
29
+ /** Default reply parser — pulls the first JSON {text, mood}; falls back to raw + neutral. */
30
+ declare const defaultParseReply: ParseReply;
31
+
32
+ type Props = {
33
+ readonly persona: CallPersona;
34
+ readonly chatPort?: ChatPort | undefined;
35
+ readonly synth?: VoiceSynthesisPort | undefined;
36
+ readonly voiceId?: string | undefined;
37
+ readonly voiceModelId?: string | undefined;
38
+ /** Override the prompt builder (pure). Omit → built-in mate-style default. */
39
+ readonly buildPrompt?: BuildPrompt | undefined;
40
+ /** Override the reply parser (pure). Omit → built-in JSON {text, mood} default. */
41
+ readonly parseReply?: ParseReply | undefined;
42
+ /** Emitted after each completed turn (user said X → assistant replied Y). The
43
+ * host persists it if it wants — voice-call itself stays in-memory. */
44
+ readonly onTurn?: ((user: CallMessage, ai: CallReply) => void) | undefined;
45
+ /** Called when the call ends; receives the full in-memory transcript so the
46
+ * host can persist it in one shot. (Arg is ignorable for UI-only closers.) */
47
+ readonly onClose: (history: readonly CallMessage[]) => void;
48
+ readonly speechLang?: string | undefined;
49
+ };
50
+ /**
51
+ * Voice-only call — the lightweight sibling of digital-human-video-call. Same
52
+ * turn loop (speech in → LLM via ChatPort → TTS via synth → back to listening),
53
+ * but NO talking-head video / clip selection / waiting loop / playback engine.
54
+ * The reply audio is routed through a Web Audio AnalyserNode so the Waveform
55
+ * pulses with the actual voice. Pure ports/DI — chatPort + synth are injected.
56
+ */
57
+ declare function VoiceCall({ persona, chatPort, synth, voiceId, voiceModelId, buildPrompt, parseReply, onTurn, onClose, speechLang }: Props): react_jsx_runtime.JSX.Element;
58
+
59
+ export { type BuildPrompt, type CallMessage, type CallPersona, type CallReply, type ParseReply, VoiceCall, defaultBuildPrompt, defaultParseReply };
package/dist/index.js ADDED
@@ -0,0 +1,393 @@
1
+ // src/ui/VoiceCall.tsx
2
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState2 } from "react";
3
+ import { Mic, MicOff, Volume2, VolumeX, PhoneOff, Send, Subtitles, Loader2 } from "lucide-react";
4
+
5
+ // src/chat.ts
6
+ var HISTORY_WINDOW = 5;
7
+ var defaultBuildPrompt = (persona, history, userMessage) => {
8
+ const historyContext = history.slice(-HISTORY_WINDOW).map((m) => `${m.role === "user" ? "User" : persona.name}: ${m.content}`).join("\n");
9
+ return `You are ${persona.name}.
10
+
11
+ Character background: ${persona.description ?? ""}
12
+
13
+ Personality traits: ${persona.personality ?? ""}
14
+
15
+ Your greeting message: "${persona.greeting ?? ""}"
16
+
17
+ Conversation history:
18
+ ${historyContext}
19
+
20
+ User: ${userMessage}
21
+
22
+ Respond 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.
23
+
24
+ IMPORTANT: Respond in JSON format with your reply text and current mood:
25
+ {"text": "your response here", "mood": "happy|calm|angry|sad"}
26
+
27
+ Choose the mood that best matches the emotional tone of your response. Only use: happy, calm, angry, or sad.`;
28
+ };
29
+ var VALID_MOODS = ["happy", "calm", "angry", "sad"];
30
+ var defaultParseReply = (raw) => {
31
+ const match = raw.match(/\{[\s\S]*"text"[\s\S]*\}/);
32
+ if (match) {
33
+ try {
34
+ const parsed = JSON.parse(match[0]);
35
+ const text = typeof parsed.text === "string" && parsed.text ? parsed.text : raw;
36
+ const mood = typeof parsed.mood === "string" && VALID_MOODS.includes(parsed.mood) ? parsed.mood : "neutral";
37
+ return { text, mood };
38
+ } catch {
39
+ }
40
+ }
41
+ return { text: raw, mood: "neutral" };
42
+ };
43
+
44
+ // src/ui/useCallSpeech.ts
45
+ import { useEffect, useRef, useState } from "react";
46
+ function getCtor() {
47
+ if (typeof window === "undefined") return null;
48
+ const w = window;
49
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
50
+ }
51
+ var SILENCE_MS = 2e3;
52
+ function useCallSpeech(onUtterance, lang = "zh-CN") {
53
+ const [listening, setListening] = useState(false);
54
+ const [draft, setDraft] = useState("");
55
+ const recRef = useRef(null);
56
+ const silenceRef = useRef(null);
57
+ const wantRef = useRef(false);
58
+ const supported = getCtor() !== null;
59
+ const clearSilence = () => {
60
+ if (silenceRef.current) {
61
+ clearTimeout(silenceRef.current);
62
+ silenceRef.current = null;
63
+ }
64
+ };
65
+ const stop = () => {
66
+ wantRef.current = false;
67
+ clearSilence();
68
+ recRef.current?.stop();
69
+ recRef.current = null;
70
+ setListening(false);
71
+ setDraft("");
72
+ };
73
+ const start = () => {
74
+ const Ctor = getCtor();
75
+ if (!Ctor || wantRef.current) return;
76
+ const rec = new Ctor();
77
+ rec.lang = lang;
78
+ rec.continuous = true;
79
+ rec.interimResults = true;
80
+ rec.onresult = (e) => {
81
+ let text = "";
82
+ for (let i = 0; i < e.results.length; i++) {
83
+ const r = e.results[i];
84
+ if (r) text += r[0].transcript;
85
+ }
86
+ setDraft(text);
87
+ clearSilence();
88
+ if (text.trim()) {
89
+ silenceRef.current = setTimeout(() => {
90
+ const finalText = text.trim();
91
+ setDraft("");
92
+ onUtterance(finalText);
93
+ }, SILENCE_MS);
94
+ }
95
+ };
96
+ rec.onerror = () => {
97
+ };
98
+ rec.onend = () => {
99
+ if (wantRef.current) {
100
+ try {
101
+ rec.start();
102
+ } catch {
103
+ }
104
+ } else {
105
+ setListening(false);
106
+ }
107
+ };
108
+ try {
109
+ rec.start();
110
+ recRef.current = rec;
111
+ wantRef.current = true;
112
+ setListening(true);
113
+ } catch {
114
+ }
115
+ };
116
+ useEffect(() => () => stop(), []);
117
+ return { supported, listening, draft, start, stop };
118
+ }
119
+
120
+ // src/ui/Waveform.tsx
121
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
122
+ import { jsx } from "react/jsx-runtime";
123
+ function Waveform({ analyser, phase }) {
124
+ const canvasRef = useRef2(null);
125
+ const analyserRef = useRef2(analyser);
126
+ const phaseRef = useRef2(phase);
127
+ analyserRef.current = analyser;
128
+ phaseRef.current = phase;
129
+ useEffect2(() => {
130
+ const canvas = canvasRef.current;
131
+ const ctx = canvas?.getContext("2d");
132
+ if (!canvas || !ctx) return;
133
+ const BARS = 40;
134
+ let raf = 0;
135
+ let t = 0;
136
+ let freq = null;
137
+ const draw = () => {
138
+ raf = requestAnimationFrame(draw);
139
+ t += 0.06;
140
+ const a = analyserRef.current;
141
+ const p = phaseRef.current;
142
+ const W = canvas.width;
143
+ const H = canvas.height;
144
+ ctx.clearRect(0, 0, W, H);
145
+ const live = a && p === "speaking";
146
+ if (live) {
147
+ if (!freq || freq.length !== a.frequencyBinCount) freq = new Uint8Array(a.frequencyBinCount);
148
+ a.getByteFrequencyData(freq);
149
+ }
150
+ const bw = W / BARS;
151
+ const idleBase = p === "listening" ? 0.22 : p === "thinking" ? 0.12 : p === "connecting" ? 0.08 : 0.05;
152
+ for (let i = 0; i < BARS; i++) {
153
+ let amp;
154
+ if (live && freq) {
155
+ const m = Math.abs(i - BARS / 2) / (BARS / 2);
156
+ const idx = Math.floor((1 - m) * (freq.length * 0.6));
157
+ amp = (freq[idx] ?? 0) / 255 * (1 - m * 0.35);
158
+ } else {
159
+ amp = idleBase * (0.6 + 0.4 * Math.sin(t + i * 0.4));
160
+ }
161
+ const h = Math.max(3, amp * H);
162
+ const x = i * bw + bw * 0.22;
163
+ const y = (H - h) / 2;
164
+ ctx.fillStyle = `rgba(168, 85, 247, ${0.45 + amp * 0.55})`;
165
+ const r = Math.min(bw * 0.28, h / 2);
166
+ ctx.beginPath();
167
+ ctx.roundRect(x, y, bw * 0.56, h, r);
168
+ ctx.fill();
169
+ }
170
+ };
171
+ draw();
172
+ return () => cancelAnimationFrame(raf);
173
+ }, []);
174
+ return /* @__PURE__ */ jsx("canvas", { ref: canvasRef, width: 640, height: 160, className: "h-40 w-full" });
175
+ }
176
+
177
+ // src/ui/VoiceCall.tsx
178
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
179
+ var STATUS = {
180
+ connecting: "\u63A5\u901A\u4E2D\u2026",
181
+ listening: "\u8046\u542C\u4E2D\u2026",
182
+ thinking: "\u601D\u8003\u4E2D\u2026",
183
+ speaking: "\u8BF4\u8BDD\u4E2D\u2026",
184
+ ended: "\u5DF2\u7ED3\u675F"
185
+ };
186
+ var ctlBtn = "flex h-12 w-12 items-center justify-center rounded-full transition";
187
+ var ctlOn = "bg-white/90 text-slate-900 hover:bg-white";
188
+ 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 build = buildPrompt ?? defaultBuildPrompt;
191
+ const parse = parseReply ?? defaultParseReply;
192
+ const [callState, setCallState] = useState2("connecting");
193
+ const [latestAi, setLatestAi] = useState2(persona.greeting ?? "");
194
+ const [latestUser, setLatestUser] = useState2("");
195
+ const [muted, setMuted] = useState2(false);
196
+ const [speakerOn, setSpeakerOn] = useState2(true);
197
+ const [showCaptions, setShowCaptions] = useState2(true);
198
+ const [typed, setTyped] = useState2("");
199
+ const [analyser, setAnalyser] = useState2(null);
200
+ const historyRef = useRef3([]);
201
+ const audioRef = useRef3(null);
202
+ const audioCtxRef = useRef3(null);
203
+ const gainRef = useRef3(null);
204
+ const stateRef = useRef3("connecting");
205
+ const speakerOnRef = useRef3(true);
206
+ const bootedRef = useRef3(false);
207
+ stateRef.current = callState;
208
+ speakerOnRef.current = speakerOn;
209
+ const playAudio = (bytes) => new Promise((resolve) => {
210
+ audioRef.current?.pause();
211
+ const url = URL.createObjectURL(new Blob([bytes], { type: "audio/mpeg" }));
212
+ const audio = new Audio(url);
213
+ audioRef.current = audio;
214
+ try {
215
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
216
+ const ctx = audioCtxRef.current ??= new Ctx();
217
+ if (ctx.state === "suspended") void ctx.resume();
218
+ const src = ctx.createMediaElementSource(audio);
219
+ const an = ctx.createAnalyser();
220
+ an.fftSize = 128;
221
+ const gain = ctx.createGain();
222
+ gain.gain.value = speakerOnRef.current ? 1 : 0;
223
+ gainRef.current = gain;
224
+ src.connect(an);
225
+ an.connect(gain);
226
+ gain.connect(ctx.destination);
227
+ setAnalyser(an);
228
+ } catch {
229
+ audio.muted = !speakerOnRef.current;
230
+ }
231
+ const done = () => {
232
+ URL.revokeObjectURL(url);
233
+ setAnalyser(null);
234
+ gainRef.current = null;
235
+ resolve();
236
+ };
237
+ audio.onended = done;
238
+ audio.onerror = done;
239
+ void audio.play().catch(done);
240
+ });
241
+ const speakReply = async (text, emotion) => {
242
+ setCallState("speaking");
243
+ if (synth && voiceId) {
244
+ try {
245
+ const bytes = await synth.synthesize({
246
+ text,
247
+ voiceId,
248
+ ...voiceModelId ? { modelId: voiceModelId } : {},
249
+ ...emotion ? { emotion } : {}
250
+ });
251
+ await playAudio(bytes);
252
+ } catch (err) {
253
+ console.warn("[voice-call] synth failed:", err);
254
+ await new Promise((r) => setTimeout(r, Math.min(6e3, 1200 + text.length * 60)));
255
+ }
256
+ } else {
257
+ await new Promise((r) => setTimeout(r, Math.min(6e3, 1200 + text.length * 60)));
258
+ }
259
+ };
260
+ const handleUtterance = async (text) => {
261
+ const t = text.trim();
262
+ if (!t || !chatPort) return;
263
+ if (stateRef.current === "thinking" || stateRef.current === "speaking") return;
264
+ speech.stop();
265
+ setLatestUser(t);
266
+ setCallState("thinking");
267
+ const history = historyRef.current;
268
+ const userMsg = { id: crypto.randomUUID(), role: "user", content: t };
269
+ historyRef.current = [...history, userMsg];
270
+ try {
271
+ const raw = await chatPort.complete(build(persona, history, t));
272
+ const reply = parse(raw);
273
+ historyRef.current = [...historyRef.current, { id: crypto.randomUUID(), role: "assistant", content: reply.text }];
274
+ setLatestAi(reply.text);
275
+ onTurn?.(userMsg, reply);
276
+ await speakReply(reply.text, reply.mood);
277
+ } catch (err) {
278
+ setLatestAi(`(\u51FA\u9519:${err instanceof Error ? err.message : String(err)})`);
279
+ } finally {
280
+ setCallState("listening");
281
+ }
282
+ };
283
+ const speech = useCallSpeech(handleUtterance, speechLang);
284
+ useEffect3(() => {
285
+ if (bootedRef.current) return;
286
+ bootedRef.current = true;
287
+ void (async () => {
288
+ try {
289
+ if (persona.greeting && synth && voiceId) await speakReply(persona.greeting);
290
+ } finally {
291
+ setCallState("listening");
292
+ }
293
+ })();
294
+ }, []);
295
+ useEffect3(
296
+ () => () => {
297
+ audioRef.current?.pause();
298
+ speech.stop();
299
+ void audioCtxRef.current?.close();
300
+ },
301
+ // eslint-disable-next-line react-hooks/exhaustive-deps
302
+ []
303
+ );
304
+ useEffect3(() => {
305
+ if (callState === "listening" && !muted) speech.start();
306
+ else speech.stop();
307
+ }, [callState, muted]);
308
+ const toggleSpeaker = () => {
309
+ setSpeakerOn((on) => {
310
+ const next = !on;
311
+ if (gainRef.current) gainRef.current.gain.value = next ? 1 : 0;
312
+ if (audioRef.current) audioRef.current.muted = !next;
313
+ return next;
314
+ });
315
+ };
316
+ const endCall = () => {
317
+ setCallState("ended");
318
+ audioRef.current?.pause();
319
+ speech.stop();
320
+ onClose(historyRef.current);
321
+ };
322
+ const sendTyped = () => {
323
+ const t = typed.trim();
324
+ if (!t) return;
325
+ setTyped("");
326
+ void handleUtterance(t);
327
+ };
328
+ const thinking = callState === "thinking";
329
+ return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-gradient-to-b from-slate-900 via-slate-950 to-black text-white", children: [
330
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between p-4", children: [
331
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-full bg-white/10 px-3 py-1.5 backdrop-blur", children: [
332
+ /* @__PURE__ */ jsx2("span", { className: `h-2 w-2 rounded-full ${thinking ? "animate-pulse bg-amber-400" : callState === "speaking" ? "bg-green-400" : "bg-white/60"}` }),
333
+ /* @__PURE__ */ jsx2("span", { className: "text-sm font-medium", children: persona.name }),
334
+ /* @__PURE__ */ jsxs("span", { className: "text-xs text-white/60", children: [
335
+ "\xB7 \u8BED\u97F3\u901A\u8BDD \xB7 ",
336
+ STATUS[callState]
337
+ ] })
338
+ ] }),
339
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => setShowCaptions((v) => !v), title: "\u5B57\u5E55", className: `rounded-full p-2 backdrop-blur transition ${showCaptions ? "bg-white/20" : "bg-white/5 text-white/60"}`, children: /* @__PURE__ */ jsx2(Subtitles, { className: "h-4 w-4" }) })
340
+ ] }),
341
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-1 flex-col items-center justify-center gap-6 px-6", children: [
342
+ /* @__PURE__ */ jsxs("div", { className: "relative", children: [
343
+ persona.avatarUrl ? /* @__PURE__ */ jsx2("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"}` }) : /* @__PURE__ */ jsx2("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", children: persona.name.slice(0, 1) }),
344
+ callState === "speaking" ? /* @__PURE__ */ jsx2("span", { className: "absolute inset-0 animate-ping rounded-full ring-2 ring-purple-500/40" }) : null
345
+ ] }),
346
+ /* @__PURE__ */ jsx2("div", { className: "w-full max-w-md", children: /* @__PURE__ */ jsx2(Waveform, { analyser, phase: callState }) }),
347
+ callState === "connecting" ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-white/70", children: [
348
+ /* @__PURE__ */ jsx2(Loader2, { className: "h-4 w-4 animate-spin" }),
349
+ /* @__PURE__ */ jsxs("span", { className: "text-sm", children: [
350
+ "\u63A5\u901A ",
351
+ persona.name,
352
+ "\u2026"
353
+ ] })
354
+ ] }) : null
355
+ ] }),
356
+ showCaptions ? /* @__PURE__ */ jsxs("div", { className: "space-y-2 px-6 pb-2", children: [
357
+ latestUser ? /* @__PURE__ */ jsx2("p", { className: "ml-auto max-w-[80%] text-right text-sm text-white/70", children: /* @__PURE__ */ jsx2("span", { className: "rounded-2xl bg-white/10 px-3 py-1.5 backdrop-blur", children: latestUser }) }) : null,
358
+ latestAi ? /* @__PURE__ */ jsx2("p", { className: "max-w-[80%] text-sm", children: /* @__PURE__ */ jsx2("span", { className: "inline-block rounded-2xl bg-black/40 px-3 py-1.5 leading-relaxed backdrop-blur", children: thinking ? /* @__PURE__ */ jsx2(Loader2, { className: "inline h-3.5 w-3.5 animate-spin" }) : `${persona.name}: ${latestAi}` }) }) : null
359
+ ] }) : null,
360
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 px-4 pb-5 pt-2", children: [
361
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-4", children: [
362
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => setMuted((v) => !v), title: muted ? "\u53D6\u6D88\u9759\u97F3" : "\u9EA6\u514B\u98CE\u9759\u97F3", className: `${ctlBtn} ${muted ? ctlOff : ctlOn}`, children: muted ? /* @__PURE__ */ jsx2(MicOff, { className: "h-5 w-5" }) : /* @__PURE__ */ jsx2(Mic, { className: "h-5 w-5" }) }),
363
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: toggleSpeaker, title: speakerOn ? "\u5173\u95ED\u5916\u653E" : "\u5F00\u542F\u5916\u653E", className: `${ctlBtn} ${speakerOn ? ctlOn : ctlOff}`, children: speakerOn ? /* @__PURE__ */ jsx2(Volume2, { className: "h-5 w-5" }) : /* @__PURE__ */ jsx2(VolumeX, { className: "h-5 w-5" }) }),
364
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: endCall, title: "\u6302\u65AD", className: "flex h-14 w-14 items-center justify-center rounded-full bg-red-600 text-white transition hover:bg-red-700", children: /* @__PURE__ */ jsx2(PhoneOff, { className: "h-6 w-6" }) })
365
+ ] }),
366
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
367
+ /* @__PURE__ */ jsx2(
368
+ "input",
369
+ {
370
+ value: typed,
371
+ onChange: (e) => setTyped(e.target.value),
372
+ onKeyDown: (e) => {
373
+ if (e.key === "Enter") {
374
+ e.preventDefault();
375
+ sendTyped();
376
+ }
377
+ },
378
+ disabled: !chatPort || thinking || callState === "speaking",
379
+ placeholder: chatPort ? "\u8BF4\u8BDD,\u6216\u5728\u6B64\u8F93\u5165\u2026" : "\u9700\u6CE8\u5165 ChatPort",
380
+ 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"
381
+ }
382
+ ),
383
+ /* @__PURE__ */ jsx2("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", children: /* @__PURE__ */ jsx2(Send, { className: "h-4 w-4" }) })
384
+ ] })
385
+ ] })
386
+ ] });
387
+ }
388
+ export {
389
+ VoiceCall,
390
+ defaultBuildPrompt,
391
+ defaultParseReply
392
+ };
393
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@surfmate.team/digital-human-voice-call",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "voice",
7
+ "call",
8
+ "chat",
9
+ "tts",
10
+ "waveform",
11
+ "digital-human"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "main": "./dist/index.js",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "peerDependencies": {
32
+ "react": ">=18"
33
+ },
34
+ "dependencies": {
35
+ "lucide-react": "^0.460.0",
36
+ "@surfmate.team/digital-human-ports": "0.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^18.3.0",
40
+ "react": "^18.3.1",
41
+ "tsup": "^8.5.0",
42
+ "typescript": "^5.9.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsup",
46
+ "typecheck": "tsc --noEmit"
47
+ }
48
+ }