@cairnvibe/sdk 0.2.8 → 0.2.9

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.js CHANGED
@@ -106,7 +106,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
106
106
  const rtMicMutedRef = (0, react_1.useRef)(false);
107
107
  const rtSpeakerMutedRef = (0, react_1.useRef)(false);
108
108
  const rtStartingRef = (0, react_1.useRef)(false); // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
109
- const activeAudioRef = (0, react_1.useRef)(null);
109
+ // Progressive PCM playback for the buffered (non-realtime) speak endpoint
110
+ // — the same gapless AudioBufferSourceNode scheduling the realtime path
111
+ // uses for its audio_chunk messages (see rtPlaybackCtxRef below), just fed
112
+ // by a fetch() ReadableStream instead of WebSocket messages. This exists
113
+ // because res.blob()/res.arrayBuffer() always wait for the whole response
114
+ // body in every browser no matter how the server sent it — streaming the
115
+ // wire alone (speak-server.ts) doesn't help unless playback also starts
116
+ // before the full reply has arrived.
117
+ const typedPlaybackCtxRef = (0, react_1.useRef)(null);
118
+ const typedPlaybackGainRef = (0, react_1.useRef)(null);
119
+ const typedNextPlayTimeRef = (0, react_1.useRef)(0);
120
+ const typedScheduledSourcesRef = (0, react_1.useRef)([]);
110
121
  // Watchdog for the "rt-thinking" state: started on every "final" transcript,
111
122
  // cleared the moment the server responds with anything for that turn
112
123
  // (verb/speaking_start/speaking_end/turn_complete/error). If it ever
@@ -433,30 +444,105 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
433
444
  { role: "assistant", text: "(gave up after too many steps)" },
434
445
  ].slice(-MAX_HISTORY_TURNS);
435
446
  }
447
+ function ensureTypedPlaybackGraph() {
448
+ if (!typedPlaybackCtxRef.current) {
449
+ const ctx = new AudioContext();
450
+ const gain = ctx.createGain();
451
+ gain.connect(ctx.destination);
452
+ typedPlaybackCtxRef.current = ctx;
453
+ typedPlaybackGainRef.current = gain;
454
+ }
455
+ return { ctx: typedPlaybackCtxRef.current, gain: typedPlaybackGainRef.current };
456
+ }
457
+ /** Stops whatever's currently playing on the typed/mic path's playback
458
+ * graph, so two responses (e.g. a rapid double-click, or two answers
459
+ * resolved close together) can never be heard overlapping. */
460
+ function stopTypedPlayback() {
461
+ for (const source of typedScheduledSourcesRef.current) {
462
+ source.onended = null;
463
+ try {
464
+ source.stop();
465
+ }
466
+ catch {
467
+ // may already have finished naturally
468
+ }
469
+ }
470
+ typedScheduledSourcesRef.current = [];
471
+ typedNextPlayTimeRef.current = typedPlaybackCtxRef.current?.currentTime ?? 0;
472
+ }
473
+ function concatBytes(a, b) {
474
+ const out = new Uint8Array(a.length + b.length);
475
+ out.set(a, 0);
476
+ out.set(b, a.length);
477
+ return out;
478
+ }
436
479
  /**
437
- * The one place that starts audio playback for a spoken response — stops
438
- * whatever's currently playing first, so two responses (e.g. a rapid
439
- * double-click on "start conversation", or two utterances resolved close
440
- * together) can never be heard overlapping. Used by both the typed/mic
441
- * path and the realtime path.
480
+ * Reads a raw linear16 PCM stream (mono, 24kHz matches speak-server.ts)
481
+ * and schedules it gapless-appended into the Web Audio graph as chunks
482
+ * arrive the same technique the realtime path uses for its audio_chunk
483
+ * messages, just driven by a fetch() reader instead of WebSocket frames.
484
+ * Resolves once every scheduled chunk has actually finished *playing*,
485
+ * not just finished arriving.
442
486
  */
443
- function playResponseAudio(blob) {
444
- if (activeAudioRef.current) {
445
- activeAudioRef.current.pause();
446
- activeAudioRef.current.currentTime = 0;
447
- }
448
- const url = URL.createObjectURL(blob);
449
- const audio = new Audio(url);
450
- activeAudioRef.current = audio;
487
+ function playPcmStream(stream) {
488
+ stopTypedPlayback();
489
+ const { ctx, gain } = ensureTypedPlaybackGraph();
490
+ void ctx.resume().catch(() => { });
451
491
  return new Promise((resolve) => {
452
- const clear = () => {
453
- URL.revokeObjectURL(url);
454
- if (activeAudioRef.current === audio)
455
- activeAudioRef.current = null;
456
- resolve();
492
+ let doneArriving = false;
493
+ let leftover = new Uint8Array(0);
494
+ const maybeResolve = () => {
495
+ if (doneArriving && typedScheduledSourcesRef.current.length === 0)
496
+ resolve();
497
+ };
498
+ const scheduleChunk = (bytes) => {
499
+ const sampleCount = Math.floor(bytes.length / 2);
500
+ if (sampleCount === 0)
501
+ return;
502
+ const float32 = new Float32Array(sampleCount);
503
+ const view = new DataView(bytes.buffer, bytes.byteOffset, sampleCount * 2);
504
+ for (let i = 0; i < sampleCount; i++)
505
+ float32[i] = view.getInt16(i * 2, true) / 32768;
506
+ const buffer = ctx.createBuffer(1, sampleCount, 24000);
507
+ buffer.copyToChannel(float32, 0);
508
+ const source = ctx.createBufferSource();
509
+ source.buffer = buffer;
510
+ source.connect(gain);
511
+ const startAt = Math.max(ctx.currentTime, typedNextPlayTimeRef.current);
512
+ source.start(startAt);
513
+ typedNextPlayTimeRef.current = startAt + buffer.duration;
514
+ typedScheduledSourcesRef.current.push(source);
515
+ source.onended = () => {
516
+ typedScheduledSourcesRef.current = typedScheduledSourcesRef.current.filter((s) => s !== source);
517
+ maybeResolve();
518
+ };
457
519
  };
458
- audio.onended = clear;
459
- audio.play().catch(clear);
520
+ (async () => {
521
+ const reader = stream.getReader();
522
+ try {
523
+ for (;;) {
524
+ const { done, value } = await reader.read();
525
+ if (done)
526
+ break;
527
+ if (!value || value.length === 0)
528
+ continue;
529
+ // PCM16 samples are 2 bytes each — a chunk boundary can split a
530
+ // sample in half, so carry any odd trailing byte into the next
531
+ // read instead of corrupting one sample at every chunk seam.
532
+ const combined = concatBytes(leftover, value);
533
+ const usableLen = combined.length - (combined.length % 2);
534
+ scheduleChunk(combined.subarray(0, usableLen));
535
+ leftover = combined.subarray(usableLen);
536
+ }
537
+ }
538
+ catch {
539
+ // Best-effort — never let a stream read failure hang the caller forever.
540
+ }
541
+ finally {
542
+ doneArriving = true;
543
+ maybeResolve();
544
+ }
545
+ })();
460
546
  });
461
547
  }
462
548
  async function speak(text) {
@@ -468,9 +554,9 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
468
554
  headers: { "content-type": "application/json" },
469
555
  body: JSON.stringify({ text }),
470
556
  });
471
- if (!res.ok)
557
+ if (!res.ok || !res.body)
472
558
  return;
473
- void playResponseAudio(await res.blob());
559
+ void playPcmStream(res.body);
474
560
  }
