@vellumai/assistant 0.11.3-staging.2 → 0.11.3-staging.3

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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/call-controller.test.ts +120 -0
  3. package/src/__tests__/config-loader-backfill.test.ts +19 -0
  4. package/src/__tests__/config-schema.test.ts +139 -5
  5. package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
  6. package/src/__tests__/media-stream-output.test.ts +175 -0
  7. package/src/__tests__/media-stream-stt-session.test.ts +67 -0
  8. package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
  9. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
  10. package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
  11. package/src/calls/call-controller.ts +30 -2
  12. package/src/calls/call-speech-output.ts +12 -4
  13. package/src/calls/call-transport.ts +18 -1
  14. package/src/calls/media-stream-output.ts +53 -5
  15. package/src/calls/media-stream-server.ts +12 -0
  16. package/src/calls/media-stream-stt-session.ts +34 -0
  17. package/src/calls/telephony-synthesis-language.ts +84 -0
  18. package/src/calls/tts-text-sanitizer.ts +12 -5
  19. package/src/calls/voice-session-bridge.ts +31 -3
  20. package/src/calls/voice-triage-escalate.ts +52 -5
  21. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
  22. package/src/config/loader.ts +5 -0
  23. package/src/config/schemas/calls.ts +0 -4
  24. package/src/config/schemas/tts.ts +63 -0
  25. package/src/live-voice/__tests__/front-decision.test.ts +120 -0
  26. package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
  27. package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
  28. package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
  29. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
  30. package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
  31. package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
  32. package/src/live-voice/front-decision.ts +50 -3
  33. package/src/live-voice/live-voice-session.ts +202 -25
  34. package/src/live-voice/live-voice-tts.ts +18 -2
  35. package/src/live-voice/progress-phrases.ts +105 -2
  36. package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
  37. package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
  38. package/src/providers/speech-to-text/provider-catalog.ts +38 -0
  39. package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
  40. package/src/runtime/assistant-event-hub.ts +23 -0
  41. package/src/runtime/local-actor-identity.ts +18 -5
  42. package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
  43. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
  44. package/src/runtime/routes/events-routes.ts +17 -16
  45. package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
  46. package/src/stt/__tests__/language-metadata.test.ts +85 -0
  47. package/src/stt/language-metadata.ts +65 -0
  48. package/src/stt/types.ts +16 -0
  49. package/src/tts/__tests__/provider-adapters.test.ts +147 -0
  50. package/src/tts/__tests__/speakable-segments.test.ts +470 -0
  51. package/src/tts/language-voices.ts +23 -0
  52. package/src/tts/providers/deepgram-provider.ts +3 -1
  53. package/src/tts/providers/elevenlabs-provider.ts +73 -1
  54. package/src/tts/providers/xai-provider.ts +28 -2
  55. package/src/tts/speakable-segments.ts +293 -23
  56. package/src/tts/synthesis-stream.ts +7 -0
  57. package/src/tts/types.ts +7 -0
  58. package/src/util/__tests__/language-subtag.test.ts +54 -0
  59. package/src/util/language-subtag.ts +43 -0
  60. package/src/util/unicode.ts +1 -1
