@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 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` or `apiUrl` changes.
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 locally through `audio_end`
136
- without sending a mode-disallowed cancellation or ending the call. Ordinary
137
- `cancelResponse()` still respects the agent's mode. Disconnecting the Persona
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: Only report drained after end-of-stream, never during a jitter gap.
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 === 'push') {
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(), ["runtype.bearer", token]);
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 "transcript_final":
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
- if (msg.role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
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
- ...this.snapshot.transcript,
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-9SfA7oHc.cjs';
2
- export { I as InterruptionMode, T as TranscriptEntry, b as VoiceMetrics, c as VoiceStatus } from './types-9SfA7oHc.cjs';
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;