475
561
  catch {
476
562
  // Best-effort — never let speech playback break the widget.
@@ -488,9 +574,9 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
488
574
  headers: { "content-type": "application/json" },
489
575
  body: JSON.stringify({ text }),
490
576
  });
491
- if (!res.ok)
577
+ if (!res.ok || !res.body)
492
578
  return;
493
- await playResponseAudio(await res.blob());
579
+ await playPcmStream(res.body);
494
580
  }
495
581
  catch {
496
582
  // Best-effort — never let a synthesis failure hang the tour forever.
@@ -884,8 +970,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
884
970
  rtThinkingWatchdogRef.current = null;
885
971
  }
886
972
  rtStartingRef.current = false;
887
- activeAudioRef.current?.pause();
888
- activeAudioRef.current = null;
973
+ stopTypedPlayback();
889
974
  rtSocketRef.current?.close();
890
975
  rtSocketRef.current = null;
891
976
  rtCleanupRef.current?.();
@@ -39,6 +39,12 @@ const tts_stream_1 = require("./tts-stream");
39
39
  const DEEPGRAM_LIVE_URL = "wss://api.deepgram.com/v1/listen";
40
40
  const DEFAULT_STT_MODEL = "nova-2";
41
41
  const DEFAULT_TTS_VOICE = "aura-2-thalia-en";
42
+ // The Talker half of a Talker/Reasoner split (see finalizeTurn): spoken the
43
+ // instant a turn turns out to need more than one step, so the user hears
44
+ // something within about a second instead of dead air while the real
45
+ // multi-step work runs. A short rotating set, not one fixed line, so it
46
+ // doesn't read as a canned bot phrase on every multi-step question.
47
+ const ACK_PHRASES = ["Let me check that for you.", "One moment, let me look into that.", "Give me a second to check.", "Let me take a look."];
42
48
  // Not constrained by any telephony 8kHz requirement — this is just "what
43
49
  // quality does Deepgram render at" for browser playback, and the Web Audio
44
50
  // API resamples an AudioBuffer at any declared rate transparently.
@@ -348,6 +354,14 @@ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed,
348
354
  const myGeneration = getGeneration();
349
355
  safeSend(client, { type: "final", text: transcript });
350
356
  let loopHistory = history;