@@ -549,6 +549,81 @@ describe("ElevenLabs TTS provider adapter", () => {
549
549
 
550
550
  const body = JSON.parse(capturedBody);
551
551
  expect(body.model_id).toBe("eleven_flash_v2_5");
552
+ expect("language_code" in body).toBe(false);
553
+ });
554
+
555
+ test("synthesizeStream sends language_code when the effective model supports enforcement", async () => {
556
+ let capturedBody = "";
557
+
558
+ globalThis.fetch = mock(
559
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
560
+ capturedBody = init?.body as string;
561
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
562
+ },
563
+ ) as unknown as typeof globalThis.fetch;
564
+
565
+ const provider = createElevenLabsProvider();
566
+ await provider.synthesizeStream!(makeRequest({ language: "hi" }), () => {});
567
+
568
+ const body = JSON.parse(capturedBody);
569
+ expect(body.model_id).toBe("eleven_flash_v2_5");
570
+ expect(body.language_code).toBe("hi");
571
+ });
572
+
573
+ test("maps the tl subtag to the fil code the v2.5 roster spells Filipino with", async () => {
574
+ let capturedBody = "";
575
+
576
+ globalThis.fetch = mock(
577
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
578
+ capturedBody = init?.body as string;
579
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
580
+ },
581
+ ) as unknown as typeof globalThis.fetch;
582
+
583
+ const provider = createElevenLabsProvider();
584
+ await provider.synthesizeStream!(makeRequest({ language: "tl" }), () => {});
585
+
586
+ const body = JSON.parse(capturedBody);
587
+ expect(body.model_id).toBe("eleven_flash_v2_5");
588
+ expect(body.language_code).toBe("fil");
589
+ });
590
+
591
+ test("omits language_code for a language the v2.5 models do not support", async () => {
592
+ let capturedBody = "";
593
+
594
+ globalThis.fetch = mock(
595
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
596
+ capturedBody = init?.body as string;
597
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
598
+ },
599
+ ) as unknown as typeof globalThis.fetch;
600
+
601
+ const provider = createElevenLabsProvider();
602
+ await provider.synthesizeStream!(makeRequest({ language: "he" }), () => {});
603
+
604
+ const body = JSON.parse(capturedBody);
605
+ expect(body.model_id).toBe("eleven_flash_v2_5");
606
+ expect("language_code" in body).toBe(false);
607
+ });
608
+
609
+ test("omits language_code when the configured model does not support enforcement", async () => {
610
+ mockElevenLabsConfig.voiceModelId = "eleven_multilingual_v2";
611
+ seedTtsConfig();
612
+ let capturedBody = "";
613
+
614
+ globalThis.fetch = mock(
615
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
616
+ capturedBody = init?.body as string;
617
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
618
+ },
619
+ ) as unknown as typeof globalThis.fetch;
620
+
621
+ const provider = createElevenLabsProvider();
622
+ await provider.synthesizeStream!(makeRequest({ language: "hi" }), () => {});
623
+
624
+ const body = JSON.parse(capturedBody);
625
+ expect(body.model_id).toBe("eleven_multilingual_v2");
626
+ expect("language_code" in body).toBe(false);
552
627
  });
553
628
 
