@runtypelabs/voice 0.2.4 → 0.4.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/README.md +149 -4
- package/dist/index.cjs +257 -25
- package/dist/index.d.cts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +257 -25
- package/dist/persona.cjs +319 -30
- package/dist/persona.d.cts +23 -4
- package/dist/persona.d.ts +23 -4
- package/dist/persona.js +319 -30
- package/dist/react.cjs +320 -32
- package/dist/react.d.cts +7 -4
- package/dist/react.d.ts +7 -4
- package/dist/react.js +321 -33
- package/dist/types-Dkh4LxEA.d.cts +100 -0
- package/dist/types-Dkh4LxEA.d.ts +100 -0
- package/package.json +1 -1
- package/dist/types-9SfA7oHc.d.cts +0 -40
- package/dist/types-9SfA7oHc.d.ts +0 -40
package/README.md
CHANGED
|
@@ -46,10 +46,19 @@ Do not pass a server API key. Tokens use the WebSocket subprotocol, never the UR
|
|
|
46
46
|
`clientToken` can also be a function returning a token or a promise; it is called
|
|
47
47
|
once per new call. `startCall(tokenOverride)` overrides that call's token.
|
|
48
48
|
|
|
49
|
-
The hook ends the call on unmount and when `agentId
|
|
49
|
+
The hook ends the call on unmount and when `agentId`, `apiUrl`, `artifacts`, or
|
|
50
|
+
`sessionId` selects a different session. Copying the session ID reported by
|
|
51
|
+
`onSession` back into the hook preserves the active call, including after session
|
|
52
|
+
renewal. The snapshot it spreads carries `artifacts` and `session`
|
|
53
|
+
alongside `transcript`.
|
|
50
54
|
Changing `clientToken` affects the next call without interrupting the current one.
|
|
51
55
|
Startup and connection failures populate `error` and `status: 'error'`.
|
|
52
56
|
|
|
57
|
+
GPT-Live requires the default full-duplex browser protocol. Set `fullDuplex: false`
|
|
58
|
+
only for a client that cannot handle overlapping transcripts. GPT-Live does not
|
|
59
|
+
resume shared text/voice sessions; a call with `sessionId` or `visitorToken`
|
|
60
|
+
receives `VOICE_SHARED_SESSION_UNSUPPORTED`.
|
|
61
|
+
|
|
53
62
|
## Plain JavaScript
|
|
54
63
|
|
|
55
64
|
```ts
|
|
@@ -81,6 +90,10 @@ The adapter implements Persona 4.22's `VoiceProvider` API and delivers transcrip
|
|
|
81
90
|
through `onTranscript`, avoiding duplicate text dispatches. Use it as the custom
|
|
82
91
|
provider in the widget's `config`:
|
|
83
92
|
|
|
93
|
+
Persona 4.22 cannot handle GPT-Live's overlapping transcripts. The adapter does
|
|
94
|
+
not advertise full duplex, so it cannot connect to a GPT-Live agent. Use the
|
|
95
|
+
plain `VoiceClient` or the React hook for GPT-Live calls.
|
|
96
|
+
|
|
84
97
|
```ts
|
|
85
98
|
import { createPersonaVoiceProvider } from '@runtypelabs/voice/persona'
|
|
86
99
|
|
|
@@ -95,6 +108,84 @@ const config = {
|
|
|
95
108
|
}
|
|
96
109
|
```
|
|
97
110
|
|
|
111
|
+
### Artifacts
|
|
112
|
+
|
|
113
|
+
Set `artifacts: true` to declare the capability. The client then connects with
|
|
114
|
+
`clientCapabilities=partial_transcript,artifacts`, the server sends an additive
|
|
115
|
+
`artifact` message for every artifact the agent produces, and the client
|
|
116
|
+
assembles them into `snapshot.artifacts`. Leaving it off keeps an existing
|
|
117
|
+
embed's wire byte-identical.
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"type": "artifact",
|
|
122
|
+
"turnId": "turn-3",
|
|
123
|
+
"executionId": "exec_123",
|
|
124
|
+
"event": {
|
|
125
|
+
"type": "artifact_start",
|
|
126
|
+
"id": "art_1",
|
|
127
|
+
"artifactType": "markdown",
|
|
128
|
+
"title": "game.html",
|
|
129
|
+
"file": { "path": "game.html", "mimeType": "text/html", "language": "html" }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`event` is a unified `artifact_start`, `artifact_delta`, `artifact_update`, or
|
|
135
|
+
`artifact_complete` frame. A start opens a `streaming` record, deltas append to
|
|
136
|
+
its `content`, an update carries the component payload, and a complete settles
|
|
137
|
+
it. Cancelling a turn drops its later artifacts. Playback-only Stop keeps
|
|
138
|
+
artifact delivery active. Ending the call keeps completed artifacts
|
|
139
|
+
and drops half-streamed ones. Artifacts are never spoken: file bodies arrive
|
|
140
|
+
only through these frames.
|
|
141
|
+
|
|
142
|
+
The Persona adapter adds `onArtifact`, which fires each time a record changes,
|
|
143
|
+
and `bindVoiceArtifactsToPersona` streams those records into an initialized
|
|
144
|
+
widget through its `upsertArtifact` handle:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { bindVoiceArtifactsToPersona, createPersonaVoiceProvider } from '@runtypelabs/voice/persona'
|
|
148
|
+
import { initAgentWidget } from '@runtypelabs/persona'
|
|
149
|
+
|
|
150
|
+
const provider = createPersonaVoiceProvider({ agentId, clientToken, artifacts: true })
|
|
151
|
+
const widget = initAgentWidget({
|
|
152
|
+
target: document.getElementById('chat')!,
|
|
153
|
+
config: {
|
|
154
|
+
...config,
|
|
155
|
+
features: { artifacts: { enabled: true } },
|
|
156
|
+
voiceRecognition: {
|
|
157
|
+
enabled: true,
|
|
158
|
+
provider: { type: 'custom', custom: () => provider },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
const release = bindVoiceArtifactsToPersona(provider, widget)
|
|
163
|
+
|
|
164
|
+
function dispose() {
|
|
165
|
+
release()
|
|
166
|
+
widget.destroy()
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The artifact id is the upsert id, so every frame updates the same record in
|
|
171
|
+
place, and only the settled record writes a transcript block. The pane opens
|
|
172
|
+
once on the first artifact; pass `{ showOnFirst: false }` to leave it closed.
|
|
173
|
+
Call `dispose()` when the host removes the widget.
|
|
174
|
+
Persona's `features.artifacts.enabled` must be on, because `showArtifacts()` is
|
|
175
|
+
a no-op otherwise. Call the returned function to stop delivery; the adapter's
|
|
176
|
+
`disconnect()` releases artifact callbacks along with the rest.
|
|
177
|
+
|
|
178
|
+
### Reusing a conversation
|
|
179
|
+
|
|
180
|
+
`snapshot.session` is `{ sessionId, conversationId }` once the server reports
|
|
181
|
+
them on `session_config`. It survives `endCall`, so the next `startCall` sends
|
|
182
|
+
the same `sessionId` and reattaches the same conversation. Pass the
|
|
183
|
+
`sessionId` option to name another client session instead, for example the one
|
|
184
|
+
a Persona chat widget already opened. Call `resetSession()` between calls when
|
|
185
|
+
the next caller should not inherit the conversation, as on a shared kiosk; it
|
|
186
|
+
is a no-op while a call is open. An older server omits both ids and `session`
|
|
187
|
+
stays null.
|
|
188
|
+
|
|
98
189
|
**Widget release prerequisite:** Persona 4.22.0's factory accepts this adapter,
|
|
99
190
|
but its built-in microphone controls incorrectly route custom providers to
|
|
100
191
|
browser dictation. Use a Persona release containing
|
|
@@ -132,9 +223,14 @@ Pending Blob conversions and stale player events are invalidated.
|
|
|
132
223
|
`audio_end` means synthesis completed, while `speaking` continues until playback drains.
|
|
133
224
|
|
|
134
225
|
Persona's explicit `stopPlayback()` and the client's matching method also stop
|
|
135
|
-
playback in `none` mode. They discard the current reply
|
|
136
|
-
without sending a mode-disallowed cancellation or
|
|
137
|
-
|
|
226
|
+
playback in `none` mode. They discard the current reply's audio and captions
|
|
227
|
+
locally through `audio_end` without sending a mode-disallowed cancellation or
|
|
228
|
+
ending the call. Stopping playback does not stop the agent's execution: in
|
|
229
|
+
`none` mode the turn keeps running and its artifacts (`snapshot.artifacts`,
|
|
230
|
+
`onArtifact`) keep arriving, whether they began before or after the stop.
|
|
231
|
+
Artifacts are dropped only for a turn that was actually cancelled (`cancel` /
|
|
232
|
+
`barge-in`), interrupted by the server (`audio_clear`), or cut off by hanging
|
|
233
|
+
up. Ordinary `cancelResponse()` still respects the agent's mode. Disconnecting the Persona
|
|
138
234
|
adapter releases its callbacks; register callbacks again when reusing it.
|
|
139
235
|
When a server supplies `turnId` on final transcripts, the adapter forwards it
|
|
140
236
|
as Persona's optional fourth callback argument `{ turnId }`. Untagged servers
|
|
@@ -151,3 +247,52 @@ pnpm --filter @runtypelabs/voice test
|
|
|
151
247
|
pnpm --filter @runtypelabs/voice typecheck
|
|
152
248
|
pnpm --filter @runtypelabs/voice build
|
|
153
249
|
```
|
|
250
|
+
|
|
251
|
+
## Share a conversation with text
|
|
252
|
+
|
|
253
|
+
Initialize `/v1/client/init` before starting voice, including when voice is the
|
|
254
|
+
first user interaction. Request `durableRecovery: true` for a token whose durable
|
|
255
|
+
turn policy is enabled. Keep the returned `visitor.token` in your host's visitor
|
|
256
|
+
store, scoped to that client token. The server returns this secret only when it
|
|
257
|
+
mints the visitor; later init responses do not replace the stored value.
|
|
258
|
+
|
|
259
|
+
Pass the current text session ID and visitor credential to the voice client or
|
|
260
|
+
Persona adapter. For example, after your host initializes the text session:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const voice = createPersonaVoiceProvider({
|
|
264
|
+
agentId,
|
|
265
|
+
clientToken,
|
|
266
|
+
get sessionId() {
|
|
267
|
+
return currentSessionId
|
|
268
|
+
},
|
|
269
|
+
visitorToken: () => visitorStore.get(clientToken),
|
|
270
|
+
onSession: (session) => {
|
|
271
|
+
currentSessionId = session.sessionId
|
|
272
|
+
currentConversationId = session.conversationId
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Use the returned session ID for subsequent text requests and send the same
|
|
278
|
+
credential in `X-Visitor-Token`. For Persona, use your integration's supported
|
|
279
|
+
session initialization and credential storage APIs. Do not read Persona's
|
|
280
|
+
internal local-storage keys. `onSession` reports IDs only; never log the visitor
|
|
281
|
+
secret. A configured getter that returns no credential fails before microphone
|
|
282
|
+
acquisition, so disable Start until initialization completes.
|
|
283
|
+
|
|
284
|
+
A valid visitor credential without a session ID creates a visitor-owned voice
|
|
285
|
+
conversation. An expired session ID can locate an existing conversation when
|
|
286
|
+
current visitor proof still authorizes it. An invalid explicit session, another
|
|
287
|
+
visitor's conversation, or a different agent returns an error instead of starting
|
|
288
|
+
a separate conversation. A legacy unowned record can be claimed only when its
|
|
289
|
+
stored initialization provenance matches the presented visitor. Old voice
|
|
290
|
+
records without that provenance require a fresh conversation; a public client
|
|
291
|
+
token does not authorize claiming them.
|
|
292
|
+
|
|
293
|
+
Both portable browser engines support this contract. ElevenLabs requires its
|
|
294
|
+
browser engine flag for shared sessions. Existing clients without visitor proof
|
|
295
|
+
retain their legacy new-call behavior. Visitor proof travels in WebSocket
|
|
296
|
+
headers, never URLs, and the selected protocol remains `runtype.bearer`.
|
|
297
|
+
Disconnecting still cancels the active voice turn; conversation reuse does not
|
|
298
|
+
resume unfinished voice work.
|
package/dist/index.cjs
CHANGED
|
@@ -35,13 +35,16 @@ class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
|
|
|
35
35
|
this.readOffset = 0
|
|
36
36
|
this.buffered = 0
|
|
37
37
|
this.waiting = true
|
|
38
|
-
// INVARIANT:
|
|
38
|
+
// INVARIANT: Pipeline replies need EOS; continuous sessions report queue drain without a provider turn boundary.
|
|
39
39
|
this.eosSeen = false
|
|
40
|
+
this.continuous = false
|
|
40
41
|
this.revision = 0
|
|
41
42
|
this.port.onmessage = (e) => {
|
|
42
43
|
const msg = e.data
|
|
43
44
|
this.revision = msg.revision
|
|
44
|
-
if (msg.type === '
|
|
45
|
+
if (msg.type === 'continuous') {
|
|
46
|
+
this.continuous = msg.enabled
|
|
47
|
+
} else if (msg.type === 'push') {
|
|
45
48
|
this.eosSeen = false
|
|
46
49
|
this.chunks.push(msg.samples)
|
|
47
50
|
this.buffered += msg.samples.length
|
|
@@ -81,7 +84,7 @@ class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
|
|
|
81
84
|
}
|
|
82
85
|
if (this.buffered === 0) {
|
|
83
86
|
this.waiting = true // mid-reply underrun: re-buffer silently
|
|
84
|
-
if (this.eosSeen) {
|
|
87
|
+
if (this.eosSeen || this.continuous) {
|
|
85
88
|
this.eosSeen = false
|
|
86
89
|
this.port.postMessage({ type: 'drained', revision: this.revision })
|
|
87
90
|
}
|
|
@@ -125,6 +128,9 @@ async function createPcmPlayer(onDrained) {
|
|
|
125
128
|
if (samples.length === 0) return;
|
|
126
129
|
node.port.postMessage({ type: "push", samples, revision }, [samples.buffer]);
|
|
127
130
|
},
|
|
131
|
+
setContinuousMode(enabled) {
|
|
132
|
+
node.port.postMessage({ type: "continuous", enabled, revision });
|
|
133
|
+
},
|
|
128
134
|
endOfStream() {
|
|
129
135
|
node.port.postMessage({ type: "eos", revision });
|
|
130
136
|
},
|
|
@@ -169,9 +175,58 @@ function initialSnapshot() {
|
|
|
169
175
|
error: null,
|
|
170
176
|
errorDetails: void 0,
|
|
171
177
|
interruptionMode: "none",
|
|
172
|
-
canCancel: false
|
|
178
|
+
canCancel: false,
|
|
179
|
+
artifacts: [],
|
|
180
|
+
session: null
|
|
173
181
|
};
|
|
174
182
|
}
|
|
183
|
+
function readString(value) {
|
|
184
|
+
return typeof value === "string" && value ? value : void 0;
|
|
185
|
+
}
|
|
186
|
+
function readArtifactFile(value) {
|
|
187
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
188
|
+
const file = value;
|
|
189
|
+
const path = readString(file.path);
|
|
190
|
+
const mimeType = readString(file.mimeType);
|
|
191
|
+
if (!path || !mimeType) return void 0;
|
|
192
|
+
const language = readString(file.language);
|
|
193
|
+
return { path, mimeType, ...language ? { language } : {} };
|
|
194
|
+
}
|
|
195
|
+
function applyArtifactFrame(existing, frame, identity) {
|
|
196
|
+
if (frame.type === "artifact_start") {
|
|
197
|
+
const title = readString(frame.title);
|
|
198
|
+
const component = readString(frame.component);
|
|
199
|
+
const file = readArtifactFile(frame.file);
|
|
200
|
+
return {
|
|
201
|
+
...identity,
|
|
202
|
+
artifactType: frame.artifactType === "component" ? "component" : "markdown",
|
|
203
|
+
...title ? { title } : {},
|
|
204
|
+
...file ? { file } : {},
|
|
205
|
+
...component ? { component } : {},
|
|
206
|
+
content: "",
|
|
207
|
+
status: "streaming"
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!existing) return void 0;
|
|
211
|
+
if (frame.type === "artifact_delta") {
|
|
212
|
+
const delta = typeof frame.delta === "string" ? frame.delta : "";
|
|
213
|
+
return delta ? { ...existing, content: existing.content + delta } : void 0;
|
|
214
|
+
}
|
|
215
|
+
if (frame.type === "artifact_update") {
|
|
216
|
+
const component = readString(frame.component) ?? existing.component;
|
|
217
|
+
const props = frame.props && typeof frame.props === "object" && !Array.isArray(frame.props) ? frame.props : existing.props;
|
|
218
|
+
return {
|
|
219
|
+
...existing,
|
|
220
|
+
...component ? { component } : {},
|
|
221
|
+
...props ? { props } : {}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (frame.type === "artifact_complete") return { ...existing, status: "complete" };
|
|
225
|
+
return void 0;
|
|
226
|
+
}
|
|
227
|
+
function readTurnId(msg) {
|
|
228
|
+
return typeof msg.turnId === "string" && msg.turnId ? msg.turnId : null;
|
|
229
|
+
}
|
|
175
230
|
var VoiceClient = class {
|
|
176
231
|
constructor(options) {
|
|
177
232
|
this.options = options;
|
|
@@ -192,6 +247,17 @@ var VoiceClient = class {
|
|
|
192
247
|
stoppedResponse = false;
|
|
193
248
|
responseEnded = true;
|
|
194
249
|
hasPendingAudio = false;
|
|
250
|
+
/**
|
|
251
|
+
* The assistant entry built from clause captions for the turn being spoken. The
|
|
252
|
+
* authoritative transcript replaces it; a stop commits it, because it captions
|
|
253
|
+
* audio the caller already heard.
|
|
254
|
+
*/
|
|
255
|
+
pendingAssistant = null;
|
|
256
|
+
/** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
|
|
257
|
+
committedAssistantTurnId = null;
|
|
258
|
+
activeTurnId = null;
|
|
259
|
+
suppressedArtifactTurnId = null;
|
|
260
|
+
fullDuplex = false;
|
|
195
261
|
getSnapshot = () => this.snapshot;
|
|
196
262
|
subscribe = (listener) => {
|
|
197
263
|
this.listeners.add(listener);
|
|
@@ -213,7 +279,7 @@ var VoiceClient = class {
|
|
|
213
279
|
if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
|
|
214
280
|
this.cleanup();
|
|
215
281
|
const generation = this.generation;
|
|
216
|
-
this.update({ ...initialSnapshot(), status: "connecting" });
|
|
282
|
+
this.update({ ...initialSnapshot(), session: this.snapshot.session, status: "connecting" });
|
|
217
283
|
try {
|
|
218
284
|
const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
|
|
219
285
|
if (generation !== this.generation) return;
|
|
@@ -228,10 +294,26 @@ var VoiceClient = class {
|
|
|
228
294
|
url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
|
|
229
295
|
url.search = "";
|
|
230
296
|
url.searchParams.set("voiceProtocol", "runtype-browser-v1");
|
|
297
|
+
const capabilities = this.options.artifacts ? "partial_transcript,artifacts" : "partial_transcript";
|
|
298
|
+
url.searchParams.set("clientCapabilities", capabilities);
|
|
299
|
+
const sessionId = this.options.sessionId ?? this.snapshot.session?.sessionId;
|
|
300
|
+
if (sessionId) url.searchParams.set("sessionId", sessionId);
|
|
301
|
+
const visitorToken = typeof this.options.visitorToken === "function" ? await this.options.visitorToken() : this.options.visitorToken;
|
|
302
|
+
if (generation !== this.generation) return;
|
|
303
|
+
if (this.options.visitorToken !== void 0 && !visitorToken) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
"Voice visitor credential unavailable. Initialize the client session first."
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (visitorToken && !/^cvt_[A-Za-z0-9_-]+$/.test(visitorToken)) {
|
|
309
|
+
throw new Error("Invalid voice visitor credential.");
|
|
310
|
+
}
|
|
311
|
+
if (this.options.fullDuplex !== false)
|
|
312
|
+
url.searchParams.set("voiceCapabilities", "full-duplex-v1");
|
|
231
313
|
url.hash = "";
|
|
232
|
-
const stream = await navigator.mediaDevices.getUserMedia({
|
|
314
|
+
const stream = await (this.options.audioSource?.() ?? navigator.mediaDevices.getUserMedia({
|
|
233
315
|
audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
|
|
234
|
-
});
|
|
316
|
+
}));
|
|
235
317
|
if (generation !== this.generation) {
|
|
236
318
|
stream.getTracks().forEach((track) => track.stop());
|
|
237
319
|
return;
|
|
@@ -251,7 +333,11 @@ var VoiceClient = class {
|
|
|
251
333
|
return;
|
|
252
334
|
}
|
|
253
335
|
this.player = player;
|
|
254
|
-
const socket = new WebSocket(url.toString(), [
|
|
336
|
+
const socket = new WebSocket(url.toString(), [
|
|
337
|
+
"runtype.bearer",
|
|
338
|
+
token,
|
|
339
|
+
...visitorToken ? [visitorToken] : []
|
|
340
|
+
]);
|
|
255
341
|
socket.binaryType = "arraybuffer";
|
|
256
342
|
this.socket = socket;
|
|
257
343
|
socket.onopen = () => {
|
|
@@ -282,6 +368,11 @@ var VoiceClient = class {
|
|
|
282
368
|
this.cleanup();
|
|
283
369
|
this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
|
|
284
370
|
};
|
|
371
|
+
/** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
|
|
372
|
+
resetSession = () => {
|
|
373
|
+
if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
|
|
374
|
+
this.update({ session: null });
|
|
375
|
+
};
|
|
285
376
|
toggleMute = () => {
|
|
286
377
|
this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
|
|
287
378
|
};
|
|
@@ -296,9 +387,11 @@ var VoiceClient = class {
|
|
|
296
387
|
this.clearPlayback();
|
|
297
388
|
if (this.snapshot.interruptionMode === "none") {
|
|
298
389
|
this.stoppedResponse = !this.responseEnded;
|
|
390
|
+
this.commitClauseCaptions();
|
|
299
391
|
this.setStatus("listening");
|
|
300
392
|
return;
|
|
301
393
|
}
|
|
394
|
+
this.suppressedArtifactTurnId = this.activeTurnId;
|
|
302
395
|
this.awaitingClear = true;
|
|
303
396
|
this.setStatus("listening");
|
|
304
397
|
this.socket.send(JSON.stringify({ type: "cancel" }));
|
|
@@ -315,6 +408,7 @@ var VoiceClient = class {
|
|
|
315
408
|
});
|
|
316
409
|
}
|
|
317
410
|
cleanup() {
|
|
411
|
+
this.fullDuplex = false;
|
|
318
412
|
this.generation += 1;
|
|
319
413
|
if (this.processor) this.processor.onaudioprocess = null;
|
|
320
414
|
this.processor?.disconnect();
|
|
@@ -340,6 +434,82 @@ var VoiceClient = class {
|
|
|
340
434
|
this.awaitingClear = false;
|
|
341
435
|
this.stoppedResponse = false;
|
|
342
436
|
this.responseEnded = true;
|
|
437
|
+
this.commitClauseCaptions();
|
|
438
|
+
this.committedAssistantTurnId = null;
|
|
439
|
+
this.activeTurnId = null;
|
|
440
|
+
this.suppressedArtifactTurnId = null;
|
|
441
|
+
const settledArtifacts = this.snapshot.artifacts.filter(
|
|
442
|
+
(artifact) => artifact.status === "complete"
|
|
443
|
+
);
|
|
444
|
+
if (settledArtifacts.length !== this.snapshot.artifacts.length)
|
|
445
|
+
this.update({ artifacts: settledArtifacts });
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Folds an `artifact` message into the snapshot. Frames of a turn the caller
|
|
449
|
+
* cancelled are dropped; a playback-only stop leaves the turn running, so its
|
|
450
|
+
* artifacts keep arriving and are kept.
|
|
451
|
+
*/
|
|
452
|
+
applyArtifactMessage(msg) {
|
|
453
|
+
if (this.awaitingClear) return;
|
|
454
|
+
const frame = msg.event;
|
|
455
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame)) return;
|
|
456
|
+
const event = frame;
|
|
457
|
+
const id = readString(event.id);
|
|
458
|
+
if (!id) return;
|
|
459
|
+
const turnId = readTurnId(msg);
|
|
460
|
+
if (turnId !== null && turnId === this.suppressedArtifactTurnId) return;
|
|
461
|
+
if (turnId !== null) this.activeTurnId = turnId;
|
|
462
|
+
const artifacts = this.snapshot.artifacts;
|
|
463
|
+
const index = artifacts.findIndex((artifact) => artifact.id === id);
|
|
464
|
+
const executionId = readString(msg.executionId);
|
|
465
|
+
const next = applyArtifactFrame(index === -1 ? void 0 : artifacts[index], event, {
|
|
466
|
+
id,
|
|
467
|
+
turnId,
|
|
468
|
+
...executionId ? { executionId } : {}
|
|
469
|
+
});
|
|
470
|
+
if (!next) return;
|
|
471
|
+
this.update({
|
|
472
|
+
artifacts: index === -1 ? [...artifacts, next] : [...artifacts.slice(0, index), next, ...artifacts.slice(index + 1)]
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/** Settles the spoken clauses in place and ends the turn's claim on them. */
|
|
476
|
+
commitClauseCaptions() {
|
|
477
|
+
this.committedAssistantTurnId = this.pendingAssistant?.turnId ?? null;
|
|
478
|
+
const settled = this.pendingAssistant !== null;
|
|
479
|
+
this.pendingAssistant = null;
|
|
480
|
+
if (!settled) return;
|
|
481
|
+
const transcript = this.snapshot.transcript;
|
|
482
|
+
const last = transcript[transcript.length - 1];
|
|
483
|
+
if (last?.role !== "assistant" || last.partial !== true) return;
|
|
484
|
+
const { partial: _partial, ...committed } = last;
|
|
485
|
+
this.update({ transcript: [...transcript.slice(0, -1), committed] });
|
|
486
|
+
}
|
|
487
|
+
/** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
|
|
488
|
+
appendClauseCaption(text, turnId) {
|
|
489
|
+
const transcript = this.snapshot.transcript;
|
|
490
|
+
const last = transcript[transcript.length - 1];
|
|
491
|
+
if (this.pendingAssistant?.turnId === turnId && last?.role === "assistant") {
|
|
492
|
+
return {
|
|
493
|
+
interimTranscript: null,
|
|
494
|
+
transcript: [...transcript.slice(0, -1), { ...last, content: last.content + text }],
|
|
495
|
+
status: "speaking"
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
this.pendingAssistant = { turnId };
|
|
499
|
+
return {
|
|
500
|
+
interimTranscript: null,
|
|
501
|
+
transcript: [
|
|
502
|
+
...transcript,
|
|
503
|
+
{
|
|
504
|
+
role: "assistant",
|
|
505
|
+
content: text,
|
|
506
|
+
timestamp: Date.now(),
|
|
507
|
+
partial: true,
|
|
508
|
+
...turnId ? { turnId } : {}
|
|
509
|
+
}
|
|
510
|
+
],
|
|
511
|
+
status: "speaking"
|
|
512
|
+
};
|
|
343
513
|
}
|
|
344
514
|
clearPlayback() {
|
|
345
515
|
this.playbackRevision += 1;
|
|
@@ -374,32 +544,89 @@ var VoiceClient = class {
|
|
|
374
544
|
return;
|
|
375
545
|
}
|
|
376
546
|
switch (msg.type) {
|
|
377
|
-
case "session_config":
|
|
547
|
+
case "session_config": {
|
|
548
|
+
this.fullDuplex = msg.speechMode === "speech_to_speech";
|
|
549
|
+
if (this.fullDuplex) player.setContinuousMode?.(true);
|
|
378
550
|
if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
|
|
379
551
|
this.update({ interruptionMode: msg.interruptionMode });
|
|
380
552
|
}
|
|
553
|
+
const sessionId = readString(msg.sessionId);
|
|
554
|
+
const conversationId = readString(msg.conversationId);
|
|
555
|
+
if (sessionId && conversationId) {
|
|
556
|
+
const session = { sessionId, conversationId };
|
|
557
|
+
this.update({ session });
|
|
558
|
+
this.options.onSession?.(session);
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
case "artifact":
|
|
563
|
+
this.applyArtifactMessage(msg);
|
|
564
|
+
break;
|
|
565
|
+
case "transcript_update": {
|
|
566
|
+
if (typeof msg.text !== "string" || typeof msg.turnId !== "string" || msg.role !== "user" && msg.role !== "assistant")
|
|
567
|
+
break;
|
|
568
|
+
if (msg.role === "assistant" && this.awaitingClear) break;
|
|
569
|
+
const entries = [...this.snapshot.transcript];
|
|
570
|
+
const index = entries.findIndex(
|
|
571
|
+
(entry2) => entry2.turnId === msg.turnId && entry2.role === msg.role
|
|
572
|
+
);
|
|
573
|
+
const entry = {
|
|
574
|
+
role: msg.role,
|
|
575
|
+
content: msg.text,
|
|
576
|
+
turnId: msg.turnId,
|
|
577
|
+
isFinal: msg.final === true,
|
|
578
|
+
timestamp: index >= 0 ? entries[index].timestamp : Date.now()
|
|
579
|
+
};
|
|
580
|
+
if (index >= 0) entries[index] = entry;
|
|
581
|
+
else entries.push(entry);
|
|
582
|
+
this.update({ transcript: entries, interimTranscript: null });
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
case "delegation_started":
|
|
586
|
+
if (!this.hasPendingAudio && !this.awaitingClear) this.setStatus("thinking");
|
|
587
|
+
break;
|
|
588
|
+
case "delegation_completed":
|
|
589
|
+
if (!this.hasPendingAudio && !this.awaitingClear) this.setStatus("listening");
|
|
381
590
|
break;
|
|
382
591
|
case "transcript_interim":
|
|
383
592
|
this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
|
|
384
593
|
break;
|
|
385
|
-
case "
|
|
594
|
+
case "transcript_partial": {
|
|
595
|
+
if (msg.role !== "assistant" || typeof msg.text !== "string" || !msg.text) break;
|
|
596
|
+
this.activeTurnId = readTurnId(msg) ?? this.activeTurnId;
|
|
597
|
+
if (this.awaitingClear || this.stoppedResponse) break;
|
|
598
|
+
this.responseEnded = false;
|
|
599
|
+
this.update(this.appendClauseCaption(msg.text, readTurnId(msg)));
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
case "transcript_final": {
|
|
386
603
|
if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
|
|
387
|
-
|
|
604
|
+
const role = msg.role;
|
|
605
|
+
const text = msg.text;
|
|
606
|
+
if (role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
|
|
607
|
+
const turnId = readTurnId(msg);
|
|
608
|
+
if (turnId !== null) this.activeTurnId = turnId;
|
|
609
|
+
if (role === "assistant" && turnId !== null && turnId === this.committedAssistantTurnId)
|
|
610
|
+
break;
|
|
388
611
|
this.responseEnded = false;
|
|
612
|
+
const transcript = this.snapshot.transcript;
|
|
613
|
+
const last = transcript[transcript.length - 1];
|
|
614
|
+
const supersedes = role === "assistant" && this.pendingAssistant !== null && last?.role === "assistant" && (turnId === null || this.pendingAssistant.turnId === null || this.pendingAssistant.turnId === turnId);
|
|
615
|
+
this.pendingAssistant = null;
|
|
616
|
+
const entry = {
|
|
617
|
+
role,
|
|
618
|
+
content: text,
|
|
619
|
+
// INVARIANT: reuse the caption's timestamp so a keyed list does not remount the bubble.
|
|
620
|
+
timestamp: supersedes && last ? last.timestamp : Date.now(),
|
|
621
|
+
...turnId ? { turnId } : {}
|
|
622
|
+
};
|
|
389
623
|
this.update({
|
|
390
624
|
interimTranscript: null,
|
|
391
|
-
transcript: [
|
|
392
|
-
|
|
393
|
-
{
|
|
394
|
-
role: msg.role,
|
|
395
|
-
content: msg.text,
|
|
396
|
-
timestamp: Date.now(),
|
|
397
|
-
...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
|
|
398
|
-
}
|
|
399
|
-
],
|
|
400
|
-
status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
|
|
625
|
+
transcript: supersedes ? [...transcript.slice(0, -1), entry] : [...transcript, entry],
|
|
626
|
+
status: this.awaitingClear ? this.snapshot.status : role === "user" ? "thinking" : "speaking"
|
|
401
627
|
});
|
|
402
628
|
break;
|
|
629
|
+
}
|
|
403
630
|
case "audio_end": {
|
|
404
631
|
this.responseEnded = true;
|
|
405
632
|
if (this.stoppedResponse) {
|
|
@@ -416,10 +643,12 @@ var VoiceClient = class {
|
|
|
416
643
|
break;
|
|
417
644
|
}
|
|
418
645
|
case "audio_clear":
|
|
646
|
+
this.suppressedArtifactTurnId = this.activeTurnId ?? this.suppressedArtifactTurnId;
|
|
419
647
|
this.clearPlayback();
|
|
420
648
|
this.awaitingClear = false;
|
|
421
649
|
this.stoppedResponse = false;
|
|
422
650
|
this.responseEnded = true;
|
|
651
|
+
this.commitClauseCaptions();
|
|
423
652
|
this.setStatus("listening");
|
|
424
653
|
break;
|
|
425
654
|
case "metrics": {
|
|
@@ -432,8 +661,11 @@ var VoiceClient = class {
|
|
|
432
661
|
// @snake-case-ok: Existing voice wire contract.
|
|
433
662
|
firstAudioMs: number(msg.first_audio_ms),
|
|
434
663
|
// @snake-case-ok: Existing voice wire contract.
|
|
435
|
-
totalMs: number(msg.total_ms)
|
|
664
|
+
totalMs: number(msg.total_ms),
|
|
665
|
+
// @snake-case-ok: Existing voice wire contract.
|
|
666
|
+
firstSynthesisMs: number(msg.first_synthesis_ms),
|
|
436
667
|
// @snake-case-ok: Existing voice wire contract.
|
|
668
|
+
incremental: msg.incremental === true
|
|
437
669
|
}
|
|
438
670
|
});
|
|
439
671
|
break;
|
|
@@ -465,14 +697,14 @@ var VoiceClient = class {
|
|
|
465
697
|
const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
|
|
466
698
|
this.processor = processor;
|
|
467
699
|
processor.onaudioprocess = (event) => {
|
|
468
|
-
if (generation !== this.generation || this.snapshot.isMuted) return;
|
|
700
|
+
if (generation !== this.generation || this.snapshot.isMuted && !this.fullDuplex) return;
|
|
469
701
|
const input = event.inputBuffer.getChannelData(0);
|
|
470
702
|
let sum = 0;
|
|
471
703
|
for (const sample of input) sum += sample * sample;
|
|
472
|
-
this.update({ audioLevel: Math.sqrt(sum / input.length) });
|
|
704
|
+
this.update({ audioLevel: this.snapshot.isMuted ? 0 : Math.sqrt(sum / input.length) });
|
|
473
705
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
474
706
|
const pcm = new Int16Array(input.length);
|
|
475
|
-
if (this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
|
|
707
|
+
if (this.snapshot.isMuted || this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
|
|
476
708
|
socket.send(pcm.buffer);
|
|
477
709
|
return;
|
|
478
710
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { V as VoiceClientOptions, a as VoiceSnapshot } from './types-
|
|
2
|
-
export { I as InterruptionMode, T as TranscriptEntry, b as
|
|
1
|
+
import { V as VoiceClientOptions, a as VoiceSnapshot } from './types-Dkh4LxEA.cjs';
|
|
2
|
+
export { I as InterruptionMode, T as TranscriptEntry, b as VoiceArtifact, c as VoiceArtifactFile, d as VoiceMetrics, e as VoiceSession, f as VoiceStatus } from './types-Dkh4LxEA.cjs';
|
|
3
3
|
|
|
4
4
|
/** Browser microphone and playback lifecycle for Runtype's voice WebSocket endpoint. */
|
|
5
5
|
declare class VoiceClient {
|
|
@@ -19,6 +19,17 @@ declare class VoiceClient {
|
|
|
19
19
|
private stoppedResponse;
|
|
20
20
|
private responseEnded;
|
|
21
21
|
private hasPendingAudio;
|
|
22
|
+
/**
|
|
23
|
+
* The assistant entry built from clause captions for the turn being spoken. The
|
|
24
|
+
* authoritative transcript replaces it; a stop commits it, because it captions
|
|
25
|
+
* audio the caller already heard.
|
|
26
|
+
*/
|
|
27
|
+
private pendingAssistant;
|
|
28
|
+
/** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
|
|
29
|
+
private committedAssistantTurnId;
|
|
30
|
+
private activeTurnId;
|
|
31
|
+
private suppressedArtifactTurnId;
|
|
32
|
+
private fullDuplex;
|
|
22
33
|
constructor(options: VoiceClientOptions);
|
|
23
34
|
getSnapshot: () => VoiceSnapshot;
|
|
24
35
|
subscribe: (listener: () => void) => (() => void);
|
|
@@ -27,12 +38,24 @@ declare class VoiceClient {
|
|
|
27
38
|
/** Acquire microphone access and open a call. Invoke from a user gesture. Failures populate snapshot.error. */
|
|
28
39
|
startCall: (tokenOverride?: string) => Promise<void>;
|
|
29
40
|
endCall: () => void;
|
|
41
|
+
/** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
|
|
42
|
+
resetSession: () => void;
|
|
30
43
|
toggleMute: () => void;
|
|
31
44
|
cancelResponse: () => void;
|
|
32
45
|
/** Stop the current reply explicitly, including local playback when interruptions are disabled. */
|
|
33
46
|
stopPlayback: () => void;
|
|
34
47
|
private fail;
|
|
35
48
|
private cleanup;
|
|
49
|
+
/**
|
|
50
|
+
* Folds an `artifact` message into the snapshot. Frames of a turn the caller
|
|
51
|
+
* cancelled are dropped; a playback-only stop leaves the turn running, so its
|
|
52
|
+
* artifacts keep arriving and are kept.
|
|
53
|
+
*/
|
|
54
|
+
private applyArtifactMessage;
|
|
55
|
+
/** Settles the spoken clauses in place and ends the turn's claim on them. */
|
|
56
|
+
private commitClauseCaptions;
|
|
57
|
+
/** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
|
|
58
|
+
private appendClauseCaption;
|
|
36
59
|
private clearPlayback;
|
|
37
60
|
private handleMessage;
|
|
38
61
|
private startCapture;
|