@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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { V as VoiceClientOptions, a as VoiceSnapshot } from './types-9SfA7oHc.js';
2
- export { I as InterruptionMode, T as TranscriptEntry, b as VoiceMetrics, c as VoiceStatus } from './types-9SfA7oHc.js';
1
+ import { V as VoiceClientOptions, a as VoiceSnapshot } from './types-Dkh4LxEA.js';
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.js';
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;
package/dist/index.js CHANGED
@@ -9,13 +9,16 @@ class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
9
9
  this.readOffset = 0
10
10
  this.buffered = 0
11
11
  this.waiting = true
12
- // INVARIANT: Only report drained after end-of-stream, never during a jitter gap.
12
+ // INVARIANT: Pipeline replies need EOS; continuous sessions report queue drain without a provider turn boundary.
13
13
  this.eosSeen = false
14
+ this.continuous = false
14
15
  this.revision = 0
15
16
  this.port.onmessage = (e) => {
16
17
  const msg = e.data
17
18
  this.revision = msg.revision
18
- if (msg.type === 'push') {
19
+ if (msg.type === 'continuous') {
20
+ this.continuous = msg.enabled
21
+ } else if (msg.type === 'push') {
19
22
  this.eosSeen = false
20
23
  this.chunks.push(msg.samples)
21
24
  this.buffered += msg.samples.length
@@ -55,7 +58,7 @@ class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
55
58
  }
56
59
  if (this.buffered === 0) {
57
60
  this.waiting = true // mid-reply underrun: re-buffer silently
58
- if (this.eosSeen) {
61
+ if (this.eosSeen || this.continuous) {
59
62
  this.eosSeen = false
60
63
  this.port.postMessage({ type: 'drained', revision: this.revision })
61
64
  }
@@ -99,6 +102,9 @@ async function createPcmPlayer(onDrained) {
99
102
  if (samples.length === 0) return;
100
103
  node.port.postMessage({ type: "push", samples, revision }, [samples.buffer]);
101
104
  },
105
+ setContinuousMode(enabled) {
106
+ node.port.postMessage({ type: "continuous", enabled, revision });
107
+ },
102
108
  endOfStream() {
103
109
  node.port.postMessage({ type: "eos", revision });
104
110
  },
@@ -143,9 +149,58 @@ function initialSnapshot() {
143
149
  error: null,
144
150
  errorDetails: void 0,
145
151
  interruptionMode: "none",
146
- canCancel: false
152
+ canCancel: false,
153
+ artifacts: [],
154
+ session: null
147
155
  };
148
156
  }
157
+ function readString(value) {
158
+ return typeof value === "string" && value ? value : void 0;
159
+ }
160
+ function readArtifactFile(value) {
161
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
162
+ const file = value;
163
+ const path = readString(file.path);
164
+ const mimeType = readString(file.mimeType);
165
+ if (!path || !mimeType) return void 0;
166
+ const language = readString(file.language);
167
+ return { path, mimeType, ...language ? { language } : {} };
168
+ }
169
+ function applyArtifactFrame(existing, frame, identity) {
170
+ if (frame.type === "artifact_start") {
171
+ const title = readString(frame.title);
172
+ const component = readString(frame.component);
173
+ const file = readArtifactFile(frame.file);
174
+ return {
175
+ ...identity,
176
+ artifactType: frame.artifactType === "component" ? "component" : "markdown",
177
+ ...title ? { title } : {},
178
+ ...file ? { file } : {},
179
+ ...component ? { component } : {},
180
+ content: "",
181
+ status: "streaming"
182
+ };
183
+ }
184
+ if (!existing) return void 0;
185
+ if (frame.type === "artifact_delta") {
186
+ const delta = typeof frame.delta === "string" ? frame.delta : "";
187
+ return delta ? { ...existing, content: existing.content + delta } : void 0;
188
+ }
189
+ if (frame.type === "artifact_update") {
190
+ const component = readString(frame.component) ?? existing.component;
191
+ const props = frame.props && typeof frame.props === "object" && !Array.isArray(frame.props) ? frame.props : existing.props;
192
+ return {
193
+ ...existing,
194
+ ...component ? { component } : {},
195
+ ...props ? { props } : {}
196
+ };
197
+ }
198
+ if (frame.type === "artifact_complete") return { ...existing, status: "complete" };
199
+ return void 0;
200
+ }
201
+ function readTurnId(msg) {
202
+ return typeof msg.turnId === "string" && msg.turnId ? msg.turnId : null;
203
+ }
149
204
  var VoiceClient = class {
150
205
  constructor(options) {
151
206
  this.options = options;
@@ -166,6 +221,17 @@ var VoiceClient = class {
166
221
  stoppedResponse = false;
167
222
  responseEnded = true;
168
223
  hasPendingAudio = false;
224
+ /**
225
+ * The assistant entry built from clause captions for the turn being spoken. The
226
+ * authoritative transcript replaces it; a stop commits it, because it captions
227
+ * audio the caller already heard.
228
+ */
229
+ pendingAssistant = null;
230
+ /** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
231
+ committedAssistantTurnId = null;
232
+ activeTurnId = null;
233
+ suppressedArtifactTurnId = null;
234
+ fullDuplex = false;
169
235
  getSnapshot = () => this.snapshot;
170
236
  subscribe = (listener) => {
171
237
  this.listeners.add(listener);
@@ -187,7 +253,7 @@ var VoiceClient = class {
187
253
  if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
188
254
  this.cleanup();
189
255
  const generation = this.generation;
190
- this.update({ ...initialSnapshot(), status: "connecting" });
256
+ this.update({ ...initialSnapshot(), session: this.snapshot.session, status: "connecting" });
191
257
  try {
192
258
  const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
193
259
  if (generation !== this.generation) return;
@@ -202,10 +268,26 @@ var VoiceClient = class {
202
268
  url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
203
269
  url.search = "";
204
270
  url.searchParams.set("voiceProtocol", "runtype-browser-v1");
271
+ const capabilities = this.options.artifacts ? "partial_transcript,artifacts" : "partial_transcript";
272
+ url.searchParams.set("clientCapabilities", capabilities);
273
+ const sessionId = this.options.sessionId ?? this.snapshot.session?.sessionId;
274
+ if (sessionId) url.searchParams.set("sessionId", sessionId);
275
+ const visitorToken = typeof this.options.visitorToken === "function" ? await this.options.visitorToken() : this.options.visitorToken;
276
+ if (generation !== this.generation) return;
277
+ if (this.options.visitorToken !== void 0 && !visitorToken) {
278
+ throw new Error(
279
+ "Voice visitor credential unavailable. Initialize the client session first."
280
+ );
281
+ }
282
+ if (visitorToken && !/^cvt_[A-Za-z0-9_-]+$/.test(visitorToken)) {
283
+ throw new Error("Invalid voice visitor credential.");
284
+ }
285
+ if (this.options.fullDuplex !== false)
286
+ url.searchParams.set("voiceCapabilities", "full-duplex-v1");
205
287
  url.hash = "";
206
- const stream = await navigator.mediaDevices.getUserMedia({
288
+ const stream = await (this.options.audioSource?.() ?? navigator.mediaDevices.getUserMedia({
207
289
  audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
208
- });
290
+ }));
209
291
  if (generation !== this.generation) {
210
292
  stream.getTracks().forEach((track) => track.stop());
211
293
  return;
@@ -225,7 +307,11 @@ var VoiceClient = class {
225
307
  return;
226
308
  }
227
309
  this.player = player;
228
- const socket = new WebSocket(url.toString(), ["runtype.bearer", token]);
310
+ const socket = new WebSocket(url.toString(), [
311
+ "runtype.bearer",
312
+ token,
313
+ ...visitorToken ? [visitorToken] : []
314
+ ]);
229
315
  socket.binaryType = "arraybuffer";
230
316
  this.socket = socket;
231
317
  socket.onopen = () => {
@@ -256,6 +342,11 @@ var VoiceClient = class {
256
342
  this.cleanup();
257
343
  this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
258
344
  };
345
+ /** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
346
+ resetSession = () => {
347
+ if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
348
+ this.update({ session: null });
349
+ };
259
350
  toggleMute = () => {
260
351
  this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
261
352
  };
@@ -270,9 +361,11 @@ var VoiceClient = class {
270
361
  this.clearPlayback();
271
362
  if (this.snapshot.interruptionMode === "none") {
272
363
  this.stoppedResponse = !this.responseEnded;
364
+ this.commitClauseCaptions();
273
365
  this.setStatus("listening");
274
366
  return;
275
367
  }
368
+ this.suppressedArtifactTurnId = this.activeTurnId;
276
369
  this.awaitingClear = true;
277
370
  this.setStatus("listening");
278
371
  this.socket.send(JSON.stringify({ type: "cancel" }));
@@ -289,6 +382,7 @@ var VoiceClient = class {
289
382
  });
290
383
  }
291
384
  cleanup() {
385
+ this.fullDuplex = false;
292
386
  this.generation += 1;
293
387
  if (this.processor) this.processor.onaudioprocess = null;
294
388
  this.processor?.disconnect();
@@ -314,6 +408,82 @@ var VoiceClient = class {
314
408
  this.awaitingClear = false;
315
409
  this.stoppedResponse = false;
316
410
  this.responseEnded = true;
411
+ this.commitClauseCaptions();
412
+ this.committedAssistantTurnId = null;
413
+ this.activeTurnId = null;
414
+ this.suppressedArtifactTurnId = null;
415
+ const settledArtifacts = this.snapshot.artifacts.filter(
416
+ (artifact) => artifact.status === "complete"
417
+ );
418
+ if (settledArtifacts.length !== this.snapshot.artifacts.length)
419
+ this.update({ artifacts: settledArtifacts });
420
+ }
421
+ /**
422
+ * Folds an `artifact` message into the snapshot. Frames of a turn the caller
423
+ * cancelled are dropped; a playback-only stop leaves the turn running, so its
424
+ * artifacts keep arriving and are kept.
425
+ */
426
+ applyArtifactMessage(msg) {
427
+ if (this.awaitingClear) return;
428
+ const frame = msg.event;
429
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) return;
430
+ const event = frame;
431
+ const id = readString(event.id);
432
+ if (!id) return;
433
+ const turnId = readTurnId(msg);
434
+ if (turnId !== null && turnId === this.suppressedArtifactTurnId) return;
435
+ if (turnId !== null) this.activeTurnId = turnId;
436
+ const artifacts = this.snapshot.artifacts;
437
+ const index = artifacts.findIndex((artifact) => artifact.id === id);
438
+ const executionId = readString(msg.executionId);
439
+ const next = applyArtifactFrame(index === -1 ? void 0 : artifacts[index], event, {
440
+ id,
441
+ turnId,
442
+ ...executionId ? { executionId } : {}
443
+ });
444
+ if (!next) return;
445
+ this.update({
446
+ artifacts: index === -1 ? [...artifacts, next] : [...artifacts.slice(0, index), next, ...artifacts.slice(index + 1)]
447
+ });
448
+ }
449
+ /** Settles the spoken clauses in place and ends the turn's claim on them. */
450
+ commitClauseCaptions() {
451
+ this.committedAssistantTurnId = this.pendingAssistant?.turnId ?? null;
452
+ const settled = this.pendingAssistant !== null;
453
+ this.pendingAssistant = null;
454
+ if (!settled) return;
455
+ const transcript = this.snapshot.transcript;
456
+ const last = transcript[transcript.length - 1];
457
+ if (last?.role !== "assistant" || last.partial !== true) return;
458
+ const { partial: _partial, ...committed } = last;
459
+ this.update({ transcript: [...transcript.slice(0, -1), committed] });
460
+ }
461
+ /** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
462
+ appendClauseCaption(text, turnId) {
463
+ const transcript = this.snapshot.transcript;
464
+ const last = transcript[transcript.length - 1];
465
+ if (this.pendingAssistant?.turnId === turnId && last?.role === "assistant") {
466
+ return {
467
+ interimTranscript: null,
468
+ transcript: [...transcript.slice(0, -1), { ...last, content: last.content + text }],
469
+ status: "speaking"
470
+ };
471
+ }
472
+ this.pendingAssistant = { turnId };
473
+ return {
474
+ interimTranscript: null,
475
+ transcript: [
476
+ ...transcript,
477
+ {
478
+ role: "assistant",
479
+ content: text,
480
+ timestamp: Date.now(),
481
+ partial: true,
482
+ ...turnId ? { turnId } : {}
483
+ }
484
+ ],
485
+ status: "speaking"
486
+ };
317
487
  }
318
488
  clearPlayback() {
319
489
  this.playbackRevision += 1;
@@ -348,32 +518,89 @@ var VoiceClient = class {
348
518
  return;
349
519
  }
350
520
  switch (msg.type) {
351
- case "session_config":
521
+ case "session_config": {
522
+ this.fullDuplex = msg.speechMode === "speech_to_speech";
523
+ if (this.fullDuplex) player.setContinuousMode?.(true);
352
524
  if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
353
525
  this.update({ interruptionMode: msg.interruptionMode });
354
526
  }
527
+ const sessionId = readString(msg.sessionId);
528
+ const conversationId = readString(msg.conversationId);
529
+ if (sessionId && conversationId) {
530
+ const session = { sessionId, conversationId };
531
+ this.update({ session });
532
+ this.options.onSession?.(session);
533
+ }
534
+ break;
535
+ }
536
+ case "artifact":
537
+ this.applyArtifactMessage(msg);
538
+ break;
539
+ case "transcript_update": {
540
+ if (typeof msg.text !== "string" || typeof msg.turnId !== "string" || msg.role !== "user" && msg.role !== "assistant")
541
+ break;
542
+ if (msg.role === "assistant" && this.awaitingClear) break;
543
+ const entries = [...this.snapshot.transcript];
544
+ const index = entries.findIndex(
545
+ (entry2) => entry2.turnId === msg.turnId && entry2.role === msg.role
546
+ );
547
+ const entry = {
548
+ role: msg.role,
549
+ content: msg.text,
550
+ turnId: msg.turnId,
551
+ isFinal: msg.final === true,
552
+ timestamp: index >= 0 ? entries[index].timestamp : Date.now()
553
+ };
554
+ if (index >= 0) entries[index] = entry;
555
+ else entries.push(entry);
556
+ this.update({ transcript: entries, interimTranscript: null });
557
+ break;
558
+ }
559
+ case "delegation_started":
560
+ if (!this.hasPendingAudio && !this.awaitingClear) this.setStatus("thinking");
561
+ break;
562
+ case "delegation_completed":
563
+ if (!this.hasPendingAudio && !this.awaitingClear) this.setStatus("listening");
355
564
  break;
356
565
  case "transcript_interim":
357
566
  this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
358
567
  break;
359
- case "transcript_final":
568
+ case "transcript_partial": {
569
+ if (msg.role !== "assistant" || typeof msg.text !== "string" || !msg.text) break;
570
+ this.activeTurnId = readTurnId(msg) ?? this.activeTurnId;
571
+ if (this.awaitingClear || this.stoppedResponse) break;
572
+ this.responseEnded = false;
573
+ this.update(this.appendClauseCaption(msg.text, readTurnId(msg)));
574
+ break;
575
+ }
576
+ case "transcript_final": {
360
577
  if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
361
- if (msg.role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
578
+ const role = msg.role;
579
+ const text = msg.text;
580
+ if (role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
581
+ const turnId = readTurnId(msg);
582
+ if (turnId !== null) this.activeTurnId = turnId;
583
+ if (role === "assistant" && turnId !== null && turnId === this.committedAssistantTurnId)
584
+ break;
362
585
  this.responseEnded = false;
586
+ const transcript = this.snapshot.transcript;
587
+ const last = transcript[transcript.length - 1];
588
+ const supersedes = role === "assistant" && this.pendingAssistant !== null && last?.role === "assistant" && (turnId === null || this.pendingAssistant.turnId === null || this.pendingAssistant.turnId === turnId);
589
+ this.pendingAssistant = null;
590
+ const entry = {
591
+ role,
592
+ content: text,
593
+ // INVARIANT: reuse the caption's timestamp so a keyed list does not remount the bubble.
594
+ timestamp: supersedes && last ? last.timestamp : Date.now(),
595
+ ...turnId ? { turnId } : {}
596
+ };
363
597
  this.update({
364
598
  interimTranscript: null,
365
- transcript: [
366
- ...this.snapshot.transcript,
367
- {
368
- role: msg.role,
369
- content: msg.text,
370
- timestamp: Date.now(),
371
- ...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
372
- }
373
- ],
374
- status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
599
+ transcript: supersedes ? [...transcript.slice(0, -1), entry] : [...transcript, entry],
600
+ status: this.awaitingClear ? this.snapshot.status : role === "user" ? "thinking" : "speaking"
375
601
  });
376
602
  break;
603
+ }
377
604
  case "audio_end": {
378
605
  this.responseEnded = true;
379
606
  if (this.stoppedResponse) {
@@ -390,10 +617,12 @@ var VoiceClient = class {
390
617
  break;
391
618
  }
392
619
  case "audio_clear":
620
+ this.suppressedArtifactTurnId = this.activeTurnId ?? this.suppressedArtifactTurnId;
393
621
  this.clearPlayback();
394
622
  this.awaitingClear = false;
395
623
  this.stoppedResponse = false;
396
624
  this.responseEnded = true;
625
+ this.commitClauseCaptions();
397
626
  this.setStatus("listening");
398
627
  break;
399
628
  case "metrics": {
@@ -406,8 +635,11 @@ var VoiceClient = class {
406
635
  // @snake-case-ok: Existing voice wire contract.
407
636
  firstAudioMs: number(msg.first_audio_ms),
408
637
  // @snake-case-ok: Existing voice wire contract.
409
- totalMs: number(msg.total_ms)
638
+ totalMs: number(msg.total_ms),
639
+ // @snake-case-ok: Existing voice wire contract.
640
+ firstSynthesisMs: number(msg.first_synthesis_ms),
410
641
  // @snake-case-ok: Existing voice wire contract.
642
+ incremental: msg.incremental === true
411
643
  }
412
644
  });
413
645
  break;
@@ -439,14 +671,14 @@ var VoiceClient = class {
439
671
  const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
440
672
  this.processor = processor;
441
673
  processor.onaudioprocess = (event) => {
442
- if (generation !== this.generation || this.snapshot.isMuted) return;
674
+ if (generation !== this.generation || this.snapshot.isMuted && !this.fullDuplex) return;
443
675
  const input = event.inputBuffer.getChannelData(0);
444
676
  let sum = 0;
445
677
  for (const sample of input) sum += sample * sample;
446
- this.update({ audioLevel: Math.sqrt(sum / input.length) });
678
+ this.update({ audioLevel: this.snapshot.isMuted ? 0 : Math.sqrt(sum / input.length) });
447
679
  if (socket.readyState !== WebSocket.OPEN) return;
448
680
  const pcm = new Int16Array(input.length);
449
- if (this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
681
+ if (this.snapshot.isMuted || this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
450
682
  socket.send(pcm.buffer);
451
683
  return;
452
684
  }