@bytexbyte/nxtlinq-ai-agent-web-development 0.1.1
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/context/NxtlinqAgentContext.d.ts +12 -0
- package/dist/context/NxtlinqAgentContext.d.ts.map +1 -0
- package/dist/context/NxtlinqAgentContext.js +33 -0
- package/dist/createNxtlinqAgent.d.ts +9 -0
- package/dist/createNxtlinqAgent.d.ts.map +1 -0
- package/dist/createNxtlinqAgent.js +19 -0
- package/dist/hooks/useNxtlinqAgent.d.ts +18 -0
- package/dist/hooks/useNxtlinqAgent.d.ts.map +1 -0
- package/dist/hooks/useNxtlinqAgent.js +23 -0
- package/dist/hooks/useNxtlinqVoice.d.ts +21 -0
- package/dist/hooks/useNxtlinqVoice.d.ts.map +1 -0
- package/dist/hooks/useNxtlinqVoice.js +75 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/legacy/api/nxtlinq-api.d.ts +8 -0
- package/dist/legacy/api/nxtlinq-api.d.ts.map +1 -0
- package/dist/legacy/api/nxtlinq-api.js +13 -0
- package/dist/legacy/api/voice.d.ts +11 -0
- package/dist/legacy/api/voice.d.ts.map +1 -0
- package/dist/legacy/api/voice.js +26 -0
- package/dist/legacy/core/lib/messageHistory.d.ts +2 -0
- package/dist/legacy/core/lib/messageHistory.d.ts.map +1 -0
- package/dist/legacy/core/lib/messageHistory.js +1 -0
- package/dist/legacy/core/lib/textToSpeech.d.ts +14 -0
- package/dist/legacy/core/lib/textToSpeech.d.ts.map +1 -0
- package/dist/legacy/core/lib/textToSpeech.js +82 -0
- package/dist/legacy/core/lib/useDraggable.d.ts +15 -0
- package/dist/legacy/core/lib/useDraggable.d.ts.map +1 -0
- package/dist/legacy/core/lib/useDraggable.js +158 -0
- package/dist/legacy/core/lib/useLocalStorage.d.ts +11 -0
- package/dist/legacy/core/lib/useLocalStorage.d.ts.map +1 -0
- package/dist/legacy/core/lib/useLocalStorage.js +83 -0
- package/dist/legacy/core/lib/useResizable.d.ts +17 -0
- package/dist/legacy/core/lib/useResizable.d.ts.map +1 -0
- package/dist/legacy/core/lib/useResizable.js +203 -0
- package/dist/legacy/core/lib/useSessionStorage.d.ts +11 -0
- package/dist/legacy/core/lib/useSessionStorage.d.ts.map +1 -0
- package/dist/legacy/core/lib/useSessionStorage.js +37 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.d.ts +26 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.d.ts.map +1 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.js +102 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/index.d.ts +16 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/index.d.ts.map +1 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/index.js +92 -0
- package/dist/legacy/core/lib/useVoiceMode.d.ts +32 -0
- package/dist/legacy/core/lib/useVoiceMode.d.ts.map +1 -0
- package/dist/legacy/core/lib/useVoiceMode.js +373 -0
- package/dist/legacy/core/metakeepClient.d.ts +4 -0
- package/dist/legacy/core/metakeepClient.d.ts.map +1 -0
- package/dist/legacy/core/metakeepClient.js +10 -0
- package/dist/legacy/core/utils/aitUtils.d.ts +31 -0
- package/dist/legacy/core/utils/aitUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/aitUtils.js +35 -0
- package/dist/legacy/core/utils/ethersUtils.d.ts +8 -0
- package/dist/legacy/core/utils/ethersUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/ethersUtils.js +19 -0
- package/dist/legacy/core/utils/index.d.ts +3 -0
- package/dist/legacy/core/utils/index.d.ts.map +1 -0
- package/dist/legacy/core/utils/index.js +4 -0
- package/dist/legacy/core/utils/notificationUtils.d.ts +29 -0
- package/dist/legacy/core/utils/notificationUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/notificationUtils.js +47 -0
- package/dist/legacy/core/utils/urlUtils.d.ts +25 -0
- package/dist/legacy/core/utils/urlUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/urlUtils.js +135 -0
- package/dist/legacy/core/utils/walletTextUtils.d.ts +14 -0
- package/dist/legacy/core/utils/walletTextUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/walletTextUtils.js +23 -0
- package/dist/legacy/core/utils/walletUtils.d.ts +10 -0
- package/dist/legacy/core/utils/walletUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/walletUtils.js +38 -0
- package/dist/legacy/index.d.ts +19 -0
- package/dist/legacy/index.d.ts.map +1 -0
- package/dist/legacy/index.js +16 -0
- package/dist/ports/createWebPlatformPorts.d.ts +13 -0
- package/dist/ports/createWebPlatformPorts.d.ts.map +1 -0
- package/dist/ports/createWebPlatformPorts.js +25 -0
- package/dist/utils/fileToAttachment.d.ts +4 -0
- package/dist/utils/fileToAttachment.d.ts.map +1 -0
- package/dist/utils/fileToAttachment.js +28 -0
- package/dist/voice/useVoiceSilenceCommit.d.ts +11 -0
- package/dist/voice/useVoiceSilenceCommit.d.ts.map +1 -0
- package/dist/voice/useVoiceSilenceCommit.js +68 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts +16 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts.map +1 -0
- package/dist/voice/useVoiceTranscriptMessages.js +134 -0
- package/dist/voice/useWsRealtimeAudio.d.ts +18 -0
- package/dist/voice/useWsRealtimeAudio.d.ts.map +1 -0
- package/dist/voice/useWsRealtimeAudio.js +115 -0
- package/dist/voice/voiceMicConstants.d.ts +4 -0
- package/dist/voice/voiceMicConstants.d.ts.map +1 -0
- package/dist/voice/voiceMicConstants.js +10 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.d.ts +23 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.d.ts.map +1 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.js +138 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.d.ts +19 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.d.ts.map +1 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.js +76 -0
- package/dist/voice/ws/float32ToPcm16.d.ts +2 -0
- package/dist/voice/ws/float32ToPcm16.d.ts.map +1 -0
- package/dist/voice/ws/float32ToPcm16.js +8 -0
- package/dist/voice/ws/voiceSilenceConstants.d.ts +5 -0
- package/dist/voice/ws/voiceSilenceConstants.d.ts.map +1 -0
- package/dist/voice/ws/voiceSilenceConstants.js +4 -0
- package/dist/voice/ws/wsRealtimeConstants.d.ts +2 -0
- package/dist/voice/ws/wsRealtimeConstants.d.ts.map +1 -0
- package/dist/voice/ws/wsRealtimeConstants.js +1 -0
- package/dist/webAgentDefaults.d.ts +9 -0
- package/dist/webAgentDefaults.d.ts.map +1 -0
- package/dist/webAgentDefaults.js +9 -0
- package/package.json +55 -0
- package/src/context/NxtlinqAgentContext.tsx +79 -0
- package/src/createNxtlinqAgent.ts +36 -0
- package/src/hooks/useNxtlinqAgent.ts +73 -0
- package/src/hooks/useNxtlinqVoice.ts +143 -0
- package/src/index.ts +84 -0
- package/src/legacy/api/nxtlinq-api.ts +32 -0
- package/src/legacy/api/voice.ts +72 -0
- package/src/legacy/core/lib/messageHistory.ts +6 -0
- package/src/legacy/core/lib/textToSpeech.ts +127 -0
- package/src/legacy/core/lib/useDraggable.ts +193 -0
- package/src/legacy/core/lib/useLocalStorage.ts +89 -0
- package/src/legacy/core/lib/useResizable.ts +256 -0
- package/src/legacy/core/lib/useSessionStorage.ts +43 -0
- package/src/legacy/core/lib/useSpeechToTextFromMic/helper.ts +132 -0
- package/src/legacy/core/lib/useSpeechToTextFromMic/index.ts +126 -0
- package/src/legacy/core/lib/useVoiceMode.ts +407 -0
- package/src/legacy/core/metakeepClient.ts +12 -0
- package/src/legacy/core/utils/aitUtils.ts +55 -0
- package/src/legacy/core/utils/ethersUtils.ts +24 -0
- package/src/legacy/core/utils/index.ts +5 -0
- package/src/legacy/core/utils/notificationUtils.ts +64 -0
- package/src/legacy/core/utils/urlUtils.ts +160 -0
- package/src/legacy/core/utils/walletTextUtils.ts +26 -0
- package/src/legacy/core/utils/walletUtils.ts +53 -0
- package/src/legacy/index.ts +35 -0
- package/src/ports/createWebPlatformPorts.ts +44 -0
- package/src/utils/fileToAttachment.ts +32 -0
- package/src/voice/useVoiceSilenceCommit.ts +84 -0
- package/src/voice/useVoiceTranscriptMessages.ts +184 -0
- package/src/voice/useWsRealtimeAudio.ts +141 -0
- package/src/voice/voiceMicConstants.ts +13 -0
- package/src/voice/ws/BrowserWsPcmPlayer.ts +139 -0
- package/src/voice/ws/BrowserWsPcmRecorder.ts +83 -0
- package/src/voice/ws/float32ToPcm16.ts +8 -0
- package/src/voice/ws/voiceSilenceConstants.ts +4 -0
- package/src/voice/ws/wsRealtimeConstants.ts +1 -0
- package/src/webAgentDefaults.ts +12 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Message,
|
|
3
|
+
VoiceDoneEvent,
|
|
4
|
+
VoiceTranscriptEvent,
|
|
5
|
+
} from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
6
|
+
import { mergeStreamingTranscript } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
7
|
+
import { useCallback, useRef } from 'react';
|
|
8
|
+
import { flushSync } from 'react-dom';
|
|
9
|
+
|
|
10
|
+
type InteractionMode = 'text' | 'voice';
|
|
11
|
+
|
|
12
|
+
type VoiceTranscriptAgentApi = {
|
|
13
|
+
getMessages: () => Message[];
|
|
14
|
+
setMessages: (messages: Message[]) => void;
|
|
15
|
+
syncVoiceTurnHistory: (options?: { last?: number }) => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const STREAM_PREFIX = 'voice-stream-';
|
|
19
|
+
|
|
20
|
+
function voiceMeta(sessionId: string | null) {
|
|
21
|
+
return {
|
|
22
|
+
voiceRealtime: true as const,
|
|
23
|
+
voiceSessionId: sessionId ?? undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useVoiceTranscriptMessages(
|
|
28
|
+
api: VoiceTranscriptAgentApi,
|
|
29
|
+
interactionMode: InteractionMode,
|
|
30
|
+
voiceSessionId: string | null,
|
|
31
|
+
getVoiceSessionId?: () => string | null,
|
|
32
|
+
) {
|
|
33
|
+
const streamIdRef = useRef<string | null>(null);
|
|
34
|
+
const sessionIdRef = useRef(voiceSessionId);
|
|
35
|
+
sessionIdRef.current = voiceSessionId;
|
|
36
|
+
|
|
37
|
+
const resolveSessionId = useCallback(
|
|
38
|
+
() => getVoiceSessionId?.() ?? sessionIdRef.current,
|
|
39
|
+
[getVoiceSessionId],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const isVoiceUiActive = useCallback(
|
|
43
|
+
() => interactionMode === 'voice',
|
|
44
|
+
[interactionMode],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const upsertStreaming = useCallback(
|
|
48
|
+
(text: string) => {
|
|
49
|
+
const messages = api.getMessages();
|
|
50
|
+
let streamId = streamIdRef.current;
|
|
51
|
+
if (!streamId) {
|
|
52
|
+
streamId = `${STREAM_PREFIX}${Date.now()}`;
|
|
53
|
+
streamIdRef.current = streamId;
|
|
54
|
+
}
|
|
55
|
+
const idx = messages.findIndex((m) => m.id === streamId);
|
|
56
|
+
const partialContent =
|
|
57
|
+
idx >= 0
|
|
58
|
+
? mergeStreamingTranscript(messages[idx]?.partialContent ?? '', text)
|
|
59
|
+
: text;
|
|
60
|
+
const meta = voiceMeta(resolveSessionId());
|
|
61
|
+
const apply = () => {
|
|
62
|
+
if (idx >= 0) {
|
|
63
|
+
api.setMessages(
|
|
64
|
+
messages.map((m, i) =>
|
|
65
|
+
i === idx
|
|
66
|
+
? { ...m, partialContent, isStreaming: true, metadata: { ...m.metadata, ...meta } }
|
|
67
|
+
: m,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
api.setMessages([
|
|
73
|
+
...messages,
|
|
74
|
+
{
|
|
75
|
+
id: streamId,
|
|
76
|
+
role: 'assistant',
|
|
77
|
+
content: '',
|
|
78
|
+
partialContent,
|
|
79
|
+
isStreaming: true,
|
|
80
|
+
timestamp: new Date().toISOString(),
|
|
81
|
+
metadata: meta,
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
};
|
|
85
|
+
flushSync(apply);
|
|
86
|
+
},
|
|
87
|
+
[api, resolveSessionId],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const finalizeAssistant = useCallback(
|
|
91
|
+
(text: string, messageId?: string | null) => {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
streamIdRef.current = null;
|
|
94
|
+
if (!trimmed) return;
|
|
95
|
+
|
|
96
|
+
const messages = api.getMessages();
|
|
97
|
+
const streamIdx = messages.findIndex((m) => m.isStreaming && m.role === 'assistant');
|
|
98
|
+
if (streamIdx >= 0) {
|
|
99
|
+
api.setMessages(
|
|
100
|
+
messages.map((m, i) =>
|
|
101
|
+
i === streamIdx
|
|
102
|
+
? {
|
|
103
|
+
...m,
|
|
104
|
+
id: messageId ?? m.id,
|
|
105
|
+
content: trimmed,
|
|
106
|
+
partialContent: undefined,
|
|
107
|
+
isStreaming: false,
|
|
108
|
+
metadata: { ...m.metadata, ...voiceMeta(resolveSessionId()) },
|
|
109
|
+
}
|
|
110
|
+
: m,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const last = messages[messages.length - 1];
|
|
116
|
+
if (last?.role === 'assistant' && last.content === trimmed) return;
|
|
117
|
+
api.setMessages([
|
|
118
|
+
...messages,
|
|
119
|
+
{
|
|
120
|
+
id: messageId ?? `voice-asst-${Date.now()}`,
|
|
121
|
+
role: 'assistant',
|
|
122
|
+
content: trimmed,
|
|
123
|
+
timestamp: new Date().toISOString(),
|
|
124
|
+
metadata: voiceMeta(resolveSessionId()),
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
},
|
|
128
|
+
[api, resolveSessionId],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const handleTranscript = useCallback(
|
|
132
|
+
(event: VoiceTranscriptEvent) => {
|
|
133
|
+
if (!isVoiceUiActive()) return;
|
|
134
|
+
const text = event.text?.trim() ?? '';
|
|
135
|
+
if (event.role === 'assistant') {
|
|
136
|
+
// Keep one streaming bubble for the whole turn; finalize only in handleDone.
|
|
137
|
+
if (text) upsertStreaming(text);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (event.role === 'user' && !event.interim && text) {
|
|
141
|
+
const messages = api.getMessages();
|
|
142
|
+
const last = messages[messages.length - 1];
|
|
143
|
+
if (last?.role === 'user' && last.content === text) return;
|
|
144
|
+
api.setMessages([
|
|
145
|
+
...messages,
|
|
146
|
+
{
|
|
147
|
+
id: `voice-user-${Date.now()}`,
|
|
148
|
+
role: 'user',
|
|
149
|
+
content: text,
|
|
150
|
+
timestamp: new Date().toISOString(),
|
|
151
|
+
metadata: voiceMeta(resolveSessionId()),
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
[api, finalizeAssistant, isVoiceUiActive, resolveSessionId, upsertStreaming],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const handleDone = useCallback(
|
|
160
|
+
(event: VoiceDoneEvent) => {
|
|
161
|
+
if (!isVoiceUiActive()) return;
|
|
162
|
+
if (event.guardrailsBlocked || event.billingBlocked || event.error) {
|
|
163
|
+
streamIdRef.current = null;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const reply = event.replyText?.trim() ?? '';
|
|
167
|
+
if (reply) {
|
|
168
|
+
finalizeAssistant(reply, event.assistantMessageId ?? undefined);
|
|
169
|
+
} else {
|
|
170
|
+
streamIdRef.current = null;
|
|
171
|
+
}
|
|
172
|
+
void api.syncVoiceTurnHistory({ last: 20 }).catch((err) => {
|
|
173
|
+
console.warn('[nxtlinq] syncVoiceTurnHistory after voice turn failed', err);
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
[api, finalizeAssistant, isVoiceUiActive],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const clearVoiceStream = useCallback(() => {
|
|
180
|
+
streamIdRef.current = null;
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
return { handleTranscript, handleDone, clearVoiceStream };
|
|
184
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { VoiceSession, VoiceStatus } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { BrowserWsPcmPlayer } from './ws/BrowserWsPcmPlayer';
|
|
4
|
+
import { BrowserWsPcmRecorder } from './ws/BrowserWsPcmRecorder';
|
|
5
|
+
import { useVoiceSilenceCommit } from './useVoiceSilenceCommit';
|
|
6
|
+
|
|
7
|
+
type WsVoiceCallbacks = {
|
|
8
|
+
onOpen?: () => void;
|
|
9
|
+
onAudioDelta?: (pcm16: Int16Array | ArrayBuffer) => void;
|
|
10
|
+
onClose?: (reason: string) => void;
|
|
11
|
+
onError?: (err: Error) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type UseWsRealtimeAudioOptions = {
|
|
15
|
+
voiceStatus: VoiceStatus;
|
|
16
|
+
muteAfterSilenceCommit: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function useWsRealtimeAudio(
|
|
20
|
+
isCaptureActive: boolean,
|
|
21
|
+
isVoiceActive: boolean,
|
|
22
|
+
options: UseWsRealtimeAudioOptions,
|
|
23
|
+
) {
|
|
24
|
+
const playerRef = useRef<BrowserWsPcmPlayer | null>(null);
|
|
25
|
+
const recorderRef = useRef<BrowserWsPcmRecorder | null>(null);
|
|
26
|
+
const sessionRef = useRef<VoiceSession | null>(null);
|
|
27
|
+
const captureGenRef = useRef(0);
|
|
28
|
+
const isCaptureActiveRef = useRef(isCaptureActive);
|
|
29
|
+
isCaptureActiveRef.current = isCaptureActive;
|
|
30
|
+
const prevCaptureActiveRef = useRef(isCaptureActive);
|
|
31
|
+
|
|
32
|
+
const getSession = useCallback(() => sessionRef.current, []);
|
|
33
|
+
const muteAfterSilenceCommitRef = useRef(options.muteAfterSilenceCommit);
|
|
34
|
+
muteAfterSilenceCommitRef.current = options.muteAfterSilenceCommit;
|
|
35
|
+
|
|
36
|
+
const silence = useVoiceSilenceCommit(
|
|
37
|
+
getSession,
|
|
38
|
+
() => muteAfterSilenceCommitRef.current(),
|
|
39
|
+
options.voiceStatus,
|
|
40
|
+
);
|
|
41
|
+
const silenceRef = useRef(silence);
|
|
42
|
+
silenceRef.current = silence;
|
|
43
|
+
|
|
44
|
+
const ensurePlayer = useCallback(async () => {
|
|
45
|
+
if (!playerRef.current) {
|
|
46
|
+
playerRef.current = new BrowserWsPcmPlayer();
|
|
47
|
+
playerRef.current.prewarm();
|
|
48
|
+
}
|
|
49
|
+
await playerRef.current.ensureRunning();
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const stopCapture = useCallback((commit: boolean) => {
|
|
53
|
+
captureGenRef.current += 1;
|
|
54
|
+
const s = silenceRef.current;
|
|
55
|
+
s.clearPoll();
|
|
56
|
+
// Commit before recorder.stop() — stop() must not clear the OpenAI input buffer first.
|
|
57
|
+
if (commit && !s.consumeSkipCommitOnMute()) {
|
|
58
|
+
s.tryCommit('manual');
|
|
59
|
+
}
|
|
60
|
+
recorderRef.current?.stop();
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const startCapture = useCallback(async () => {
|
|
64
|
+
const session = sessionRef.current;
|
|
65
|
+
if (!session) return;
|
|
66
|
+
const gen = ++captureGenRef.current;
|
|
67
|
+
const s = silenceRef.current;
|
|
68
|
+
if (!recorderRef.current) recorderRef.current = new BrowserWsPcmRecorder();
|
|
69
|
+
playerRef.current?.clearQueue();
|
|
70
|
+
recorderRef.current.bindSession(session);
|
|
71
|
+
recorderRef.current.setOnRms(s.onSpeechRms);
|
|
72
|
+
recorderRef.current.setShouldAppend(() => s.hasHadSpeech());
|
|
73
|
+
// Let mic-unmute state settle before getUserMedia (Berify beginMicCapture).
|
|
74
|
+
await new Promise<void>((resolve) => {
|
|
75
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
76
|
+
});
|
|
77
|
+
if (gen !== captureGenRef.current || !isCaptureActiveRef.current) return;
|
|
78
|
+
s.resetTurn();
|
|
79
|
+
try {
|
|
80
|
+
await recorderRef.current.start();
|
|
81
|
+
if (gen !== captureGenRef.current || !isCaptureActiveRef.current) {
|
|
82
|
+
recorderRef.current.stop();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
s.startPoll();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
s.clearPoll();
|
|
88
|
+
console.error('[nxtlinq] mic capture start failed', err);
|
|
89
|
+
}
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const cleanup = useCallback(() => {
|
|
93
|
+
silenceRef.current.clearPoll();
|
|
94
|
+
recorderRef.current?.cleanup();
|
|
95
|
+
recorderRef.current = null;
|
|
96
|
+
playerRef.current?.cleanup();
|
|
97
|
+
playerRef.current = null;
|
|
98
|
+
sessionRef.current = null;
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const bindSession = useCallback((session: VoiceSession | null, captureWhenUnmuted = false) => {
|
|
102
|
+
sessionRef.current = session;
|
|
103
|
+
recorderRef.current?.bindSession(session);
|
|
104
|
+
if (session && captureWhenUnmuted && isCaptureActiveRef.current) {
|
|
105
|
+
void startCapture();
|
|
106
|
+
}
|
|
107
|
+
}, [startCapture]);
|
|
108
|
+
|
|
109
|
+
const buildCallbacks = useCallback((overrides?: Partial<WsVoiceCallbacks>): WsVoiceCallbacks => ({
|
|
110
|
+
onOpen: () => {
|
|
111
|
+
void ensurePlayer();
|
|
112
|
+
overrides?.onOpen?.();
|
|
113
|
+
},
|
|
114
|
+
onAudioDelta: (pcm16) => {
|
|
115
|
+
void ensurePlayer().then(() => playerRef.current?.addAudio(pcm16));
|
|
116
|
+
overrides?.onAudioDelta?.(pcm16);
|
|
117
|
+
},
|
|
118
|
+
onClose: (reason) => {
|
|
119
|
+
cleanup();
|
|
120
|
+
overrides?.onClose?.(reason);
|
|
121
|
+
},
|
|
122
|
+
onError: (err) => {
|
|
123
|
+
cleanup();
|
|
124
|
+
overrides?.onError?.(err);
|
|
125
|
+
},
|
|
126
|
+
}), [cleanup, ensurePlayer]);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!isVoiceActive) {
|
|
130
|
+
prevCaptureActiveRef.current = false;
|
|
131
|
+
cleanup();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const prev = prevCaptureActiveRef.current;
|
|
135
|
+
prevCaptureActiveRef.current = isCaptureActive;
|
|
136
|
+
if (isCaptureActive && !prev) void startCapture();
|
|
137
|
+
else if (!isCaptureActive && prev) stopCapture(true);
|
|
138
|
+
}, [isCaptureActive, isVoiceActive, startCapture, stopCapture, cleanup]);
|
|
139
|
+
|
|
140
|
+
return { bindSession, buildCallbacks, getOutputAudioLevel: () => playerRef.current?.getAudioLevel() ?? 0 };
|
|
141
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { VoiceStatus } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
|
|
3
|
+
export const ASSISTANT_MIC_HOLD_STATUSES: ReadonlySet<VoiceStatus> = new Set([
|
|
4
|
+
'transcribing',
|
|
5
|
+
'thinking',
|
|
6
|
+
'generating',
|
|
7
|
+
'speaking',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const SPEAKER_ACTIVE_STATUSES: ReadonlySet<VoiceStatus> = new Set([
|
|
11
|
+
'generating',
|
|
12
|
+
'speaking',
|
|
13
|
+
]);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { WS_REALTIME_SAMPLE_RATE } from './wsRealtimeConstants';
|
|
2
|
+
|
|
3
|
+
const LEVEL_SCALE = 3.2;
|
|
4
|
+
const LOOKAHEAD_SECONDS = 0.02;
|
|
5
|
+
const CROSSFADE_SECONDS = 0.04;
|
|
6
|
+
|
|
7
|
+
function applyFade(buffer: Float32Array, fadeSamples: number): void {
|
|
8
|
+
const fade = Math.min(fadeSamples, Math.floor(buffer.length / 2));
|
|
9
|
+
if (fade <= 0) return;
|
|
10
|
+
for (let i = 0; i < fade; i += 1) {
|
|
11
|
+
buffer[i]! *= i / fade;
|
|
12
|
+
buffer[buffer.length - 1 - i]! *= (fade - i) / fade;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class BrowserWsPcmPlayer {
|
|
17
|
+
private readonly sampleRate = WS_REALTIME_SAMPLE_RATE;
|
|
18
|
+
private readonly fadeSamples = Math.max(1, Math.floor(this.sampleRate * 0.01));
|
|
19
|
+
private audioContext: AudioContext | null = null;
|
|
20
|
+
private gainNode: GainNode | null = null;
|
|
21
|
+
private analyserNode: AnalyserNode | null = null;
|
|
22
|
+
private analyserBuffer: Float32Array | null = null;
|
|
23
|
+
private readonly activeSources = new Set<AudioBufferSourceNode>();
|
|
24
|
+
private queue: AudioBuffer[] = [];
|
|
25
|
+
private isPlaying = true;
|
|
26
|
+
private playHead = 0;
|
|
27
|
+
private lastChunkRms = 0;
|
|
28
|
+
private lastChunkAt = 0;
|
|
29
|
+
|
|
30
|
+
async ensureRunning(): Promise<void> {
|
|
31
|
+
const ctx = this.ensureContext();
|
|
32
|
+
if (ctx.state === 'suspended') await ctx.resume();
|
|
33
|
+
this.isPlaying = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
prewarm(): void {
|
|
37
|
+
this.ensureContext();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
addAudio(pcm16: Int16Array | ArrayBuffer): void {
|
|
41
|
+
const ctx = this.ensureContext();
|
|
42
|
+
const int16 = pcm16 instanceof ArrayBuffer ? new Int16Array(pcm16) : pcm16;
|
|
43
|
+
if (int16.length === 0) return;
|
|
44
|
+
const floats = new Float32Array(int16.length);
|
|
45
|
+
let sumSq = 0;
|
|
46
|
+
for (let i = 0; i < int16.length; i += 1) {
|
|
47
|
+
const sample = int16[i]! / 32768;
|
|
48
|
+
floats[i] = Math.max(-1, Math.min(1, sample));
|
|
49
|
+
sumSq += sample * sample;
|
|
50
|
+
}
|
|
51
|
+
applyFade(floats, this.fadeSamples);
|
|
52
|
+
this.lastChunkRms = Math.min(1, Math.sqrt(sumSq / int16.length) * LEVEL_SCALE);
|
|
53
|
+
this.lastChunkAt = Date.now();
|
|
54
|
+
const buffer = ctx.createBuffer(1, floats.length, this.sampleRate);
|
|
55
|
+
buffer.getChannelData(0).set(floats);
|
|
56
|
+
this.queue.push(buffer);
|
|
57
|
+
this.isPlaying = true;
|
|
58
|
+
this.pipelineQueue();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getAudioLevel(): number {
|
|
62
|
+
const analyser = this.analyserNode;
|
|
63
|
+
const buf = this.analyserBuffer;
|
|
64
|
+
if (analyser && buf && this.activeSources.size > 0) {
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
|
+
analyser.getFloatTimeDomainData(buf as any);
|
|
67
|
+
let sumSq = 0;
|
|
68
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
69
|
+
const v = buf[i]!;
|
|
70
|
+
sumSq += v * v;
|
|
71
|
+
}
|
|
72
|
+
return Math.min(1, Math.sqrt(sumSq / buf.length) * LEVEL_SCALE);
|
|
73
|
+
}
|
|
74
|
+
return Date.now() - this.lastChunkAt < 500 ? this.lastChunkRms : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
clearQueue(): void {
|
|
78
|
+
for (const source of this.activeSources) {
|
|
79
|
+
try { source.stop(); } catch { /* noop */ }
|
|
80
|
+
}
|
|
81
|
+
this.activeSources.clear();
|
|
82
|
+
this.queue = [];
|
|
83
|
+
this.playHead = this.audioContext?.currentTime ?? 0;
|
|
84
|
+
this.lastChunkRms = 0;
|
|
85
|
+
this.lastChunkAt = 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cleanup(): void {
|
|
89
|
+
this.clearQueue();
|
|
90
|
+
this.isPlaying = false;
|
|
91
|
+
void this.audioContext?.close();
|
|
92
|
+
this.analyserNode = null;
|
|
93
|
+
this.analyserBuffer = null;
|
|
94
|
+
this.gainNode = null;
|
|
95
|
+
this.audioContext = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private ensureContext(): AudioContext {
|
|
99
|
+
if (!this.audioContext) {
|
|
100
|
+
this.audioContext = new AudioContext({ sampleRate: this.sampleRate });
|
|
101
|
+
this.gainNode = this.audioContext.createGain();
|
|
102
|
+
this.analyserNode = this.audioContext.createAnalyser();
|
|
103
|
+
this.analyserNode.fftSize = 512;
|
|
104
|
+
this.analyserBuffer = new Float32Array(this.analyserNode.fftSize);
|
|
105
|
+
this.gainNode.connect(this.analyserNode);
|
|
106
|
+
this.analyserNode.connect(this.audioContext.destination);
|
|
107
|
+
this.playHead = this.audioContext.currentTime;
|
|
108
|
+
}
|
|
109
|
+
return this.audioContext;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private pipelineQueue(): void {
|
|
113
|
+
const ctx = this.audioContext;
|
|
114
|
+
const gain = this.gainNode;
|
|
115
|
+
if (!ctx || !gain || !this.isPlaying) return;
|
|
116
|
+
while (this.queue.length > 0) {
|
|
117
|
+
const audioBuffer = this.queue.shift();
|
|
118
|
+
if (!audioBuffer) return;
|
|
119
|
+
const source = ctx.createBufferSource();
|
|
120
|
+
source.buffer = audioBuffer;
|
|
121
|
+
const perSourceGain = ctx.createGain();
|
|
122
|
+
source.connect(perSourceGain);
|
|
123
|
+
perSourceGain.connect(gain);
|
|
124
|
+
const now = ctx.currentTime;
|
|
125
|
+
const startAt = Math.max((this.playHead || now) - CROSSFADE_SECONDS * 0.75, now + LOOKAHEAD_SECONDS);
|
|
126
|
+
const duration = audioBuffer.duration;
|
|
127
|
+
const endAt = startAt + duration;
|
|
128
|
+
const fade = Math.min(CROSSFADE_SECONDS, duration / 2);
|
|
129
|
+
perSourceGain.gain.setValueAtTime(0, startAt);
|
|
130
|
+
perSourceGain.gain.linearRampToValueAtTime(1, startAt + fade);
|
|
131
|
+
perSourceGain.gain.setValueAtTime(1, endAt - fade);
|
|
132
|
+
perSourceGain.gain.linearRampToValueAtTime(0, endAt);
|
|
133
|
+
this.playHead = endAt;
|
|
134
|
+
this.activeSources.add(source);
|
|
135
|
+
source.start(startAt);
|
|
136
|
+
source.onended = () => this.activeSources.delete(source);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { VoiceSession } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
import { float32ToPcm16 } from './float32ToPcm16';
|
|
3
|
+
import { WS_REALTIME_SAMPLE_RATE } from './wsRealtimeConstants';
|
|
4
|
+
|
|
5
|
+
function computeRms(channel: Float32Array): number {
|
|
6
|
+
let sum = 0;
|
|
7
|
+
for (let i = 0; i < channel.length; i += 1) {
|
|
8
|
+
const v = channel[i]!;
|
|
9
|
+
sum += v * v;
|
|
10
|
+
}
|
|
11
|
+
return channel.length > 0 ? Math.sqrt(sum / channel.length) : 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** getUserMedia only on start() — Berify hold-to-talk aligned. */
|
|
15
|
+
export class BrowserWsPcmRecorder {
|
|
16
|
+
private session: VoiceSession | null = null;
|
|
17
|
+
private onRms?: (rms: number) => void;
|
|
18
|
+
private stream: MediaStream | null = null;
|
|
19
|
+
private audioContext: AudioContext | null = null;
|
|
20
|
+
private processor: ScriptProcessorNode | null = null;
|
|
21
|
+
private source: MediaStreamAudioSourceNode | null = null;
|
|
22
|
+
private silentGain: GainNode | null = null;
|
|
23
|
+
|
|
24
|
+
private shouldAppend?: () => boolean;
|
|
25
|
+
|
|
26
|
+
bindSession(session: VoiceSession | null): void {
|
|
27
|
+
this.session = session;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setShouldAppend(handler: () => boolean): void {
|
|
31
|
+
this.shouldAppend = handler;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setOnRms(handler: (rms: number) => void): void {
|
|
35
|
+
this.onRms = handler;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start(): Promise<void> {
|
|
39
|
+
if (this.processor) return;
|
|
40
|
+
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
41
|
+
const ctx = new AudioContext({ sampleRate: WS_REALTIME_SAMPLE_RATE });
|
|
42
|
+
this.audioContext = ctx;
|
|
43
|
+
this.source = ctx.createMediaStreamSource(this.stream);
|
|
44
|
+
this.processor = ctx.createScriptProcessor(4096, 1, 1);
|
|
45
|
+
this.silentGain = ctx.createGain();
|
|
46
|
+
this.silentGain.gain.value = 0;
|
|
47
|
+
this.source.connect(this.processor);
|
|
48
|
+
this.processor.connect(this.silentGain);
|
|
49
|
+
this.silentGain.connect(ctx.destination);
|
|
50
|
+
this.processor.onaudioprocess = (event) => {
|
|
51
|
+
if (!this.session?.appendInputAudio) return;
|
|
52
|
+
const channel = event.inputBuffer.getChannelData(0);
|
|
53
|
+
const rms = computeRms(channel);
|
|
54
|
+
this.onRms?.(rms);
|
|
55
|
+
// Berify: only stream PCM after RMS crosses speech threshold.
|
|
56
|
+
if (this.shouldAppend && !this.shouldAppend()) return;
|
|
57
|
+
this.session.appendInputAudio(float32ToPcm16(channel));
|
|
58
|
+
};
|
|
59
|
+
if (ctx.state === 'suspended') {
|
|
60
|
+
await ctx.resume();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop(): void {
|
|
65
|
+
this.processor?.disconnect();
|
|
66
|
+
this.source?.disconnect();
|
|
67
|
+
this.silentGain?.disconnect();
|
|
68
|
+
this.processor = null;
|
|
69
|
+
this.source = null;
|
|
70
|
+
this.silentGain = null;
|
|
71
|
+
for (const track of this.stream?.getTracks() ?? []) {
|
|
72
|
+
track.stop();
|
|
73
|
+
}
|
|
74
|
+
this.stream = null;
|
|
75
|
+
void this.audioContext?.close();
|
|
76
|
+
this.audioContext = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
cleanup(): void {
|
|
80
|
+
this.stop();
|
|
81
|
+
this.session = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function float32ToPcm16(input: Float32Array): Int16Array {
|
|
2
|
+
const out = new Int16Array(input.length);
|
|
3
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
4
|
+
const sample = Math.max(-1, Math.min(1, input[i]!));
|
|
5
|
+
out[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
|
6
|
+
}
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WS_REALTIME_SAMPLE_RATE = 24000;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AgentConfig } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
|
|
3
|
+
/** Browser SDK defaults — integrators do not pass these props. */
|
|
4
|
+
export const WEB_AGENT_DEFAULTS = {
|
|
5
|
+
voiceTransport: 'webrtc',
|
|
6
|
+
voiceMode: 'realtime',
|
|
7
|
+
defaultModel: 'open-ai-stream',
|
|
8
|
+
} as const satisfies Partial<AgentConfig>;
|
|
9
|
+
|
|
10
|
+
export function applyWebAgentDefaults<C extends AgentConfig>(config: C): C {
|
|
11
|
+
return { ...WEB_AGENT_DEFAULTS, ...config };
|
|
12
|
+
}
|