@vellumai/assistant 0.11.2-dev.202608072015.e50ee9a → 0.11.2-dev.202608072144.9eaee43
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/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- 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 +142 -5
- 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/channels/config.ts +13 -0
- package/src/channels/types.ts +4 -0
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
- package/src/config/loader.ts +5 -0
- package/src/config/schemas/__tests__/live-voice.test.ts +35 -0
- package/src/config/schemas/calls.ts +0 -4
- package/src/config/schemas/live-voice.ts +25 -0
- 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-integration.test.ts +5 -0
- 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__/live-voice-vad.test.ts +278 -1
- 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 +517 -45
- package/src/live-voice/live-voice-tts.ts +18 -2
- package/src/live-voice/progress-phrases.ts +105 -2
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +196 -0
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +3 -0
- package/src/mcp/mcp-auth-orchestrator.ts +5 -2
- package/src/mcp/mcp-oauth-provider.ts +146 -7
- package/src/notifications/signal.ts +1 -0
- 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/stt/__tests__/language-metadata.test.ts +85 -0
- package/src/stt/__tests__/speech-energy.test.ts +79 -0
- package/src/stt/language-metadata.ts +65 -0
- package/src/stt/speech-energy.ts +115 -12
- 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
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
* assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
|
|
7
7
|
* internal ids (`vellum` for native app conversations, `platform` for the
|
|
8
8
|
* internal control plane). This is the single source of truth for that set:
|
|
9
|
+
*
|
|
10
|
+
* One id, `plugin`, does not name a surface: it names *every* surface a plugin
|
|
11
|
+
* brings. A plugin channel's real identity is the plugin, which is workspace
|
|
12
|
+
* state and cannot be a compile-time union member, so the plugin name travels
|
|
13
|
+
* in `sourceMetadata.plugin` and is prefixed onto every external id the gateway
|
|
14
|
+
* forwards (`imessage:+15551234567`). Two plugins therefore share a channel
|
|
15
|
+
* row — one admission floor, one set of channel-wide defaults — while their
|
|
16
|
+
* conversations, contacts, and trust records stay disjoint. See
|
|
17
|
+
* `gateway/src/channels/plugin-inbound.ts` for what that concedes.
|
|
18
|
+
*
|
|
9
19
|
* the assistant adopts it wholesale as its `ChannelId`, and the gateway
|
|
10
20
|
* asserts its own (narrower) inbound list is a subset of it so the two sides
|
|
11
21
|
* cannot silently drift.
|
|
@@ -30,6 +40,7 @@ export const CHANNEL_IDS = [
|
|
|
30
40
|
"platform",
|
|
31
41
|
"a2a",
|
|
32
42
|
"discord",
|
|
43
|
+
"plugin",
|
|
33
44
|
] as const;
|
|
34
45
|
|
|
35
46
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
* assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
|
|
7
7
|
* internal ids (`vellum` for native app conversations, `platform` for the
|
|
8
8
|
* internal control plane). This is the single source of truth for that set:
|
|
9
|
+
*
|
|
10
|
+
* One id, `plugin`, does not name a surface: it names *every* surface a plugin
|
|
11
|
+
* brings. A plugin channel's real identity is the plugin, which is workspace
|
|
12
|
+
* state and cannot be a compile-time union member, so the plugin name travels
|
|
13
|
+
* in `sourceMetadata.plugin` and is prefixed onto every external id the gateway
|
|
14
|
+
* forwards (`imessage:+15551234567`). Two plugins therefore share a channel
|
|
15
|
+
* row — one admission floor, one set of channel-wide defaults — while their
|
|
16
|
+
* conversations, contacts, and trust records stay disjoint. See
|
|
17
|
+
* `gateway/src/channels/plugin-inbound.ts` for what that concedes.
|
|
18
|
+
*
|
|
9
19
|
* the assistant adopts it wholesale as its `ChannelId`, and the gateway
|
|
10
20
|
* asserts its own (narrower) inbound list is a subset of it so the two sides
|
|
11
21
|
* cannot silently drift.
|
|
@@ -30,6 +40,7 @@ export const CHANNEL_IDS = [
|
|
|
30
40
|
"platform",
|
|
31
41
|
"a2a",
|
|
32
42
|
"discord",
|
|
43
|
+
"plugin",
|
|
33
44
|
] as const;
|
|
34
45
|
|
|
35
46
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
* assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
|
|
7
7
|
* internal ids (`vellum` for native app conversations, `platform` for the
|
|
8
8
|
* internal control plane). This is the single source of truth for that set:
|
|
9
|
+
*
|
|
10
|
+
* One id, `plugin`, does not name a surface: it names *every* surface a plugin
|
|
11
|
+
* brings. A plugin channel's real identity is the plugin, which is workspace
|
|
12
|
+
* state and cannot be a compile-time union member, so the plugin name travels
|
|
13
|
+
* in `sourceMetadata.plugin` and is prefixed onto every external id the gateway
|
|
14
|
+
* forwards (`imessage:+15551234567`). Two plugins therefore share a channel
|
|
15
|
+
* row — one admission floor, one set of channel-wide defaults — while their
|
|
16
|
+
* conversations, contacts, and trust records stay disjoint. See
|
|
17
|
+
* `gateway/src/channels/plugin-inbound.ts` for what that concedes.
|
|
18
|
+
*
|
|
9
19
|
* the assistant adopts it wholesale as its `ChannelId`, and the gateway
|
|
10
20
|
* asserts its own (narrower) inbound list is a subset of it so the two sides
|
|
11
21
|
* cannot silently drift.
|
|
@@ -30,6 +40,7 @@ export const CHANNEL_IDS = [
|
|
|
30
40
|
"platform",
|
|
31
41
|
"a2a",
|
|
32
42
|
"discord",
|
|
43
|
+
"plugin",
|
|
33
44
|
] as const;
|
|
34
45
|
|
|
35
46
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
package/package.json
CHANGED
|
@@ -377,6 +377,8 @@ function setupController(
|
|
|
377
377
|
requiresPcmAudio?: boolean;
|
|
378
378
|
/** Simulate a transport that gates teardown on playback drain. */
|
|
379
379
|
awaitPlaybackDrained?: () => Promise<void>;
|
|
380
|
+
/** Synthesis-language resolver, as the media-stream server wires it. */
|
|
381
|
+
resolveSynthesisLanguage?: () => string | undefined;
|
|
380
382
|
},
|
|
381
383
|
) {
|
|
382
384
|
ensureConversation("conv-ctrl-test");
|
|
@@ -400,6 +402,7 @@ function setupController(
|
|
|
400
402
|
const controller = new CallController(session.id, transport, task ?? null, {
|
|
401
403
|
assistantId: opts?.assistantId,
|
|
402
404
|
trustContext: opts?.trustContext,
|
|
405
|
+
resolveSynthesisLanguage: opts?.resolveSynthesisLanguage,
|
|
403
406
|
});
|
|
404
407
|
return { session, relay: transport, controller };
|
|
405
408
|
}
|
|
@@ -3470,6 +3473,123 @@ describe("call-controller", () => {
|
|
|
3470
3473
|
controller.destroy();
|
|
3471
3474
|
});
|
|
3472
3475
|
|
|
3476
|
+
test("synthesized provider: the resolved language rides every segment's synthesis request", async () => {
|
|
3477
|
+
const requestLanguages: Array<string | undefined> = [];
|
|
3478
|
+
registerFishAudioSegmentRecorder({
|
|
3479
|
+
onSynthesizeStream: async (_text, request) => {
|
|
3480
|
+
requestLanguages.push(request.language);
|
|
3481
|
+
},
|
|
3482
|
+
});
|
|
3483
|
+
mockStartVoiceTurn.mockImplementation(
|
|
3484
|
+
createMockVoiceTurn(["Hola desde aqui. ", "Segunda frase."]),
|
|
3485
|
+
);
|
|
3486
|
+
const { controller } = setupController(undefined, {
|
|
3487
|
+
resolveSynthesisLanguage: () => "es",
|
|
3488
|
+
});
|
|
3489
|
+
|
|
3490
|
+
await controller.handleCallerUtterance("Hola");
|
|
3491
|
+
|
|
3492
|
+
expect(requestLanguages).toEqual(["es", "es"]);
|
|
3493
|
+
|
|
3494
|
+
controller.destroy();
|
|
3495
|
+
});
|
|
3496
|
+
|
|
3497
|
+
test("synthesized provider: no language on the request when nothing resolves", async () => {
|
|
3498
|
+
const requestLanguages: Array<string | undefined> = [];
|
|
3499
|
+
registerFishAudioSegmentRecorder({
|
|
3500
|
+
onSynthesizeStream: async (_text, request) => {
|
|
3501
|
+
requestLanguages.push(request.language);
|
|
3502
|
+
},
|
|
3503
|
+
});
|
|
3504
|
+
mockStartVoiceTurn.mockImplementation(
|
|
3505
|
+
createMockVoiceTurn(["Hello from here."]),
|
|
3506
|
+
);
|
|
3507
|
+
// Default resolver + default config (multilingual STT pin): no hint.
|
|
3508
|
+
const { controller } = setupController();
|
|
3509
|
+
|
|
3510
|
+
await controller.handleCallerUtterance("Hi");
|
|
3511
|
+
|
|
3512
|
+
expect(requestLanguages).toEqual([undefined]);
|
|
3513
|
+
|
|
3514
|
+
controller.destroy();
|
|
3515
|
+
});
|
|
3516
|
+
|
|
3517
|
+
function registerDeepgramRequestRecorder(): {
|
|
3518
|
+
requests: Array<{ voiceId?: string; language?: string }>;
|
|
3519
|
+
} {
|
|
3520
|
+
const cfg = loadConfig();
|
|
3521
|
+
cfg.services.tts.provider = "deepgram";
|
|
3522
|
+
|
|
3523
|
+
_resetTtsProviderOverridesForTests();
|
|
3524
|
+
const requests: Array<{ voiceId?: string; language?: string }> = [];
|
|
3525
|
+
const deepgramRecorder: TtsProvider = {
|
|
3526
|
+
id: "deepgram",
|
|
3527
|
+
capabilities: {
|
|
3528
|
+
supportsStreaming: false,
|
|
3529
|
+
supportedFormats: ["mp3", "wav", "opus"],
|
|
3530
|
+
},
|
|
3531
|
+
async synthesize(request) {
|
|
3532
|
+
requests.push({
|
|
3533
|
+
voiceId: request.voiceId,
|
|
3534
|
+
language: request.language,
|
|
3535
|
+
});
|
|
3536
|
+
return { audio: Buffer.from("audio"), contentType: "audio/mpeg" };
|
|
3537
|
+
},
|
|
3538
|
+
};
|
|
3539
|
+
_setTtsProviderForTests(deepgramRecorder);
|
|
3540
|
+
return { requests };
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
test("synthesized provider: a configured languageVoices entry for the call language rides the request as voiceId", async () => {
|
|
3544
|
+
const cfg = loadConfig();
|
|
3545
|
+
cfg.services.tts.providers.deepgram.languageVoices = {
|
|
3546
|
+
hi: "aura-2-hindi-voice",
|
|
3547
|
+
};
|
|
3548
|
+
try {
|
|
3549
|
+
const { requests } = registerDeepgramRequestRecorder();
|
|
3550
|
+
mockStartVoiceTurn.mockImplementation(
|
|
3551
|
+
createMockVoiceTurn(["Namaste doston."]),
|
|
3552
|
+
);
|
|
3553
|
+
const { controller } = setupController(undefined, {
|
|
3554
|
+
resolveSynthesisLanguage: () => "hi",
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
await controller.handleCallerUtterance("Namaste");
|
|
3558
|
+
|
|
3559
|
+
expect(requests).toEqual([
|
|
3560
|
+
{ voiceId: "aura-2-hindi-voice", language: "hi" },
|
|
3561
|
+
]);
|
|
3562
|
+
|
|
3563
|
+
controller.destroy();
|
|
3564
|
+
} finally {
|
|
3565
|
+
delete cfg.services.tts.providers.deepgram.languageVoices;
|
|
3566
|
+
}
|
|
3567
|
+
});
|
|
3568
|
+
|
|
3569
|
+
test("synthesized provider: no voiceId when languageVoices has no entry for the call language", async () => {
|
|
3570
|
+
const cfg = loadConfig();
|
|
3571
|
+
cfg.services.tts.providers.deepgram.languageVoices = {
|
|
3572
|
+
hi: "aura-2-hindi-voice",
|
|
3573
|
+
};
|
|
3574
|
+
try {
|
|
3575
|
+
const { requests } = registerDeepgramRequestRecorder();
|
|
3576
|
+
mockStartVoiceTurn.mockImplementation(
|
|
3577
|
+
createMockVoiceTurn(["Hola desde aqui."]),
|
|
3578
|
+
);
|
|
3579
|
+
const { controller } = setupController(undefined, {
|
|
3580
|
+
resolveSynthesisLanguage: () => "es",
|
|
3581
|
+
});
|
|
3582
|
+
|
|
3583
|
+
await controller.handleCallerUtterance("Hola");
|
|
3584
|
+
|
|
3585
|
+
expect(requests).toEqual([{ voiceId: undefined, language: "es" }]);
|
|
3586
|
+
|
|
3587
|
+
controller.destroy();
|
|
3588
|
+
} finally {
|
|
3589
|
+
delete cfg.services.tts.providers.deepgram.languageVoices;
|
|
3590
|
+
}
|
|
3591
|
+
});
|
|
3592
|
+
|
|
3473
3593
|
test("synthesized provider: segments synthesize serially in order even when the first is slow", async () => {
|
|
3474
3594
|
const events: string[] = [];
|
|
3475
3595
|
registerFishAudioSegmentRecorder({
|
|
@@ -548,6 +548,25 @@ describe("loadConfig startup behavior", () => {
|
|
|
548
548
|
expect(raw.daemon?.standaloneRecording).toBe(false);
|
|
549
549
|
});
|
|
550
550
|
|
|
551
|
+
test("strips calls.voice.language from existing user configs", () => {
|
|
552
|
+
// `calls.voice.language` is a retired no-op knob: nothing read it.
|
|
553
|
+
// The spoken language lives at `services.stt.language` and per-language
|
|
554
|
+
// TTS voices at `services.tts.providers.<id>.languageVoices`. Existing
|
|
555
|
+
// configs that have it written to disk should load cleanly with the
|
|
556
|
+
// field stripped and its siblings preserved.
|
|
557
|
+
writeConfig({
|
|
558
|
+
provider: "anthropic",
|
|
559
|
+
calls: { voice: { language: "es-ES", telephonyStreaming: false } },
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
loadConfig();
|
|
563
|
+
|
|
564
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
565
|
+
expect(raw.calls?.voice?.language).toBeUndefined();
|
|
566
|
+
// Sibling fields under calls.voice are preserved
|
|
567
|
+
expect(raw.calls?.voice?.telephonyStreaming).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
|
|
551
570
|
test("still writes a default config on first launch when file is absent", () => {
|
|
552
571
|
// Discoverability: when no config.json exists, write one populated with
|
|
553
572
|
// all schema defaults so users can see and edit available options.
|
|
@@ -888,7 +888,6 @@ describe("AssistantConfigSchema", () => {
|
|
|
888
888
|
denyCategories: [],
|
|
889
889
|
},
|
|
890
890
|
voice: {
|
|
891
|
-
language: "en-US",
|
|
892
891
|
interruptSensitivity: "low",
|
|
893
892
|
telephonyStreaming: true,
|
|
894
893
|
utteranceEndMs: 1000,
|
|
@@ -933,6 +932,9 @@ describe("AssistantConfigSchema", () => {
|
|
|
933
932
|
silenceThresholdMs: 1200,
|
|
934
933
|
maxTurnDurationMs: 30000,
|
|
935
934
|
bargeInMinSpeechMs: 250,
|
|
935
|
+
echoBargeInMargin: 1.5,
|
|
936
|
+
echoEmaHalfLifeMs: 400,
|
|
937
|
+
echoDrainSlackMs: 300,
|
|
936
938
|
},
|
|
937
939
|
frontModel: {
|
|
938
940
|
endpointDecisionTimeoutMs: 1200,
|
|
@@ -1079,7 +1081,6 @@ describe("AssistantConfigSchema", () => {
|
|
|
1079
1081
|
|
|
1080
1082
|
test("config without calls.voice parses correctly and produces defaults", () => {
|
|
1081
1083
|
const result = AssistantConfigSchema.parse({});
|
|
1082
|
-
expect(result.calls.voice.language).toBe("en-US");
|
|
1083
1084
|
expect(result.calls.voice.interruptSensitivity).toBe("low");
|
|
1084
1085
|
expect(result.calls.voice.telephonyStreaming).toBe(true);
|
|
1085
1086
|
expect(result.calls.voice.utteranceEndMs).toBe(1000);
|
|
@@ -1089,17 +1090,26 @@ describe("AssistantConfigSchema", () => {
|
|
|
1089
1090
|
const result = AssistantConfigSchema.parse({
|
|
1090
1091
|
calls: {
|
|
1091
1092
|
voice: {
|
|
1092
|
-
language: "es-ES",
|
|
1093
1093
|
telephonyStreaming: false,
|
|
1094
1094
|
utteranceEndMs: 2500,
|
|
1095
1095
|
},
|
|
1096
1096
|
},
|
|
1097
1097
|
});
|
|
1098
|
-
expect(result.calls.voice.language).toBe("es-ES");
|
|
1099
1098
|
expect(result.calls.voice.telephonyStreaming).toBe(false);
|
|
1100
1099
|
expect(result.calls.voice.utteranceEndMs).toBe(2500);
|
|
1101
1100
|
});
|
|
1102
1101
|
|
|
1102
|
+
test("language is no longer part of the voice config schema", () => {
|
|
1103
|
+
// The retired knob was read by nothing; Zod strips the unrecognized key
|
|
1104
|
+
// so persisted configs that still carry it keep parsing.
|
|
1105
|
+
const result = AssistantConfigSchema.parse({
|
|
1106
|
+
calls: { voice: { language: "es-ES" } },
|
|
1107
|
+
});
|
|
1108
|
+
expect(
|
|
1109
|
+
(result.calls.voice as Record<string, unknown>).language,
|
|
1110
|
+
).toBeUndefined();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1103
1113
|
test("rejects calls.voice.utteranceEndMs outside the 1000-5000 range", () => {
|
|
1104
1114
|
for (const utteranceEndMs of [999, 5001]) {
|
|
1105
1115
|
const result = AssistantConfigSchema.safeParse({
|
|
@@ -1417,6 +1427,131 @@ describe("AssistantConfigSchema", () => {
|
|
|
1417
1427
|
expect(result.services.tts.providers.deepgram.format).toBe("opus");
|
|
1418
1428
|
});
|
|
1419
1429
|
|
|
1430
|
+
test("accepts services.tts.providers languageVoices maps", () => {
|
|
1431
|
+
const result = AssistantConfigSchema.parse({
|
|
1432
|
+
services: {
|
|
1433
|
+
tts: {
|
|
1434
|
+
providers: {
|
|
1435
|
+
elevenlabs: {
|
|
1436
|
+
languageVoices: { hi: "voice-hindi", ja: "voice-japanese" },
|
|
1437
|
+
},
|
|
1438
|
+
deepgram: { languageVoices: { hi: "aura-2-hi-voice" } },
|
|
1439
|
+
vellum: { languageVoices: { hi: "aura-2-hi-voice" } },
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
});
|
|
1444
|
+
expect(result.services.tts.providers.elevenlabs.languageVoices).toEqual({
|
|
1445
|
+
hi: "voice-hindi",
|
|
1446
|
+
ja: "voice-japanese",
|
|
1447
|
+
});
|
|
1448
|
+
expect(result.services.tts.providers.deepgram.languageVoices).toEqual({
|
|
1449
|
+
hi: "aura-2-hi-voice",
|
|
1450
|
+
});
|
|
1451
|
+
expect(result.services.tts.providers.vellum.languageVoices).toEqual({
|
|
1452
|
+
hi: "aura-2-hi-voice",
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test("normalizes user-entered languageVoices keys to lowercase base subtags", () => {
|
|
1457
|
+
// "hi-IN" or "HI" typed into config must still match a spoken "hi"
|
|
1458
|
+
// instead of silently never applying; the schema normalizes on parse.
|
|
1459
|
+
const result = AssistantConfigSchema.parse({
|
|
1460
|
+
services: {
|
|
1461
|
+
tts: {
|
|
1462
|
+
providers: {
|
|
1463
|
+
elevenlabs: {
|
|
1464
|
+
languageVoices: {
|
|
1465
|
+
"hi-IN": "voice-hindi",
|
|
1466
|
+
JA: "voice-japanese",
|
|
1467
|
+
es_419: "voice-spanish",
|
|
1468
|
+
" PT ": "voice-portuguese",
|
|
1469
|
+
},
|
|
1470
|
+
},
|
|
1471
|
+
},
|
|
1472
|
+
},
|
|
1473
|
+
},
|
|
1474
|
+
});
|
|
1475
|
+
expect(result.services.tts.providers.elevenlabs.languageVoices).toEqual({
|
|
1476
|
+
hi: "voice-hindi",
|
|
1477
|
+
ja: "voice-japanese",
|
|
1478
|
+
es: "voice-spanish",
|
|
1479
|
+
pt: "voice-portuguese",
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
test("keeps the first entry when languageVoices keys collide after normalization", () => {
|
|
1484
|
+
const result = AssistantConfigSchema.parse({
|
|
1485
|
+
services: {
|
|
1486
|
+
tts: {
|
|
1487
|
+
providers: {
|
|
1488
|
+
deepgram: {
|
|
1489
|
+
languageVoices: { "hi-IN": "voice-first", hi: "voice-second" },
|
|
1490
|
+
},
|
|
1491
|
+
},
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
expect(result.services.tts.providers.deepgram.languageVoices).toEqual({
|
|
1496
|
+
hi: "voice-first",
|
|
1497
|
+
});
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
test("drops languageVoices keys that normalize to the empty string", () => {
|
|
1501
|
+
const result = AssistantConfigSchema.parse({
|
|
1502
|
+
services: {
|
|
1503
|
+
tts: {
|
|
1504
|
+
providers: {
|
|
1505
|
+
vellum: {
|
|
1506
|
+
languageVoices: {
|
|
1507
|
+
"": "voice-empty",
|
|
1508
|
+
" ": "voice-blank",
|
|
1509
|
+
"-IN": "voice-region-only",
|
|
1510
|
+
hi: "voice-hindi",
|
|
1511
|
+
},
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
});
|
|
1517
|
+
expect(result.services.tts.providers.vellum.languageVoices).toEqual({
|
|
1518
|
+
hi: "voice-hindi",
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
test("languageVoices defaults to unset", () => {
|
|
1523
|
+
const result = AssistantConfigSchema.parse({});
|
|
1524
|
+
expect(
|
|
1525
|
+
result.services.tts.providers.elevenlabs.languageVoices,
|
|
1526
|
+
).toBeUndefined();
|
|
1527
|
+
expect(
|
|
1528
|
+
result.services.tts.providers.deepgram.languageVoices,
|
|
1529
|
+
).toBeUndefined();
|
|
1530
|
+
expect(result.services.tts.providers.vellum.languageVoices).toBeUndefined();
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
test("rejects non-string services.tts.providers languageVoices values", () => {
|
|
1534
|
+
for (const provider of ["elevenlabs", "deepgram", "vellum"]) {
|
|
1535
|
+
const result = AssistantConfigSchema.safeParse({
|
|
1536
|
+
services: {
|
|
1537
|
+
tts: {
|
|
1538
|
+
providers: {
|
|
1539
|
+
[provider]: { languageVoices: { hi: 42 } },
|
|
1540
|
+
},
|
|
1541
|
+
},
|
|
1542
|
+
},
|
|
1543
|
+
});
|
|
1544
|
+
expect(result.success).toBe(false);
|
|
1545
|
+
if (!result.success) {
|
|
1546
|
+
expect(
|
|
1547
|
+
result.error.issues.some((issue) =>
|
|
1548
|
+
issue.path.join(".").includes("languageVoices"),
|
|
1549
|
+
),
|
|
1550
|
+
).toBe(true);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1420
1555
|
// Legacy config objects on disk may carry a `mode` key. Parsing must strip
|
|
1421
1556
|
// it rather than reject the config — and must not treat it as a managed
|
|
1422
1557
|
// selection, which is what migration 130 rewrites to `provider: "vellum"`.
|
|
@@ -2432,7 +2567,9 @@ describe("loadConfig with schema validation", () => {
|
|
|
2432
2567
|
expect(config.calls.userConsultTimeoutSeconds).toBe(120);
|
|
2433
2568
|
expect(config.calls.disclosure.enabled).toBe(true);
|
|
2434
2569
|
expect(config.calls.safety.denyCategories).toEqual([]);
|
|
2435
|
-
expect(
|
|
2570
|
+
expect(
|
|
2571
|
+
(config.calls.voice as Record<string, unknown>).language,
|
|
2572
|
+
).toBeUndefined();
|
|
2436
2573
|
expect(
|
|
2437
2574
|
(config.calls.voice as Record<string, unknown>).transcriptionProvider,
|
|
2438
2575
|
).toBeUndefined();
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "../calls/media-stream-audio-transcode.js";
|
|
27
27
|
import { MediaStreamOutput } from "../calls/media-stream-output.js";
|
|
28
28
|
import { resolveCallTtsProvider } from "../calls/resolve-call-tts-provider.js";
|
|
29
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
29
30
|
|
|
30
31
|
const mockResolveCallTtsProvider = resolveCallTtsProvider as ReturnType<
|
|
31
32
|
typeof jest.fn
|
|
@@ -1213,6 +1214,180 @@ describe("MediaStreamOutput", () => {
|
|
|
1213
1214
|
});
|
|
1214
1215
|
});
|
|
1215
1216
|
|
|
1217
|
+
// ---------------------------------------------------------------------------
|
|
1218
|
+
// Synthesis language hint
|
|
1219
|
+
// ---------------------------------------------------------------------------
|
|
1220
|
+
|
|
1221
|
+
describe("synthesis language hint", () => {
|
|
1222
|
+
/** Synthesize one turn and return the provider request it produced. */
|
|
1223
|
+
async function synthesizeOneTurn(
|
|
1224
|
+
output: MediaStreamOutput,
|
|
1225
|
+
): Promise<{ language?: string }> {
|
|
1226
|
+
mockSynthesize.mockResolvedValue({
|
|
1227
|
+
audio: makeWavBuffer([1000, 2000, 3000, 4000]),
|
|
1228
|
+
contentType: "audio/wav",
|
|
1229
|
+
});
|
|
1230
|
+
output.sendTextToken("Hello caller.", true);
|
|
1231
|
+
await drain(() => mockSynthesize.mock.calls.length > 0);
|
|
1232
|
+
return mockSynthesize.mock.calls[0][0] as { language?: string };
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
test("the resolver's language rides the provider request", async () => {
|
|
1236
|
+
const { ws } = createMockWs();
|
|
1237
|
+
const output = makeOutput(ws, "stream-lang");
|
|
1238
|
+
output.setSynthesisLanguageResolver(() => "es");
|
|
1239
|
+
|
|
1240
|
+
const request = await synthesizeOneTurn(output);
|
|
1241
|
+
|
|
1242
|
+
expect(request.language).toBe("es");
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test("fixed system copy synthesizes without the caller-language hint while model text keeps it", async () => {
|
|
1246
|
+
const { ws } = createMockWs();
|
|
1247
|
+
const output = makeOutput(ws, "stream-lang");
|
|
1248
|
+
output.setSynthesisLanguageResolver(() => "ja");
|
|
1249
|
+
mockSynthesize.mockResolvedValue({
|
|
1250
|
+
audio: makeWavBuffer([1000, 2000, 3000, 4000]),
|
|
1251
|
+
contentType: "audio/wav",
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
output.sendTextToken("Model reply.", true);
|
|
1255
|
+
output.sendTextToken("Are you still there?", true, { systemCopy: true });
|
|
1256
|
+
await drain(() => mockSynthesize.mock.calls.length >= 2);
|
|
1257
|
+
|
|
1258
|
+
const requests = mockSynthesize.mock.calls.map(
|
|
1259
|
+
(call) => call[0] as { text: string; language?: string },
|
|
1260
|
+
);
|
|
1261
|
+
expect(requests[0]?.text).toBe("Model reply.");
|
|
1262
|
+
expect(requests[0]?.language).toBe("ja");
|
|
1263
|
+
expect(requests[1]?.text).toBe("Are you still there?");
|
|
1264
|
+
expect(requests[1]?.language).toBeUndefined();
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test("no language when nothing resolves (default multilingual pin)", async () => {
|
|
1268
|
+
const { ws } = createMockWs();
|
|
1269
|
+
const output = makeOutput(ws, "stream-lang");
|
|
1270
|
+
|
|
1271
|
+
const request = await synthesizeOneTurn(output);
|
|
1272
|
+
|
|
1273
|
+
expect(request.language).toBeUndefined();
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
test("a monolingual pin on a manual-selection provider resolves as the hint", async () => {
|
|
1277
|
+
setConfig("services", {
|
|
1278
|
+
stt: { provider: "deepgram", language: "es-ES" },
|
|
1279
|
+
});
|
|
1280
|
+
try {
|
|
1281
|
+
const { ws } = createMockWs();
|
|
1282
|
+
const output = makeOutput(ws, "stream-lang");
|
|
1283
|
+
|
|
1284
|
+
const request = await synthesizeOneTurn(output);
|
|
1285
|
+
|
|
1286
|
+
expect(request.language).toBe("es");
|
|
1287
|
+
} finally {
|
|
1288
|
+
setConfig("services", {});
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
test("the pin is ignored for auto-detecting providers", async () => {
|
|
1293
|
+
setConfig("services", {
|
|
1294
|
+
stt: { provider: "openai-whisper", language: "es" },
|
|
1295
|
+
});
|
|
1296
|
+
try {
|
|
1297
|
+
const { ws } = createMockWs();
|
|
1298
|
+
const output = makeOutput(ws, "stream-lang");
|
|
1299
|
+
|
|
1300
|
+
const request = await synthesizeOneTurn(output);
|
|
1301
|
+
|
|
1302
|
+
expect(request.language).toBeUndefined();
|
|
1303
|
+
} finally {
|
|
1304
|
+
setConfig("services", {});
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
// ---------------------------------------------------------------------------
|
|
1310
|
+
// Per-language voice override (languageVoices)
|
|
1311
|
+
// ---------------------------------------------------------------------------
|
|
1312
|
+
|
|
1313
|
+
describe("per-language voice override", () => {
|
|
1314
|
+
afterEach(() => {
|
|
1315
|
+
setConfig("services", {});
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
/** Seed a deepgram languageVoices entry and resolve deepgram as the provider. */
|
|
1319
|
+
function useDeepgramWithHindiVoice(): void {
|
|
1320
|
+
setConfig("services", {
|
|
1321
|
+
tts: {
|
|
1322
|
+
provider: "deepgram",
|
|
1323
|
+
providers: {
|
|
1324
|
+
deepgram: { languageVoices: { hi: "aura-2-hindi-voice" } },
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
useProvider({
|
|
1329
|
+
id: "deepgram",
|
|
1330
|
+
capabilities: { supportsStreaming: false, supportedFormats: ["wav"] },
|
|
1331
|
+
synthesize: mockSynthesize,
|
|
1332
|
+
});
|
|
1333
|
+
mockSynthesize.mockResolvedValue({
|
|
1334
|
+
audio: makeWavBuffer([1000, 2000, 3000, 4000]),
|
|
1335
|
+
contentType: "audio/wav",
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
test("a configured languageVoices entry for the resolved language rides the request as voiceId", async () => {
|
|
1340
|
+
useDeepgramWithHindiVoice();
|
|
1341
|
+
const { ws } = createMockWs();
|
|
1342
|
+
const output = makeOutput(ws, "stream-voice");
|
|
1343
|
+
output.setSynthesisLanguageResolver(() => "hi");
|
|
1344
|
+
|
|
1345
|
+
output.sendTextToken("Namaste.", true);
|
|
1346
|
+
await drain(() => mockSynthesize.mock.calls.length > 0);
|
|
1347
|
+
|
|
1348
|
+
const request = mockSynthesize.mock.calls[0][0] as {
|
|
1349
|
+
voiceId?: string;
|
|
1350
|
+
language?: string;
|
|
1351
|
+
};
|
|
1352
|
+
expect(request.language).toBe("hi");
|
|
1353
|
+
expect(request.voiceId).toBe("aura-2-hindi-voice");
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
test("no voiceId when the map has no entry for the resolved language", async () => {
|
|
1357
|
+
useDeepgramWithHindiVoice();
|
|
1358
|
+
const { ws } = createMockWs();
|
|
1359
|
+
const output = makeOutput(ws, "stream-voice");
|
|
1360
|
+
output.setSynthesisLanguageResolver(() => "es");
|
|
1361
|
+
|
|
1362
|
+
output.sendTextToken("Hola.", true);
|
|
1363
|
+
await drain(() => mockSynthesize.mock.calls.length > 0);
|
|
1364
|
+
|
|
1365
|
+
const request = mockSynthesize.mock.calls[0][0] as {
|
|
1366
|
+
voiceId?: string;
|
|
1367
|
+
language?: string;
|
|
1368
|
+
};
|
|
1369
|
+
expect(request.language).toBe("es");
|
|
1370
|
+
expect(request.voiceId).toBeUndefined();
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
test("system copy gets neither the language hint nor the language voice", async () => {
|
|
1374
|
+
useDeepgramWithHindiVoice();
|
|
1375
|
+
const { ws } = createMockWs();
|
|
1376
|
+
const output = makeOutput(ws, "stream-voice");
|
|
1377
|
+
output.setSynthesisLanguageResolver(() => "hi");
|
|
1378
|
+
|
|
1379
|
+
output.sendTextToken("Are you still there?", true, { systemCopy: true });
|
|
1380
|
+
await drain(() => mockSynthesize.mock.calls.length > 0);
|
|
1381
|
+
|
|
1382
|
+
const request = mockSynthesize.mock.calls[0][0] as {
|
|
1383
|
+
voiceId?: string;
|
|
1384
|
+
language?: string;
|
|
1385
|
+
};
|
|
1386
|
+
expect(request.language).toBeUndefined();
|
|
1387
|
+
expect(request.voiceId).toBeUndefined();
|
|
1388
|
+
});
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1216
1391
|
// ---------------------------------------------------------------------------
|
|
1217
1392
|
// Streaming PCM synthesis (incremental transcode)
|
|
1218
1393
|
// ---------------------------------------------------------------------------
|