@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 +112 -27
- package/dist/realtime-server.js +43 -3
- package/dist/server.d.ts +1 -0
- package/dist/server.js +35 -0
- package/dist/speak-server.d.ts +13 -3
- package/dist/speak-server.js +79 -23
- package/package.json +1 -1
- package/src/index.tsx +115 -26
- package/src/realtime-server.ts +42 -3
- package/src/server.ts +34 -0
- package/src/speak-server.ts +99 -23
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
|
-
|
|
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
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
888
|
-
activeAudioRef.current = null;
|
|
973
|
+
stopTypedPlayback();
|
|
889
974
|
rtSocketRef.current?.close();
|
|
890
975
|
rtSocketRef.current = null;
|
|
891
976
|
rtCleanupRef.current?.();
|
package/dist/realtime-server.js
CHANGED
|
@@ -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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/dist/speak-server.d.ts
CHANGED
|
@@ -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
|
-
/** `
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/speak-server.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
return { status: 200, body: {
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
942
|
-
activeAudioRef.current = null;
|
|
1031
|
+
stopTypedPlayback();
|
|
943
1032
|
rtSocketRef.current?.close();
|
|
944
1033
|
rtSocketRef.current = null;
|
|
945
1034
|
rtCleanupRef.current?.();
|
package/src/realtime-server.ts
CHANGED
|
@@ -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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/speak-server.ts
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
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
|
-
/** `
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
}
|