554
629
  test("synthesizeStream respects configured voiceModelId over flash default", async () => {
@@ -1179,6 +1254,22 @@ describe("Deepgram TTS provider adapter", () => {
1179
1254
  expect(body.text).toBe("Hello world");
1180
1255
  });
1181
1256
 
1257
+ test("request voiceId overrides the configured Deepgram model", async () => {
1258
+ let capturedUrl = "";
1259
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
1260
+ capturedUrl = typeof input === "string" ? input : input.toString();
1261
+ return new Response(new Uint8Array([0x49, 0x44, 0x33]), { status: 200 });
1262
+ }) as unknown as typeof globalThis.fetch;
1263
+
1264
+ const provider = createDeepgramProvider();
1265
+ await provider.synthesize(makeRequest({ voiceId: "aura-2-thalia-en" }));
1266
+ expect(capturedUrl).toContain("model=aura-2-thalia-en");
1267
+
1268
+ // A blank request voiceId falls back to the configured model.
1269
+ await provider.synthesize(makeRequest({ voiceId: " " }));
1270
+ expect(capturedUrl).toContain("model=aura-asteria-en");
1271
+ });
1272
+
1182
1273
  test("uses linear16 encoding with container=none and sample_rate=16000 when outputFormat is pcm", async () => {
1183
1274
  const audioPayload = new Uint8Array([0x00, 0x01, 0x02]);
1184
1275
  let capturedUrl = "";
@@ -1685,6 +1776,42 @@ describe("xAI TTS provider adapter", () => {
1685
1776
  expect(body.voice_id).toBe("ara");
1686
1777
  });
1687
1778
 
1779
+ test("request language overrides config language", async () => {
1780
+ const audioPayload = new Uint8Array([0x49, 0x44, 0x33]);
1781
+ let capturedBody = "";
1782
+
1783
+ globalThis.fetch = mock(
1784
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
1785
+ capturedBody = init?.body as string;
1786
+ return new Response(audioPayload, { status: 200 });
1787
+ },
1788
+ ) as unknown as typeof globalThis.fetch;
1789
+
1790
+ const provider = createXaiProvider();
1791
+ await provider.synthesize(makeRequest({ language: "hi" }));
1792
+
1793
+ const body = JSON.parse(capturedBody);
1794
+ expect(body.language).toBe("hi");
1795
+ });
1796
+
1797
+ test("keeps configured language for a request code outside the code-switching roster", async () => {
1798
+ const audioPayload = new Uint8Array([0x49, 0x44, 0x33]);
1799
+ let capturedBody = "";
1800
+
1801
+ globalThis.fetch = mock(
1802
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
1803
+ capturedBody = init?.body as string;
1804
+ return new Response(audioPayload, { status: 200 });
1805
+ },
1806
+ ) as unknown as typeof globalThis.fetch;
1807
+
1808
+ const provider = createXaiProvider();
1809
+ await provider.synthesize(makeRequest({ language: "th" }));
1810
+
1811
+ const body = JSON.parse(capturedBody);
1812
+ expect(body.language).toBe("auto");
1813
+ });
1814
+
1688
1815
  test("wav config format produces codec=wav without bit_rate", async () => {
1689
1816
  mockXaiConfig.format = "wav";
1690
1817
  seedTtsConfig();
@@ -1902,6 +2029,26 @@ describe("xAI TTS provider adapter", () => {
1902
2029
  expect(capturedSocketOptions().url).toContain("sample_rate=22050");
1903
2030
  });
1904
2031
 
2032
+ test("request language overrides config language in the stream URL", async () => {
2033
+ const provider = createXaiProvider();
2034
+ await provider.synthesizeStream!(
2035
+ makeRequest({ language: "es" }),
2036
+ () => {},
2037
+ );
2038
+
2039
+ expect(capturedSocketOptions().url).toContain("language=es");
2040
+ });
2041
+
2042
+ test("keeps configured language in the stream URL for an out-of-roster request code", async () => {
2043
+ const provider = createXaiProvider();
2044
+ await provider.synthesizeStream!(
2045
+ makeRequest({ language: "th" }),
2046
+ () => {},
2047
+ );
2048
+
2049
+ expect(capturedSocketOptions().url).toContain("language=auto");
2050
+ });
2051
+
1905
2052
  test("mp3 requests carry codec=mp3 and bit_rate and resolve audio/mpeg", async () => {
1906
2053
  const provider = createXaiProvider();
1907
2054
  const result = await provider.synthesizeStream!(makeRequest(), () => {});
@@ -1,10 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { sanitizeForTts } from "../../calls/tts-text-sanitizer.js";
3
4
  import { extractSpeakableSegments } from "../speakable-segments.js";
4
5
 
5
6
  const DEFAULT_CHAR_THRESHOLD = 180;
6
7
  const EAGER_CHAR_THRESHOLD = 60;
7
8
 
9
+ // The compile target's lib predates String.prototype.isWellFormed; Bun
10
+ // supports it at runtime.
11
+ function isWellFormedUtf16(value: string): boolean {
12
+ return (value as string & { isWellFormed(): boolean }).isWellFormed();
13
+ }
14
+
8
15
  describe("extractSpeakableSegments", () => {
9
16
  test("splits complete sentences and keeps the trailing fragment as remainder", () => {
10
17
  const { segments, remainder } = extractSpeakableSegments(
@@ -36,6 +43,16 @@ describe("extractSpeakableSegments", () => {
36
43
  expect(remainder).toBe("Version 3.5 is out");
37
44
  });
38
45
 
46
+ test("does not split inside a decimal number", () => {
47
+ const { segments, remainder } = extractSpeakableSegments(
48
+ "3.14 is pi.",
49
+ false,
50
+ );
51
+
52
+ expect(segments).toEqual(["3.14 is pi."]);
53
+ expect(remainder).toBe("");
54
+ });
55
+
39
56
  test("treats a newline as a segment boundary", () => {
40
57
  const { segments, remainder } = extractSpeakableSegments(
41
58
  "First line\nsecond line still going",
@@ -185,6 +202,459 @@ describe("extractSpeakableSegments", () => {
185
202
  });
186
203
  });
187
204
 
205
+ describe("non-Latin scripts", () => {
206
+ test("splits Hindi at the danda", () => {
207
+ const { segments, remainder } = extractSpeakableSegments(
208
+ "नमस्ते। आप कैसे हैं?",
209
+ false,
210
+ );
211
+
212
+ expect(segments).toEqual(["नमस्ते।", "आप कैसे हैं?"]);
213
+ expect(remainder).toBe("");
214
+ });
215
+
216
+ test("splits Japanese at the ideographic full stop with no whitespace", () => {
217
+ const { segments, remainder } = extractSpeakableSegments(
218
+ "こんにちは。今日はいい天気ですね。続き",
219
+ false,
220
+ );
221
+
222
+ expect(segments).toEqual(["こんにちは。", "今日はいい天気ですね。"]);
223
+ expect(remainder).toBe("続き");
224
+ });
225
+
226
+ test("a buffer ending at a non-Latin ender keeps buffering for closers", () => {
227
+ // Streaming deltas can split a quoted sentence: `「こんにちは。`
228
+ // arrives first and `」次です。` in the next delta. A boundary at the
229
+ // buffer-final 。 would orphan the 」 into the next segment.
230
+ const firstDelta = extractSpeakableSegments("「こんにちは。", false);
231
+ expect(firstDelta.segments).toEqual([]);
232
+ expect(firstDelta.remainder).toBe("「こんにちは。");
233
+
234
+ const afterNextDelta = extractSpeakableSegments(
235
+ `${firstDelta.remainder}」次です。`,
236
+ false,
237
+ );
238
+ expect(afterNextDelta.segments).toEqual(["「こんにちは。」"]);
239
+ expect(afterNextDelta.remainder).toBe("次です。");
240
+ });
241
+
242
+ test("force still flushes a buffer ending at an ideographic full stop", () => {
243
+ const { segments, remainder } = extractSpeakableSegments(
244
+ "終わりです。",
245
+ true,
246
+ );
247
+
248
+ expect(segments).toEqual(["終わりです。"]);
249
+ expect(remainder).toBe("");
250
+ });
251
+
252
+ test("the Arabic question mark terminates a segment", () => {
253
+ const { segments, remainder } = extractSpeakableSegments(
254
+ "كيف حالك؟ الطقس جميل",
255
+ false,
256
+ );
257
+
258
+ expect(segments).toEqual(["كيف حالك؟"]);
259
+ expect(remainder).toBe(" الطقس جميل");
260
+ });
261
+
262
+ test("eager mode splits at an ideographic comma with no whitespace", () => {
263
+ const text = `${"あ".repeat(30)}、まだ続く`;
264
+
265
+ const { segments, remainder } = extractSpeakableSegments(text, false, {
266
+ eager: true,
267
+ });
268
+
269
+ expect(segments).toEqual([`${"あ".repeat(30)}、`]);
270
+ expect(remainder).toBe("まだ続く");
271
+ });
272
+
273
+ test("eager mode keeps a fullwidth grouping comma inside a number", () => {
274
+ const text =
275
+ "これは長い導入文で二十四文字を確実に超えています1,000円です";
276
+
277
+ const { segments, remainder } = extractSpeakableSegments(text, false, {
278
+ eager: true,
279
+ });
280
+
281
+ expect(segments).toEqual([]);
282
+ expect(remainder).toBe(text);
283
+ });
284
+
285
+ test("eager mode defers a digit-final fullwidth comma at the buffer edge", () => {
286
+ // The grouped digits may arrive in the next delta (`1,` then
287
+ // `000円`), so a buffer-final comma after a digit keeps buffering.
288
+ const text = "これは長い導入文で二十四文字を確実に超えています1,";
289
+
290
+ const { segments, remainder } = extractSpeakableSegments(text, false, {
291
+ eager: true,
292
+ });
293
+
294
+ expect(segments).toEqual([]);
295
+ expect(remainder).toBe(text);
296
+ });
297
+
298
+ test("eager mode still splits at a fullwidth comma between non-digits", () => {
299
+ const text = `${"あ".repeat(30)},まだ続く`;
300
+
301
+ const { segments, remainder } = extractSpeakableSegments(text, false, {
302
+ eager: true,
303
+ });
304
+
305
+ expect(segments).toEqual([`${"あ".repeat(30)},`]);
306
+ expect(remainder).toBe("まだ続く");
307
+ });
308
+
309
+ test("hard-cap splits never break a surrogate pair", () => {
310
+ // "犬" shifts every non-BMP pair to an odd offset so the 180-unit cap
311
+ // lands mid-pair without the step-back.
312
+ const text = `犬${"𠮟".repeat(120)}`;
313
+
314
+ const { segments, remainder } = extractSpeakableSegments(text, false);
315
+
316
+ expect(segments).toHaveLength(1);
317
+ for (const segment of segments) {
318
+ expect(isWellFormedUtf16(segment)).toBe(true);
319
+ }
320
+ expect(isWellFormedUtf16(remainder)).toBe(true);
321
+ // No text is lost or reordered across the split.
322
+ expect(segments.join("") + remainder).toBe(text);
323
+ });
324
+
325
+ test("accented italic spans toggle like ASCII ones", () => {
326
+ const { segments, remainder } = extractSpeakableSegments(
327
+ "*café. crème* done. And more",
328
+ false,
329
+ );
330
+
331
+ expect(segments).toEqual(["*café. crème* done."]);
332
+ expect(remainder).toBe(" And more");
333
+ });
334
+
335
+ test("CJK underscore spans toggle like ASCII ones", () => {
336
+ const { segments, remainder } = extractSpeakableSegments(
337
+ "_変数_ です。まだ続きます",
338
+ false,
339
+ );
340
+
341
+ expect(segments).toEqual(["_変数_ です。"]);
342
+ expect(remainder).toBe("まだ続きます");
343
+ });
344
+
345
+ test("does not split a fullwidth decimal number", () => {
346
+ const { segments, remainder } = extractSpeakableSegments(
347
+ "3.14です",
348
+ false,
349
+ );
350
+
351
+ expect(segments).toEqual([]);
352
+ expect(remainder).toBe("3.14です");
353
+ });
354
+
355
+ test("a buffer ending exactly at a fullwidth full stop keeps buffering", () => {
356
+ // Streaming deltas can split a fullwidth decimal: `3.` arrives
357
+ // first and `14です` in the next delta. The trailing .must not be
358
+ // classified as a sentence boundary until the next character shows
359
+ // whether it is a decimal point.
360
+ const firstDelta = extractSpeakableSegments("3.", false);
361
+ expect(firstDelta.segments).toEqual([]);
362
+ expect(firstDelta.remainder).toBe("3.");
363
+
364
+ const afterNextDelta = extractSpeakableSegments(
365
+ `${firstDelta.remainder}14です。次`,
366
+ false,
367
+ );
368
+ expect(afterNextDelta.segments).toEqual(["3.14です。"]);
369
+ expect(afterNextDelta.remainder).toBe("次");
370
+ });
371
+
372
+ test("force still flushes a buffer ending at a fullwidth full stop", () => {
373
+ const { segments, remainder } = extractSpeakableSegments("3.", true);
374
+
375
+ expect(segments).toEqual(["3."]);
376
+ expect(remainder).toBe("");
377
+ });
378
+
379
+ test("the fullwidth full stop still ends a genuine sentence", () => {
380
+ const { segments, remainder } = extractSpeakableSegments(
381
+ "終わりです.次です",
382
+ false,
383
+ );
384
+
385
+ expect(segments).toEqual(["終わりです."]);
386
+ expect(remainder).toBe("次です");
387
+ });
388
+
389
+ test("the halfwidth ideographic full stop ends a sentence", () => {
390
+ const { segments, remainder } = extractSpeakableSegments(
391
+ "こんにちは。次です",
392
+ false,
393
+ );
394
+
395
+ expect(segments).toEqual(["こんにちは。"]);
396
+ expect(remainder).toBe("次です");
397
+ });
398
+
399
+ test("non-Latin enders inside a Markdown link URL do not split", () => {
400
+ const { segments, remainder } = extractSpeakableSegments(
401
+ "[記事](https://example.com/前編。後編)を読んで。次",
402
+ false,
403
+ );
404
+
405
+ expect(segments).toEqual([
406
+ "[記事](https://example.com/前編。後編)を読んで。",
407
+ ]);
408
+ expect(remainder).toBe("次");
409
+ });
410
+
411
+ test("an unclosed link URL still splits at a newline", () => {
412
+ const { segments, remainder } = extractSpeakableSegments(
413
+ "[x](https://a。b\n次",
414
+ false,
415
+ );
416
+
417
+ expect(segments).toEqual(["[x](https://a。b"]);
418
+ expect(remainder).toBe("次");
419
+ });
420
+
421
+ test("balanced parens inside a link URL keep suppressing boundaries", () => {
422
+ // The sanitizer's link pattern accepts balanced parens inside URLs
423
+ // (Wikipedia-style paths), so the segmenter must not exit the URL at
424
+ // the inner closing paren and split at a later terminator.
425
+ const { segments, remainder } = extractSpeakableSegments(
426
+ "[記事](https://example.com/a_(b)/前編。後編)を読んで。次",
427
+ false,
428
+ );
429
+
430
+ expect(segments).toEqual([
431
+ "[記事](https://example.com/a_(b)/前編。後編)を読んで。",
432
+ ]);
433
+ expect(remainder).toBe("次");
434
+ });
435
+
436
+ test("non-Latin enders inside a Markdown link label do not split the link", () => {
437
+ const { segments, remainder } = extractSpeakableSegments(
438
+ "[詳細。こちら](https://example.com)を確認。次",
439
+ false,
440
+ );
441
+
442
+ expect(segments).toEqual(["[詳細。こちら](https://example.com)を確認。"]);
443
+ expect(remainder).toBe("次");
444
+ // The intact link sanitizes to its label; a split would have left
445
+ // markup or the raw URL in a fragment.
446
+ expect(sanitizeForTts(segments[0] ?? "")).toBe("詳細。こちらを確認。");
447
+ });
448
+
449
+ test("enders inside image alt text do not split the image link", () => {
450
+ const { segments, remainder } = extractSpeakableSegments(
451
+ "![代替。テキスト](https://example.com/a.png)です。次",
452
+ false,
453
+ );
454
+
455
+ expect(segments).toEqual([
456
+ "![代替。テキスト](https://example.com/a.png)です。",
457
+ ]);
458
+ expect(remainder).toBe("次");
459
+ });
460
+
461
+ test("a bracket inside an inline code span does not suppress the sentence boundary", () => {
462
+ const { segments, remainder } = extractSpeakableSegments(
463
+ "Use `const items = [` to start. Then close it later on.",
464
+ false,
465
+ );
466
+
467
+ expect(segments).toEqual([
468
+ "Use `const items = [` to start.",
469
+ "Then close it later on.",
470
+ ]);
471
+ expect(remainder).toBe("");
472
+ });
473
+
474
+ test("a stray bracket splits at the suppressed boundary once a newline cancels it", () => {
475
+ const { segments, remainder } = extractSpeakableSegments(
476
+ "これは[メモ。次です。\nそして",
477
+ false,
478
+ );
479
+
480
+ expect(segments).toEqual(["これは[メモ。", "次です。"]);
481
+ expect(remainder).toBe("そして");
482
+ });
483
+
484
+ test("a closing bracket without a paren reopens the suppressed boundary", () => {
485
+ const { segments, remainder } = extractSpeakableSegments(
486
+ "これは[メモ。です]ね。次",
487
+ false,
488
+ );
489
+
490
+ expect(segments).toEqual(["これは[メモ。", "です]ね。"]);
491
+ expect(remainder).toBe("次");
492
+ });
493
+
494
+ test("a stray bracket in a long buffer splits at the suppressed boundary at the cap", () => {
495
+ const filler = "あ".repeat(DEFAULT_CHAR_THRESHOLD);
496
+ const { segments, remainder } = extractSpeakableSegments(
497
+ `これは[メモ。${filler}`,
498
+ false,
499
+ );
500
+
501
+ expect(segments).toEqual(["これは[メモ。", filler]);
502
+ expect(remainder).toBe("");
503
+ });
504
+
505
+ test("a label split across deltas defers, then completes", () => {
506
+ const firstDelta = extractSpeakableSegments("[詳細。こち", false);
507
+ expect(firstDelta.segments).toEqual([]);
508
+ expect(firstDelta.remainder).toBe("[詳細。こち");
509
+
510
+ const afterNextDelta = extractSpeakableSegments(
511
+ `${firstDelta.remainder}ら](https://example.com)を確認。次`,
512
+ false,
513
+ );
514
+ expect(afterNextDelta.segments).toEqual([
515
+ "[詳細。こちら](https://example.com)を確認。",
516
+ ]);
517
+ expect(afterNextDelta.remainder).toBe("次");
518
+ });
519
+
520
+ test("a buffer-final closing bracket keeps buffering for the paren", () => {
521
+ const firstDelta = extractSpeakableSegments("[詳細。こちら]", false);
522
+ expect(firstDelta.segments).toEqual([]);
523
+ expect(firstDelta.remainder).toBe("[詳細。こちら]");
524
+
525
+ const afterNextDelta = extractSpeakableSegments(
526
+ `${firstDelta.remainder}(https://example.com)を確認。次`,
527
+ false,
528
+ );
529
+ expect(afterNextDelta.segments).toEqual([
530
+ "[詳細。こちら](https://example.com)を確認。",
531
+ ]);
532
+ expect(afterNextDelta.remainder).toBe("次");
533
+ });
534
+
535
+ test("force flushes a buffer-final open label", () => {
536
+ const openLabel = extractSpeakableSegments("[詳細。こち", true);
537
+ expect(openLabel.segments).toEqual(["[詳細。こち"]);
538
+ expect(openLabel.remainder).toBe("");
539
+
540
+ const openUrl = extractSpeakableSegments(
541
+ "[詳細。こちら](https://exa",
542
+ true,
543
+ );
544
+ expect(openUrl.segments).toEqual(["[詳細。こちら](https://exa"]);
545
+ expect(openUrl.remainder).toBe("");
546
+ });
547
+
548
+ test("adjacent enders stay in one segment", () => {
549
+ // 本当!? must not emit 本当! and then a punctuation-only ? segment.
550
+ const { segments, remainder } = extractSpeakableSegments(
551
+ "本当!?次です",
552
+ false,
553
+ );
554
+
555
+ expect(segments).toEqual(["本当!?"]);
556
+ expect(remainder).toBe("次です");
557
+ });
558
+
559
+ test("adjacent enders followed by a closer stay together", () => {
560
+ const { segments, remainder } = extractSpeakableSegments(
561
+ "「本当!?」次です",
562
+ false,
563
+ );
564
+
565
+ expect(segments).toEqual(["「本当!?」"]);
566
+ expect(remainder).toBe("次です");
567
+ });
568
+
569
+ test("a fullwidth stop inside a filename-style token does not split", () => {
570
+ const { segments, remainder } = extractSpeakableSegments(
571
+ "添付のreport.pdfを見てください。次です",
572
+ false,
573
+ );
574
+
575
+ expect(segments).toEqual(["添付のreport.pdfを見てください。"]);
576
+ expect(remainder).toBe("次です");
577
+ });
578
+
579
+ test("keeps a CJK closing bracket with its sentence", () => {
580
+ const { segments, remainder } = extractSpeakableSegments(
581
+ "「こんにちは。」次です。",
582
+ false,
583
+ );
584
+
585
+ expect(segments).toEqual(["「こんにちは。」"]);
586
+ // The trailing sentence ends at the buffer edge, so it keeps
587
+ // buffering in case the next delta opens with a closer.
588
+ expect(remainder).toBe("次です。");
589
+ });
590
+
591
+ test("keeps a CJK double angle bracket with its sentence", () => {
592
+ const { segments, remainder } = extractSpeakableSegments(
593
+ "《こんにちは。》次です。",
594
+ false,
595
+ );
596
+
597
+ expect(segments).toEqual(["《こんにちは。》"]);
598
+ expect(remainder).toBe("次です。");
599
+ });
600
+
601
+ test("keeps a closing curly quote with its sentence", () => {
602
+ const { segments, remainder } = extractSpeakableSegments(
603
+ "“早く!”と言った。まだ続く",
604
+ false,
605
+ );
606
+
607
+ expect(segments).toEqual(["“早く!”", "と言った。"]);
608
+ expect(remainder).toBe("まだ続く");
609
+ });
610
+
611
+ test("a span closer touching an astral word char keeps the span open", () => {
612
+ // 𠮟 is a non-BMP letter (surrogate pair). The sanitizer's Unicode
613
+ // lookarounds see it as \p{L} and leave *note*𠮟 unstripped, so the
614
+ // segmenter must agree and defer the boundary at the period.
615
+ const text = "*note*𠮟. Next";
616
+
617
+ const { segments, remainder } = extractSpeakableSegments(text, false);
618
+
619
+ expect(segments).toEqual([]);
620
+ expect(remainder).toBe(text);
621
+
622
+ // Forced flush keeps the whole span in one segment, so per-segment
623
+ // sanitization matches whole-text sanitization: no unbalanced marker
624
+ // is spoken.
625
+ const forced = extractSpeakableSegments(text, true);
626
+ expect(forced.segments.map(sanitizeForTts).join("")).toBe(
627
+ sanitizeForTts(text),
628
+ );
629
+ });
630
+
631
+ test("astral underscore spans toggle like BMP ones", () => {
632
+ // prev/next of both `_` markers are surrogate-pair halves; assembled
633
+ // code points make the span open and close exactly as the sanitizer's
634
+ // lookarounds do, so the split after 。 leaks no markers.
635
+ const { segments, remainder } = extractSpeakableSegments(
636
+ "_𠮟_ です。まだ続く",
637
+ false,
638
+ );
639
+
640
+ expect(segments).toEqual(["_𠮟_ です。"]);
641
+ expect(remainder).toBe("まだ続く");
642
+ // The sanitizer strips the balanced span, so nothing leaks.
643
+ expect(sanitizeForTts(segments[0] ?? "")).toBe("𠮟 です。");
644
+ });
645
+
646
+ test("a span closer touching a CJK word char keeps the span open", () => {
647
+ // Mirrors the sanitizer's Unicode lookarounds: it leaves *強調*です
648
+ // unstripped, so splitting after the 。 would leak the markers.
649
+ const text = "*強調*です。まだ続く";
650
+
651
+ const { segments, remainder } = extractSpeakableSegments(text, false);
652
+
653
+ expect(segments).toEqual([]);
654
+ expect(remainder).toBe(text);
655
+ });
656
+ });
657
+
188
658
  describe("open inline spans", () => {
189
659
  test("does not split at a clause boundary inside an open bold span", () => {
190
660
  const { segments, remainder } = extractSpeakableSegments(
@@ -0,0 +1,23 @@
1
+ import { localizedOrDefault } from "../util/language-subtag.js";
2
+
3
+ /**
4
+ * Look up a per-language voice override in a provider config's
5
+ * `languageVoices` map. The config schema normalizes map keys to lowercase
6
+ * base subtags at parse time, so this is a single exact lookup on the
7
+ * language's base subtag. Returns undefined when the language is unset,
8
+ * the map has no entry for it, or the entry is blank.
9
+ */
10
+ export function resolveLanguageVoiceOverride(
11
+ languageVoices: Readonly<Record<string, string>> | undefined,
12
+ language: string | undefined,
13
+ ): string | undefined {
14
+ if (!languageVoices) {
15
+ return undefined;
16
+ }
17
+ const voice = localizedOrDefault<string | undefined>(
18
+ languageVoices,
19
+ language,
20
+ undefined,
21
+ );
22
+ return voice?.trim() || undefined;
23
+ }