@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.
- package/package.json +1 -1
- package/src/__tests__/call-controller.test.ts +120 -0
- package/src/__tests__/config-loader-backfill.test.ts +19 -0
- package/src/__tests__/config-schema.test.ts +139 -5
- package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
- package/src/__tests__/media-stream-output.test.ts +175 -0
- package/src/__tests__/media-stream-stt-session.test.ts +67 -0
- package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
- package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
- package/src/calls/call-controller.ts +30 -2
- package/src/calls/call-speech-output.ts +12 -4
- package/src/calls/call-transport.ts +18 -1
- package/src/calls/media-stream-output.ts +53 -5
- package/src/calls/media-stream-server.ts +12 -0
- package/src/calls/media-stream-stt-session.ts +34 -0
- package/src/calls/telephony-synthesis-language.ts +84 -0
- package/src/calls/tts-text-sanitizer.ts +12 -5
- package/src/calls/voice-session-bridge.ts +31 -3
- package/src/calls/voice-triage-escalate.ts +52 -5
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
- package/src/config/loader.ts +5 -0
- package/src/config/schemas/calls.ts +0 -4
- package/src/config/schemas/tts.ts +63 -0
- package/src/live-voice/__tests__/front-decision.test.ts +120 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
- package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
- package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
- package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
- package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
- package/src/live-voice/front-decision.ts +50 -3
- package/src/live-voice/live-voice-session.ts +202 -25
- package/src/live-voice/live-voice-tts.ts +18 -2
- package/src/live-voice/progress-phrases.ts +105 -2
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
- package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
- package/src/providers/speech-to-text/provider-catalog.ts +38 -0
- package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/local-actor-identity.ts +18 -5
- package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
- package/src/runtime/routes/events-routes.ts +17 -16
- package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
- package/src/stt/__tests__/language-metadata.test.ts +85 -0
- package/src/stt/language-metadata.ts +65 -0
- package/src/stt/types.ts +16 -0
- package/src/tts/__tests__/provider-adapters.test.ts +147 -0
- package/src/tts/__tests__/speakable-segments.test.ts +470 -0
- package/src/tts/language-voices.ts +23 -0
- package/src/tts/providers/deepgram-provider.ts +3 -1
- package/src/tts/providers/elevenlabs-provider.ts +73 -1
- package/src/tts/providers/xai-provider.ts +28 -2
- package/src/tts/speakable-segments.ts +293 -23
- package/src/tts/synthesis-stream.ts +7 -0
- package/src/tts/types.ts +7 -0
- package/src/util/__tests__/language-subtag.test.ts +54 -0
- package/src/util/language-subtag.ts +43 -0
- 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
|
+
"です。次",
|
|
452
|
+
false,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
expect(segments).toEqual([
|
|
456
|
+
"です。",
|
|
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
|
+
}
|