357
+ // The Talker: set once, the first time a turn turns out to need more
358
+ // than one step (see the loop below) — a real, in-flight speakStreamed()
359
+ // call, never awaited until we're actually ready to speak the real
360
+ // answer. Deliberately not re-triggered per step: the Speak connection
361
+ // (speakStreamed) only ever handles one utterance at a time, so a second
362
+ // ack mid-loop would race the first one's own audio_chunk/Flushed
363
+ // handling instead of queuing cleanly.
364
+ let ackPromise = null;
351
365
  try {
352
366
  for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
353
367
  const { route, visible, liveElements, webMcpTools } = getContext();
@@ -367,9 +381,19 @@ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed,
367
381
  // speak, not after.
368
382
  safeSend(client, { type: "verb", verb });
369
383
  if (!core_1.TERMINAL_VERBS.has(verb.verb)) {
370
- // A continuing step — no speech for it (keeps the loop fast;
371
- // the client still shows it visually) wait for its real result
372
- // and go around again instead of ending the turn.
384
+ if (i === 0) {
385
+ // This turn just revealed it needs more than one step speak a
386
+ // quick, cheap acknowledgment *now*, in parallel with the rest
387
+ // of the loop's own real work below (not awaited here), so the
388
+ // user hears something within about a second instead of dead
389
+ // air for however long the real multi-step answer takes.
390
+ // Single-step turns (the common case) never reach this branch
391
+ // at all, so they keep today's latency exactly as it is.
392
+ ackPromise = speakStreamed(ACK_PHRASES[Math.floor(Math.random() * ACK_PHRASES.length)]);
393
+ }
394
+ // A continuing step itself stays silent (keeps the loop fast; the
395
+ // client still shows it visually) — wait for its real result and
396
+ // go around again instead of ending the turn.
373
397
  const observation = await waitForToolResult();
374
398
  if (myGeneration !== getGeneration())
375
399
  return;
@@ -381,6 +405,17 @@ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed,
381
405
  }
382
406
  history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
383
407
  history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
408
+ if (ackPromise) {
409
+ // Never start a second speakStreamed call before the first (the
410
+ // ack) has actually finished — same single Speak connection, one
411
+ // utterance at a time. In the common multi-step case the real
412
+ // work below already took about as long as the ack itself did, so
413
+ // this rarely adds a real wait.
414
+ await ackPromise;
415
+ ackPromise = null;
416
+ if (myGeneration !== getGeneration())
417
+ return; // a barge-in could have landed during the ack itself
418
+ }
384
419
  // A verb with no spoken text (highlight/navigate/do often have none)
385
420
  // still needs to unstick the client's "thinking" state and let the mic
386
421
  // resume — turn_complete covers that with no audio path involved.
@@ -397,6 +432,11 @@ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed,
397
432
  history.push({ role: "user", text: transcript }, { role: "assistant", text: "(gave up after too many steps)" });
398
433
  history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
399
434
  safeSend(client, { type: "verb", verb: { verb: "explain", text: "I wasn't able to finish that — try asking again or breaking it into smaller steps." } });
435
+ if (ackPromise) {
436
+ await ackPromise;
437
+ if (myGeneration !== getGeneration())
438
+ return;
439
+ }
400
440
  await speakStreamed("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
401
441
  }
402
442
  catch (err) {
package/dist/server.d.ts CHANGED
@@ -93,6 +93,7 @@ export declare class GroqVerbLLM implements VerbLLM {
93
93
  private clientFactory;
94
94
  constructor(keys: KeyRotator, model: string, toolSchema: Record<string, unknown>, clientFactory?: (apiKey: string) => GroqLikeClient);
95
95
  respond(systemPrompt: string, userMessage: string): Promise<unknown>;
96
+ private attemptRespond;
96
97
  }
97
98
  /**
98
99
  * A compact route directory — NOT every element on every page. Found live
package/dist/server.js CHANGED
@@ -210,6 +210,28 @@ class GroqVerbLLM {
210
210
  this.clientFactory = clientFactory;
211
211
  }
212
212
  async respond(systemPrompt, userMessage) {
213
+ try {
214
+ return await this.attemptRespond(systemPrompt, userMessage);
215
+ }
216
+ catch (err) {
217
+ // Real, live bug, not theoretical: openai/gpt-oss-120b (a reasoning-
218
+ // capable open model) occasionally "thinks out loud" in plain prose
219
+ // instead of emitting the forced tool call — Groq's own server-side
220
+ // validation rejects that outright, a 400 with code
221
+ // "output_parse_failed", before this code ever sees a real response
222
+ // to work with. Non-deterministic (found live re-asking the exact
223
+ // same question a moment later succeeded cleanly), so one retry —
224
+ // not exponential backoff, this is a latency-sensitive voice/chat
225
+ // path — genuinely helps rather than just delaying the same
226
+ // failure. Anything else still propagates to resolveVerb's own
227
+ // catch, unchanged.
228
+ if (isOutputParseFailure(err)) {
229
+ return await this.attemptRespond(systemPrompt, userMessage);
230
+ }
231
+ throw err;
232
+ }
233
+ }
234
+ async attemptRespond(systemPrompt, userMessage) {
213
235
  const client = this.clientFactory(this.keys.take());
214
236
  const completion = await client.chat.completions.create({
215
237
  model: this.model,
@@ -241,6 +263,19 @@ class GroqVerbLLM {
241
263
  }
242
264
  }
243
265
  exports.GroqVerbLLM = GroqVerbLLM;
266
+ /** Groq's SDK doesn't export a stable error shape to import and check
267
+ * against, so this checks defensively across the ways the real error has
268
+ * actually been observed to surface — a thrown APIError with a nested
269
+ * `.error.code`, a plain `.code`, or just the code string showing up
270
+ * somewhere in the message — rather than relying on exactly one of them. */
271
+ function isOutputParseFailure(err) {
272
+ if (!err || typeof err !== "object")
273
+ return false;
274
+ const e = err;
275
+ if (e.code === "output_parse_failed" || e.error?.code === "output_parse_failed")
276
+ return true;
277
+ return typeof e.message === "string" && e.message.includes("output_parse_failed");
278
+ }
244
279
  // ---------------------------------------------------------------------------
245
280
  // Shared tool schema / system prompt
246
281
  // ---------------------------------------------------------------------------
@@ -1,16 +1,26 @@
1
+ import { DeepgramSpeakStream, type DeepgramSpeakStreamOptions, type SpeakChunkCallback } from "./tts-stream";
1
2
  export interface CreateSpeakHandlerOptions {
2
3
  apiKey: string;
3
4
  model?: string;
4
5
  }
5
6
  export interface SpeakResult {
6
7
  status: number;
7
- /** `audio` is raw MP3 bytes on success. */
8
+ /** `stream` yields raw linear16 PCM chunks (mono, 24kHz) as Deepgram
9
+ * renders them — forward it directly, unbuffered; do not await it into a
10
+ * Blob/ArrayBuffer or the whole point of streaming is lost. */
8
11
  body: {
9
- audio: ArrayBuffer;
12
+ stream: ReadableStream<Uint8Array>;
10
13
  contentType: string;
11
14
  } | {
12
15
  error: string;
13
16
  };
14
17
  }
15
18
  export type SpeakHandler = (text: string) => Promise<SpeakResult>;
16
- export declare function createSpeakHandler(options: CreateSpeakHandlerOptions): SpeakHandler;
19
+ /** Test-only seam: lets tests inject a fake stream instead of opening a real
20
+ * Deepgram WebSocket. Not part of CreateSpeakHandlerOptions on purpose — real
21
+ * call sites (the scaffolded route templates) never pass this. */
22
+ export type SpeakStreamFactory = (opts: DeepgramSpeakStreamOptions, onAudioChunk: SpeakChunkCallback, handlers?: {
23
+ onFlushed?: (sequenceId: number) => void;
24
+ onError?: (err: Error) => void;
25
+ }) => DeepgramSpeakStream;
26
+ export declare function createSpeakHandler(options: CreateSpeakHandlerOptions, streamFactory?: SpeakStreamFactory): SpeakHandler;
@@ -1,41 +1,97 @@
1
1
  "use strict";
2
- // Server-side text-to-speech for the Copilot widget's spoken answers (see
3
- // `speakEndpoint` in index.tsx). The Deepgram key must never reach the
4
- // client, so this is a plain fetch to Deepgram's /v1/speak REST endpoint —
5
- // no SDK dependency needed for one request shape.
6
2
  Object.defineProperty(exports, "__esModule", { value: true });
7
3
  exports.createSpeakHandler = createSpeakHandler;
8
- const DEEPGRAM_SPEAK_URL = "https://api.deepgram.com/v1/speak";
9
- // Verified against Deepgram's docs while building this re-check if this
10
- // starts erroring, voice model names retire over time.
4
+ // Server-side text-to-speech for the Copilot widget's spoken answers (see
5
+ // `speakEndpoint` in index.tsx). The Deepgram key must never reach the
6
+ // client.
7
+ //
8
+ // This used to be one fetch to Deepgram's /v1/speak REST endpoint, buffered
9
+ // into an ArrayBuffer with `await response.arrayBuffer()` before returning
10
+ // anything. That's the exact same bug the realtime path already fixed once
11
+ // (see tts-stream.ts's own comment): nothing plays until Deepgram renders
12
+ // AND the network delivers the *entire* reply, which measured 5-8s for a
13
+ // normal explain answer in real production logs. It also hit Deepgram's
14
+ // REST-only 2000-character cap on longer replies with no handling at all.
15
+ //
16
+ // Fixed the same way the realtime path was: open the streaming Speak
17
+ // WebSocket (tts-stream.ts's DeepgramSpeakStream, the same class the
18
+ // realtime server already uses) and forward audio chunks to the caller as
19
+ // they arrive, via a ReadableStream — not buffered. The route handler
20
+ // forwards that stream straight through as the HTTP response body, and the
21
+ // client (index.tsx) reads it progressively instead of awaiting a full
22
+ // Blob, so playback can start on the first chunk. Splitting the text into
23
+ // sentence-sized `Speak` messages before one `Flush` sidesteps the old
24
+ // 2000-char REST limit entirely (it doesn't apply to the WS protocol) and
25
+ // lets Deepgram start rendering the first sentence sooner.
26
+ const tts_stream_1 = require("./tts-stream");
11
27
  const DEEPGRAM_DEFAULT_VOICE = "aura-2-thalia-en";
12
- function createSpeakHandler(options) {
28
+ // Matches the realtime path's own playback sample rate (index.tsx's
29
+ // audio_chunk handling defaults to 24000 too) — keeping them identical lets
30
+ // both paths share one raw-PCM16 decode/schedule routine on the client.
31
+ const SAMPLE_RATE = 24000;
32
+ // Not a protocol limit (the WS Speak protocol has none like REST's 2000
33
+ // chars) — just keeps each queued chunk sentence-sized so Deepgram can start
34
+ // rendering the first one quickly instead of parsing one giant message.
35
+ const MAX_CHUNK_CHARS = 300;
36
+ function splitIntoChunks(text, maxChars) {
37
+ const sentences = text.match(/[^.!?]+[.!?]*\s*/g) ?? [text];
38
+ const chunks = [];
39
+ let current = "";
40
+ for (const sentence of sentences) {
41
+ if (current && current.length + sentence.length > maxChars) {
42
+ chunks.push(current);
43
+ current = "";
44
+ }
45
+ current += sentence;
46
+ }
47
+ if (current)
48
+ chunks.push(current);
49
+ return chunks;
50
+ }
51
+ function createSpeakHandler(options, streamFactory = (opts, onAudioChunk, handlers) => new tts_stream_1.DeepgramSpeakStream(opts, onAudioChunk, handlers)) {
13
52
  const model = options.model ?? process.env.DEEPGRAM_VOICE ?? DEEPGRAM_DEFAULT_VOICE;
14
53
  return async function handleSpeak(text) {
15
54
  if (!text || !text.trim()) {
16
55
  return { status: 400, body: { error: "no text provided" } };
17
56
  }
18
- let response;
57
+ let enqueue = null;
58
+ let closeOut = null;
59
+ let failOut = null;
60
+ const stream = new ReadableStream({
61
+ start(controller) {
62
+ enqueue = (chunk) => controller.enqueue(chunk);
63
+ closeOut = () => controller.close();
64
+ failOut = (err) => controller.error(err);
65
+ },
66
+ });
67
+ // True once connect() below resolves — an onError before that point is
68
+ // already reported through connect()'s own rejection, so it's ignored
69
+ // here to avoid double-handling the same failure.
70
+ let connected = false;
71
+ const speakStream = streamFactory({ apiKey: options.apiKey, model, encoding: "linear16", sampleRate: SAMPLE_RATE }, (chunk) => enqueue?.(new Uint8Array(chunk)), {
72
+ onFlushed: () => {
73
+ closeOut?.();
74
+ speakStream.close();
75
+ },
76
+ onError: (err) => {
77
+ if (!connected)
78
+ return;
79
+ console.error("[cairn] speak stream error:", err);
80
+ failOut?.(err);
81
+ },
82
+ });
19
83
  try {
20
- response = await fetch(`${DEEPGRAM_SPEAK_URL}?model=${encodeURIComponent(model)}`, {
21
- method: "POST",
22
- headers: {
23
- Authorization: `Token ${options.apiKey}`,
24
- "content-type": "application/json",
25
- },
26
- body: JSON.stringify({ text }),
27
- });
84
+ await speakStream.connect();
28
85
  }
29
86
  catch (err) {
30
87
  console.error("[cairn] speak request failed:", err);
31
88
  return { status: 200, body: { error: "speech service unreachable" } };
32
89
  }
33
- if (!response.ok) {
34
- const detail = await response.text().catch(() => "");
35
- console.error("[cairn] Deepgram speak returned an error:", response.status, detail);
36
- return { status: 200, body: { error: "speech synthesis failed" } };
90
+ connected = true;
91
+ for (const chunk of splitIntoChunks(text, MAX_CHUNK_CHARS)) {
92
+ speakStream.sendText(chunk);
37
93
  }
38
- const audio = await response.arrayBuffer();
39
- return { status: 200, body: { audio, contentType: response.headers.get("content-type") ?? "audio/mpeg" } };
94
+ speakStream.flush();
95
+ return { status: 200, body: { stream, contentType: `audio/L16;rate=${SAMPLE_RATE}` } };
40
96
  };
41
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnvibe/sdk",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "In-app AI copilot — <Copilot/> for React/Next.js, <cairn-widget> for any framework — plus the server handlers and realtime voice relay behind them.",
5
5
  "license": "MIT",
6
6
  "publishConfig": { "access": "public" },
package/src/index.tsx CHANGED
@@ -161,7 +161,18 @@ export function Copilot({
161
161
  const rtMicMutedRef = useRef(false);
162
162
  const rtSpeakerMutedRef = useRef(false);
163
163
  const rtStartingRef = useRef(false); // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
164
- const activeAudioRef = useRef<HTMLAudioElement | null>(null);
164
+ // Progressive PCM playback for the buffered (non-realtime) speak endpoint
165
+ // — the same gapless AudioBufferSourceNode scheduling the realtime path
166
+ // uses for its audio_chunk messages (see rtPlaybackCtxRef below), just fed
167
+ // by a fetch() ReadableStream instead of WebSocket messages. This exists
168
+ // because res.blob()/res.arrayBuffer() always wait for the whole response
169
+ // body in every browser no matter how the server sent it — streaming the
170
+ // wire alone (speak-server.ts) doesn't help unless playback also starts
171
+ // before the full reply has arrived.
172
+ const typedPlaybackCtxRef = useRef<AudioContext | null>(null);
173
+ const typedPlaybackGainRef = useRef<GainNode | null>(null);
174
+ const typedNextPlayTimeRef = useRef(0);
175
+ const typedScheduledSourcesRef = useRef<AudioBufferSourceNode[]>([]);
165
176
  // Watchdog for the "rt-thinking" state: started on every "final" transcript,
166
177
  // cleared the moment the server responds with anything for that turn
167
178
  // (verb/speaking_start/speaking_end/turn_complete/error). If it ever
@@ -497,29 +508,108 @@ export function Copilot({
497
508
  ].slice(-MAX_HISTORY_TURNS);
498
509
  }
499
510
 
511
+ function ensureTypedPlaybackGraph(): { ctx: AudioContext; gain: GainNode } {
512
+ if (!typedPlaybackCtxRef.current) {
513
+ const ctx = new AudioContext();
514
+ const gain = ctx.createGain();
515
+ gain.connect(ctx.destination);
516
+ typedPlaybackCtxRef.current = ctx;
517
+ typedPlaybackGainRef.current = gain;
518
+ }
519
+ return { ctx: typedPlaybackCtxRef.current, gain: typedPlaybackGainRef.current! };
520
+ }
521
+
522
+ /** Stops whatever's currently playing on the typed/mic path's playback
523
+ * graph, so two responses (e.g. a rapid double-click, or two answers
524
+ * resolved close together) can never be heard overlapping. */
525
+ function stopTypedPlayback() {
526
+ for (const source of typedScheduledSourcesRef.current) {
527
+ source.onended = null;
528
+ try {
529
+ source.stop();
530
+ } catch {
531
+ // may already have finished naturally
532
+ }
533
+ }
534
+ typedScheduledSourcesRef.current = [];
535
+ typedNextPlayTimeRef.current = typedPlaybackCtxRef.current?.currentTime ?? 0;
536
+ }
537
+
538
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
539
+ const out = new Uint8Array(a.length + b.length);
540
+ out.set(a, 0);
541
+ out.set(b, a.length);
542
+ return out;
543
+ }
544
+
500
545
  /**
501
- * The one place that starts audio playback for a spoken response — stops
502
- * whatever's currently playing first, so two responses (e.g. a rapid
503
- * double-click on "start conversation", or two utterances resolved close
504
- * together) can never be heard overlapping. Used by both the typed/mic
505
- * path and the realtime path.
546
+ * Reads a raw linear16 PCM stream (mono, 24kHz matches speak-server.ts)
547
+ * and schedules it gapless-appended into the Web Audio graph as chunks
548
+ * arrive the same technique the realtime path uses for its audio_chunk
549
+ * messages, just driven by a fetch() reader instead of WebSocket frames.
550
+ * Resolves once every scheduled chunk has actually finished *playing*,
551
+ * not just finished arriving.
506
552
  */
507
- function playResponseAudio(blob: Blob): Promise<void> {
508
- if (activeAudioRef.current) {
509
- activeAudioRef.current.pause();
510
- activeAudioRef.current.currentTime = 0;
511
- }
512
- const url = URL.createObjectURL(blob);
513
- const audio = new Audio(url);
514
- activeAudioRef.current = audio;
553
+ function playPcmStream(stream: ReadableStream<Uint8Array>): Promise<void> {
554
+ stopTypedPlayback();
555
+ const { ctx, gain } = ensureTypedPlaybackGraph();
556
+ void ctx.resume().catch(() => {});
557
+
515
558
  return new Promise((resolve) => {
516
- const clear = () => {
517
- URL.revokeObjectURL(url);
518
- if (activeAudioRef.current === audio) activeAudioRef.current = null;
519
- resolve();
559
+ let doneArriving = false;
560
+ let leftover: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
561
+
562
+ const maybeResolve = () => {
563
+ if (doneArriving && typedScheduledSourcesRef.current.length === 0) resolve();
520
564
  };
521
- audio.onended = clear;
522
- audio.play().catch(clear);
565
+
566
+ const scheduleChunk = (bytes: Uint8Array) => {
567
+ const sampleCount = Math.floor(bytes.length / 2);
568
+ if (sampleCount === 0) return;
569
+ const float32 = new Float32Array(sampleCount);
570
+ const view = new DataView(bytes.buffer, bytes.byteOffset, sampleCount * 2);
571
+ for (let i = 0; i < sampleCount; i++) float32[i] = view.getInt16(i * 2, true) / 32768;
572
+
573
+ const buffer = ctx.createBuffer(1, sampleCount, 24000);
574
+ buffer.copyToChannel(float32, 0);
575
+
576
+ const source = ctx.createBufferSource();
577
+ source.buffer = buffer;
578
+ source.connect(gain);
579
+
580
+ const startAt = Math.max(ctx.currentTime, typedNextPlayTimeRef.current);
581
+ source.start(startAt);
582
+ typedNextPlayTimeRef.current = startAt + buffer.duration;
583
+
584
+ typedScheduledSourcesRef.current.push(source);
585
+ source.onended = () => {
586
+ typedScheduledSourcesRef.current = typedScheduledSourcesRef.current.filter((s) => s !== source);
587
+ maybeResolve();
588
+ };
589
+ };
590
+
591
+ (async () => {
592
+ const reader = stream.getReader();
593
+ try {
594
+ for (;;) {
595
+ const { done, value } = await reader.read();
596
+ if (done) break;
597
+ if (!value || value.length === 0) continue;
598
+ // PCM16 samples are 2 bytes each — a chunk boundary can split a
599
+ // sample in half, so carry any odd trailing byte into the next
600
+ // read instead of corrupting one sample at every chunk seam.
601
+ const combined = concatBytes(leftover, value);
602
+ const usableLen = combined.length - (combined.length % 2);
603
+ scheduleChunk(combined.subarray(0, usableLen));
604
+ leftover = combined.subarray(usableLen);
605
+ }
606
+ } catch {
607
+ // Best-effort — never let a stream read failure hang the caller forever.
608
+ } finally {
609
+ doneArriving = true;
610
+ maybeResolve();
611
+ }
612
+ })();
523
613
  });
524
614
  }
525
615
 
@@ -531,8 +621,8 @@ export function Copilot({
531
621
  headers: { "content-type": "application/json" },
532
622
  body: JSON.stringify({ text }),
533
623
  });
534
- if (!res.ok) return;
535
- void playResponseAudio(await res.blob());
624
+ if (!res.ok || !res.body) return;
625
+ void playPcmStream(res.body);
536
626
  } catch {
537
627
  // Best-effort — never let speech playback break the widget.
538
628
  }
@@ -549,8 +639,8 @@ export function Copilot({
549
639
  headers: { "content-type": "application/json" },
550
640
  body: JSON.stringify({ text }),
551
641
  });
552
- if (!res.ok) return;
553
- await playResponseAudio(await res.blob());
642
+ if (!res.ok || !res.body) return;
643
+ await playPcmStream(res.body);
554
644
  } catch {
555
645
  // Best-effort — never let a synthesis failure hang the tour forever.
556
646
  }
@@ -938,8 +1028,7 @@ export function Copilot({
938
1028
  rtThinkingWatchdogRef.current = null;
939
1029
  }
940
1030
  rtStartingRef.current = false;
941
- activeAudioRef.current?.pause();
942
- activeAudioRef.current = null;
1031
+ stopTypedPlayback();
943
1032
  rtSocketRef.current?.close();
944
1033
  rtSocketRef.current = null;
945
1034
  rtCleanupRef.current?.();
@@ -34,6 +34,12 @@ import { DeepgramSpeakStream } from "./tts-stream";
34
34
  const DEEPGRAM_LIVE_URL = "wss://api.deepgram.com/v1/listen";
35
35
  const DEFAULT_STT_MODEL = "nova-2";
36
36
  const DEFAULT_TTS_VOICE = "aura-2-thalia-en";
37
+ // The Talker half of a Talker/Reasoner split (see finalizeTurn): spoken the
38
+ // instant a turn turns out to need more than one step, so the user hears
39
+ // something within about a second instead of dead air while the real
40
+ // multi-step work runs. A short rotating set, not one fixed line, so it
41
+ // doesn't read as a canned bot phrase on every multi-step question.
42
+ const ACK_PHRASES = ["Let me check that for you.", "One moment, let me look into that.", "Give me a second to check.", "Let me take a look."];
37
43
  // Not constrained by any telephony 8kHz requirement — this is just "what
38
44
  // quality does Deepgram render at" for browser playback, and the Web Audio
39
45
  // API resamples an AudioBuffer at any declared rate transparently.
@@ -413,6 +419,14 @@ async function finalizeTurn(
413
419
  safeSend(client, { type: "final", text: transcript });
414
420
 
415
421
  let loopHistory = history;
422
+ // The Talker: set once, the first time a turn turns out to need more
423
+ // than one step (see the loop below) — a real, in-flight speakStreamed()
424
+ // call, never awaited until we're actually ready to speak the real
425
+ // answer. Deliberately not re-triggered per step: the Speak connection
426
+ // (speakStreamed) only ever handles one utterance at a time, so a second
427
+ // ack mid-loop would race the first one's own audio_chunk/Flushed
428
+ // handling instead of queuing cleanly.
429
+ let ackPromise: Promise<void> | null = null;
416
430
 
417
431
  try {
418
432
  for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
@@ -435,9 +449,19 @@ async function finalizeTurn(
435
449
  safeSend(client, { type: "verb", verb });
436
450
 
437
451
  if (!TERMINAL_VERBS.has(verb.verb)) {
438
- // A continuing step — no speech for it (keeps the loop fast;
439
- // the client still shows it visually) wait for its real result
440
- // and go around again instead of ending the turn.
452
+ if (i === 0) {
453
+ // This turn just revealed it needs more than one step speak a
454
+ // quick, cheap acknowledgment *now*, in parallel with the rest
455
+ // of the loop's own real work below (not awaited here), so the
456
+ // user hears something within about a second instead of dead
457
+ // air for however long the real multi-step answer takes.
458
+ // Single-step turns (the common case) never reach this branch
459
+ // at all, so they keep today's latency exactly as it is.
460
+ ackPromise = speakStreamed(ACK_PHRASES[Math.floor(Math.random() * ACK_PHRASES.length)]);
461
+ }
462
+ // A continuing step itself stays silent (keeps the loop fast; the
463
+ // client still shows it visually) — wait for its real result and
464
+ // go around again instead of ending the turn.
441
465
  const observation = await waitForToolResult();
442
466
  if (myGeneration !== getGeneration()) return;
443
467
  loopHistory = [
@@ -450,6 +474,17 @@ async function finalizeTurn(
450
474
  history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
451
475
  history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
452
476
 
477
+ if (ackPromise) {
478
+ // Never start a second speakStreamed call before the first (the
479
+ // ack) has actually finished — same single Speak connection, one
480
+ // utterance at a time. In the common multi-step case the real
481
+ // work below already took about as long as the ack itself did, so
482
+ // this rarely adds a real wait.
483
+ await ackPromise;
484
+ ackPromise = null;
485
+ if (myGeneration !== getGeneration()) return; // a barge-in could have landed during the ack itself
486
+ }
487
+
453
488
  // A verb with no spoken text (highlight/navigate/do often have none)
454
489
  // still needs to unstick the client's "thinking" state and let the mic
455
490
  // resume — turn_complete covers that with no audio path involved.
@@ -466,6 +501,10 @@ async function finalizeTurn(
466
501
  history.push({ role: "user", text: transcript }, { role: "assistant", text: "(gave up after too many steps)" });
467
502
  history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
468
503
  safeSend(client, { type: "verb", verb: { verb: "explain", text: "I wasn't able to finish that — try asking again or breaking it into smaller steps." } });
504
+ if (ackPromise) {
505
+ await ackPromise;
506
+ if (myGeneration !== getGeneration()) return;
507
+ }
469
508
  await speakStreamed("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
470
509
  } catch (err) {
471
510
  console.error("[cairn realtime] failed to resolve/speak this turn:", err);
package/src/server.ts CHANGED
@@ -303,6 +303,28 @@ export class GroqVerbLLM implements VerbLLM {
303
303
  ) {}
304
304
 
305
305
  async respond(systemPrompt: string, userMessage: string): Promise<unknown> {
306
+ try {
307
+ return await this.attemptRespond(systemPrompt, userMessage);
308
+ } catch (err) {
309
+ // Real, live bug, not theoretical: openai/gpt-oss-120b (a reasoning-
310
+ // capable open model) occasionally "thinks out loud" in plain prose
311
+ // instead of emitting the forced tool call — Groq's own server-side
312
+ // validation rejects that outright, a 400 with code
313
+ // "output_parse_failed", before this code ever sees a real response
314
+ // to work with. Non-deterministic (found live re-asking the exact
315
+ // same question a moment later succeeded cleanly), so one retry —
316
+ // not exponential backoff, this is a latency-sensitive voice/chat
317
+ // path — genuinely helps rather than just delaying the same
318
+ // failure. Anything else still propagates to resolveVerb's own
319
+ // catch, unchanged.
320
+ if (isOutputParseFailure(err)) {
321
+ return await this.attemptRespond(systemPrompt, userMessage);
322
+ }
323
+ throw err;
324
+ }
325
+ }
326
+
327
+ private async attemptRespond(systemPrompt: string, userMessage: string): Promise<unknown> {
306
328
  const client = this.clientFactory(this.keys.take());
307
329
  const completion = await client.chat.completions.create({
308
330
  model: this.model,
@@ -333,6 +355,18 @@ export class GroqVerbLLM implements VerbLLM {
333
355
  }
334
356
  }
335
357
 
358
+ /** Groq's SDK doesn't export a stable error shape to import and check
359
+ * against, so this checks defensively across the ways the real error has
360
+ * actually been observed to surface — a thrown APIError with a nested
361
+ * `.error.code`, a plain `.code`, or just the code string showing up
362
+ * somewhere in the message — rather than relying on exactly one of them. */
363
+ function isOutputParseFailure(err: unknown): boolean {
364
+ if (!err || typeof err !== "object") return false;
365
+ const e = err as { code?: unknown; error?: { code?: unknown }; message?: unknown };
366
+ if (e.code === "output_parse_failed" || e.error?.code === "output_parse_failed") return true;
367
+ return typeof e.message === "string" && e.message.includes("output_parse_failed");
368
+ }
369
+
336
370
  // ---------------------------------------------------------------------------
337
371
  // Shared tool schema / system prompt
338
372
  // ---------------------------------------------------------------------------
@@ -1,12 +1,36 @@
1
1
  // Server-side text-to-speech for the Copilot widget's spoken answers (see
2
2
  // `speakEndpoint` in index.tsx). The Deepgram key must never reach the
3
- // client, so this is a plain fetch to Deepgram's /v1/speak REST endpoint —
4
- // no SDK dependency needed for one request shape.
3
+ // client.
4
+ //
5
+ // This used to be one fetch to Deepgram's /v1/speak REST endpoint, buffered
6
+ // into an ArrayBuffer with `await response.arrayBuffer()` before returning
7
+ // anything. That's the exact same bug the realtime path already fixed once
8
+ // (see tts-stream.ts's own comment): nothing plays until Deepgram renders
9
+ // AND the network delivers the *entire* reply, which measured 5-8s for a
10
+ // normal explain answer in real production logs. It also hit Deepgram's
11
+ // REST-only 2000-character cap on longer replies with no handling at all.
12
+ //
13
+ // Fixed the same way the realtime path was: open the streaming Speak
14
+ // WebSocket (tts-stream.ts's DeepgramSpeakStream, the same class the
15
+ // realtime server already uses) and forward audio chunks to the caller as
16
+ // they arrive, via a ReadableStream — not buffered. The route handler
17
+ // forwards that stream straight through as the HTTP response body, and the
18
+ // client (index.tsx) reads it progressively instead of awaiting a full
19
+ // Blob, so playback can start on the first chunk. Splitting the text into
20
+ // sentence-sized `Speak` messages before one `Flush` sidesteps the old
21
+ // 2000-char REST limit entirely (it doesn't apply to the WS protocol) and
22
+ // lets Deepgram start rendering the first sentence sooner.
23
+ import { DeepgramSpeakStream, type DeepgramSpeakStreamOptions, type SpeakChunkCallback } from "./tts-stream";
5
24
 
6
- const DEEPGRAM_SPEAK_URL = "https://api.deepgram.com/v1/speak";
7
- // Verified against Deepgram's docs while building this — re-check if this
8
- // starts erroring, voice model names retire over time.
9
25
  const DEEPGRAM_DEFAULT_VOICE = "aura-2-thalia-en";
26
+ // Matches the realtime path's own playback sample rate (index.tsx's
27
+ // audio_chunk handling defaults to 24000 too) — keeping them identical lets
28
+ // both paths share one raw-PCM16 decode/schedule routine on the client.
29
+ const SAMPLE_RATE = 24000;
30
+ // Not a protocol limit (the WS Speak protocol has none like REST's 2000
31
+ // chars) — just keeps each queued chunk sentence-sized so Deepgram can start
32
+ // rendering the first one quickly instead of parsing one giant message.
33
+ const MAX_CHUNK_CHARS = 300;
10
34
 
11
35
  export interface CreateSpeakHandlerOptions {
12
36
  apiKey: string;
@@ -15,13 +39,42 @@ export interface CreateSpeakHandlerOptions {
15
39
 
16
40
  export interface SpeakResult {
17
41
  status: number;
18
- /** `audio` is raw MP3 bytes on success. */
19
- body: { audio: ArrayBuffer; contentType: string } | { error: string };
42
+ /** `stream` yields raw linear16 PCM chunks (mono, 24kHz) as Deepgram
43
+ * renders them forward it directly, unbuffered; do not await it into a
44
+ * Blob/ArrayBuffer or the whole point of streaming is lost. */
45
+ body: { stream: ReadableStream<Uint8Array>; contentType: string } | { error: string };
20
46
  }
21
47
 
22
48
  export type SpeakHandler = (text: string) => Promise<SpeakResult>;
23
49
 
24
- export function createSpeakHandler(options: CreateSpeakHandlerOptions): SpeakHandler {
50
+ /** Test-only seam: lets tests inject a fake stream instead of opening a real
51
+ * Deepgram WebSocket. Not part of CreateSpeakHandlerOptions on purpose — real
52
+ * call sites (the scaffolded route templates) never pass this. */
53
+ export type SpeakStreamFactory = (
54
+ opts: DeepgramSpeakStreamOptions,
55
+ onAudioChunk: SpeakChunkCallback,
56
+ handlers?: { onFlushed?: (sequenceId: number) => void; onError?: (err: Error) => void },
57
+ ) => DeepgramSpeakStream;
58
+
59
+ function splitIntoChunks(text: string, maxChars: number): string[] {
60
+ const sentences = text.match(/[^.!?]+[.!?]*\s*/g) ?? [text];
61
+ const chunks: string[] = [];
62
+ let current = "";
63
+ for (const sentence of sentences) {
64
+ if (current && current.length + sentence.length > maxChars) {
65
+ chunks.push(current);
66
+ current = "";
67
+ }
68
+ current += sentence;
69
+ }
70
+ if (current) chunks.push(current);
71
+ return chunks;
72
+ }
73
+
74
+ export function createSpeakHandler(
75
+ options: CreateSpeakHandlerOptions,
76
+ streamFactory: SpeakStreamFactory = (opts, onAudioChunk, handlers) => new DeepgramSpeakStream(opts, onAudioChunk, handlers),
77
+ ): SpeakHandler {
25
78
  const model = options.model ?? process.env.DEEPGRAM_VOICE ?? DEEPGRAM_DEFAULT_VOICE;
26
79
 
27
80
  return async function handleSpeak(text: string) {
@@ -29,28 +82,51 @@ export function createSpeakHandler(options: CreateSpeakHandlerOptions): SpeakHan
29
82
  return { status: 400, body: { error: "no text provided" } };
30
83
  }
31
84
 
32
- let response: Response;
33
- try {
34
- response = await fetch(`${DEEPGRAM_SPEAK_URL}?model=${encodeURIComponent(model)}`, {
35
- method: "POST",
36
- headers: {
37
- Authorization: `Token ${options.apiKey}`,
38
- "content-type": "application/json",
85
+ let enqueue: ((chunk: Uint8Array) => void) | null = null;
86
+ let closeOut: (() => void) | null = null;
87
+ let failOut: ((err: Error) => void) | null = null;
88
+ const stream = new ReadableStream<Uint8Array>({
89
+ start(controller) {
90
+ enqueue = (chunk) => controller.enqueue(chunk);
91
+ closeOut = () => controller.close();
92
+ failOut = (err) => controller.error(err);
93
+ },
94
+ });
95
+
96
+ // True once connect() below resolves — an onError before that point is
97
+ // already reported through connect()'s own rejection, so it's ignored
98
+ // here to avoid double-handling the same failure.
99
+ let connected = false;
100
+
101
+ const speakStream = streamFactory(
102
+ { apiKey: options.apiKey, model, encoding: "linear16", sampleRate: SAMPLE_RATE },
103
+ (chunk) => enqueue?.(new Uint8Array(chunk)),
104
+ {
105
+ onFlushed: () => {
106
+ closeOut?.();
107
+ speakStream.close();
108
+ },
109
+ onError: (err) => {
110
+ if (!connected) return;
111
+ console.error("[cairn] speak stream error:", err);
112
+ failOut?.(err);
39
113
  },
40
- body: JSON.stringify({ text }),
41
- });
114
+ },
115
+ );
116
+
117
+ try {
118
+ await speakStream.connect();
42
119
  } catch (err) {
43
120
  console.error("[cairn] speak request failed:", err);
44
121
  return { status: 200, body: { error: "speech service unreachable" } };
45
122
  }
123
+ connected = true;
46
124
 
47
- if (!response.ok) {
48
- const detail = await response.text().catch(() => "");
49
- console.error("[cairn] Deepgram speak returned an error:", response.status, detail);
50
- return { status: 200, body: { error: "speech synthesis failed" } };
125
+ for (const chunk of splitIntoChunks(text, MAX_CHUNK_CHARS)) {
126
+ speakStream.sendText(chunk);
51
127
  }
128
+ speakStream.flush();
52
129
 
53
- const audio = await response.arrayBuffer();
54
- return { status: 200, body: { audio, contentType: response.headers.get("content-type") ?? "audio/mpeg" } };
130
+ return { status: 200, body: { stream, contentType: `audio/L16;rate=${SAMPLE_RATE}` } };
55
131
  };
56
132
  }