@vellumai/assistant 0.11.3-staging.2 → 0.11.3-staging.4
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 +142 -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/__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/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/__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
|
@@ -3,6 +3,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_SPEECH_ENERGY_THRESHOLD,
|
|
5
5
|
detectPcm16SpeechActivity,
|
|
6
|
+
pcm16MaxNormalizedCorrelation,
|
|
7
|
+
pcm16MeanAmplitude,
|
|
6
8
|
} from "../speech-energy.js";
|
|
7
9
|
|
|
8
10
|
/** Build a PCM16LE buffer from an array of sample values. */
|
|
@@ -66,3 +68,80 @@ describe("detectPcm16SpeechActivity", () => {
|
|
|
66
68
|
expect(detectPcm16SpeechActivity(quiet, 500)).toBe(false);
|
|
67
69
|
});
|
|
68
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("pcm16MeanAmplitude", () => {
|
|
73
|
+
test("returns 0 for an empty buffer", () => {
|
|
74
|
+
expect(pcm16MeanAmplitude(Buffer.alloc(0))).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns the exact mean absolute amplitude", () => {
|
|
78
|
+
expect(pcm16MeanAmplitude(pcm16([1_000, -2_000, 3_000, -4_000]))).toBe(
|
|
79
|
+
2_500,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("ignores a trailing odd byte", () => {
|
|
84
|
+
const samples = pcm16([3_000, -3_000]);
|
|
85
|
+
expect(
|
|
86
|
+
pcm16MeanAmplitude(Buffer.concat([samples, Buffer.from([0x01])])),
|
|
87
|
+
).toBe(3_000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("matches the detector's threshold comparison", () => {
|
|
91
|
+
const chunks = [
|
|
92
|
+
Buffer.alloc(0),
|
|
93
|
+
pcm16([0, 0]),
|
|
94
|
+
pcm16([500, -500]),
|
|
95
|
+
pcm16([DEFAULT_SPEECH_ENERGY_THRESHOLD]),
|
|
96
|
+
pcm16([DEFAULT_SPEECH_ENERGY_THRESHOLD + 1]),
|
|
97
|
+
];
|
|
98
|
+
for (const chunk of chunks) {
|
|
99
|
+
expect(detectPcm16SpeechActivity(chunk)).toBe(
|
|
100
|
+
pcm16MeanAmplitude(chunk) > DEFAULT_SPEECH_ENERGY_THRESHOLD,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("pcm16MaxNormalizedCorrelation", () => {
|
|
107
|
+
const wave = (frequency: number, count: number): Buffer =>
|
|
108
|
+
pcm16(
|
|
109
|
+
Array.from({ length: count }, (_, index) =>
|
|
110
|
+
Math.round(
|
|
111
|
+
8_000 * Math.sin((2 * Math.PI * frequency * index) / 16_000),
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
test("finds a matching waveform at an arbitrary reference offset", () => {
|
|
117
|
+
const input = wave(240, 800);
|
|
118
|
+
const reference = Buffer.concat([wave(410, 400), input, wave(610, 400)]);
|
|
119
|
+
|
|
120
|
+
expect(pcm16MaxNormalizedCorrelation(input, reference)).toBeGreaterThan(
|
|
121
|
+
0.99,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("is invariant to gain and polarity", () => {
|
|
126
|
+
const inputSamples = Array.from({ length: 800 }, (_, index) =>
|
|
127
|
+
Math.round(5_000 * Math.sin((2 * Math.PI * index) / 83)),
|
|
128
|
+
);
|
|
129
|
+
const inverted = pcm16(inputSamples.map((sample) => -2 * sample));
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
pcm16MaxNormalizedCorrelation(pcm16(inputSamples), inverted),
|
|
133
|
+
).toBeGreaterThan(0.99);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("rejects an unrelated waveform and flat power", () => {
|
|
137
|
+
expect(
|
|
138
|
+
pcm16MaxNormalizedCorrelation(wave(240, 800), wave(610, 1_600)),
|
|
139
|
+
).toBeLessThan(0.3);
|
|
140
|
+
expect(
|
|
141
|
+
pcm16MaxNormalizedCorrelation(
|
|
142
|
+
pcm16(new Array(800).fill(3_000)),
|
|
143
|
+
pcm16(new Array(1_600).fill(3_000)),
|
|
144
|
+
),
|
|
145
|
+
).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for tallying and ranking detected-language tags emitted
|
|
3
|
+
* by streaming STT providers (e.g. Deepgram nova-3 `multi` per-word tags).
|
|
4
|
+
*
|
|
5
|
+
* Tag normalization is `baseLanguageSubtag` (util/language-subtag.ts);
|
|
6
|
+
* these helpers layer vote-counting on top of it so provider adapters and
|
|
7
|
+
* event consumers share one notion of a dominant language.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { baseLanguageSubtag } from "../util/language-subtag.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cast one vote into a language tally for a transcript chunk's dominant
|
|
14
|
+
* detected language. `languages` is dominance-ranked, so only the first
|
|
15
|
+
* entry votes: secondary tags at full weight would let a minority
|
|
16
|
+
* language outvote the dominant one. Blank and absent tags cast no vote;
|
|
17
|
+
* regional variants count toward their base subtag.
|
|
18
|
+
*/
|
|
19
|
+
export function voteDominantLanguage(
|
|
20
|
+
tally: Map<string, number>,
|
|
21
|
+
languages: readonly string[] | undefined,
|
|
22
|
+
): void {
|
|
23
|
+
const dominant = baseLanguageSubtag(languages?.[0]);
|
|
24
|
+
if (dominant !== undefined) {
|
|
25
|
+
tally.set(dominant, (tally.get(dominant) ?? 0) + 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The dominant tag in a language tally: most votes wins, ties break by
|
|
31
|
+
* first insertion (Map iteration is insertion order). Undefined for an
|
|
32
|
+
* empty tally.
|
|
33
|
+
*/
|
|
34
|
+
export function dominantLanguageTag(
|
|
35
|
+
tally: ReadonlyMap<string, number>,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
let dominant: string | undefined;
|
|
38
|
+
let dominantCount = 0;
|
|
39
|
+
for (const [tag, count] of tally) {
|
|
40
|
+
if (count > dominantCount) {
|
|
41
|
+
dominant = tag;
|
|
42
|
+
dominantCount = count;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return dominant;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tally normalized language tags and return them most-frequent-first.
|
|
50
|
+
* Ties are broken by first appearance in the input. Blank tags are
|
|
51
|
+
* skipped; regional variants count toward their base subtag.
|
|
52
|
+
*/
|
|
53
|
+
export function rankLanguages(tags: Iterable<string>): string[] {
|
|
54
|
+
// Map iteration order is insertion order, so a stable sort by count
|
|
55
|
+
// leaves tied tags in first-appearance order.
|
|
56
|
+
const counts = new Map<string, number>();
|
|
57
|
+
for (const tag of tags) {
|
|
58
|
+
const normalized = baseLanguageSubtag(tag);
|
|
59
|
+
if (normalized === undefined) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([tag]) => tag);
|
|
65
|
+
}
|
package/src/stt/speech-energy.ts
CHANGED
|
@@ -18,6 +18,120 @@
|
|
|
18
18
|
*/
|
|
19
19
|
export const DEFAULT_SPEECH_ENERGY_THRESHOLD = 800;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Return the mean absolute amplitude of little-endian signed PCM16 audio.
|
|
23
|
+
* Empty buffers return 0, and a trailing odd byte is ignored.
|
|
24
|
+
*/
|
|
25
|
+
export function pcm16MeanAmplitude(chunk: Buffer): number {
|
|
26
|
+
const sampleCount = Math.floor(chunk.length / 2);
|
|
27
|
+
if (sampleCount === 0) {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let totalAmplitude = 0;
|
|
32
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
33
|
+
totalAmplitude += Math.abs(chunk.readInt16LE(i * 2));
|
|
34
|
+
}
|
|
35
|
+
return totalAmplitude / sampleCount;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find the strongest normalized correlation between a PCM16 input window and
|
|
40
|
+
* any same-length window in a PCM16 reference. The samples are block-averaged
|
|
41
|
+
* before matching so this stays cheap enough for live audio and remains
|
|
42
|
+
* tolerant of the low-pass filtering introduced by speakers and microphones.
|
|
43
|
+
*
|
|
44
|
+
* The absolute coefficient makes polarity inversion harmless. A flat input or
|
|
45
|
+
* reference has no identifying waveform and returns 0 instead of being treated
|
|
46
|
+
* as a match based on level alone.
|
|
47
|
+
*/
|
|
48
|
+
export function pcm16MaxNormalizedCorrelation(
|
|
49
|
+
input: Buffer,
|
|
50
|
+
reference: Buffer,
|
|
51
|
+
downsampleFactor = 8,
|
|
52
|
+
): number {
|
|
53
|
+
if (!Number.isInteger(downsampleFactor) || downsampleFactor <= 0) {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let inputSamples = blockAveragePcm16(input, downsampleFactor);
|
|
58
|
+
const referenceSamples = blockAveragePcm16(reference, downsampleFactor);
|
|
59
|
+
if (inputSamples.length > referenceSamples.length) {
|
|
60
|
+
inputSamples = inputSamples.subarray(0, referenceSamples.length);
|
|
61
|
+
}
|
|
62
|
+
if (inputSamples.length < 2) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let inputSum = 0;
|
|
67
|
+
for (let index = 0; index < inputSamples.length; index += 1) {
|
|
68
|
+
inputSum += inputSamples[index]!;
|
|
69
|
+
}
|
|
70
|
+
const inputMean = inputSum / inputSamples.length;
|
|
71
|
+
const centeredInput = new Float64Array(inputSamples.length);
|
|
72
|
+
let inputEnergy = 0;
|
|
73
|
+
for (let index = 0; index < inputSamples.length; index += 1) {
|
|
74
|
+
const centered = inputSamples[index]! - inputMean;
|
|
75
|
+
centeredInput[index] = centered;
|
|
76
|
+
inputEnergy += centered * centered;
|
|
77
|
+
}
|
|
78
|
+
if (inputEnergy === 0) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const prefixSum = new Float64Array(referenceSamples.length + 1);
|
|
83
|
+
const prefixSquareSum = new Float64Array(referenceSamples.length + 1);
|
|
84
|
+
for (let index = 0; index < referenceSamples.length; index += 1) {
|
|
85
|
+
const sample = referenceSamples[index]!;
|
|
86
|
+
prefixSum[index + 1] = prefixSum[index]! + sample;
|
|
87
|
+
prefixSquareSum[index + 1] = prefixSquareSum[index]! + sample * sample;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let best = 0;
|
|
91
|
+
const windowLength = inputSamples.length;
|
|
92
|
+
for (
|
|
93
|
+
let offset = 0;
|
|
94
|
+
offset + windowLength <= referenceSamples.length;
|
|
95
|
+
offset += 1
|
|
96
|
+
) {
|
|
97
|
+
const referenceSum = prefixSum[offset + windowLength]! - prefixSum[offset]!;
|
|
98
|
+
const referenceSquareSum =
|
|
99
|
+
prefixSquareSum[offset + windowLength]! - prefixSquareSum[offset]!;
|
|
100
|
+
const referenceEnergy =
|
|
101
|
+
referenceSquareSum - (referenceSum * referenceSum) / windowLength;
|
|
102
|
+
if (referenceEnergy <= 0) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let dotProduct = 0;
|
|
107
|
+
for (let index = 0; index < windowLength; index += 1) {
|
|
108
|
+
dotProduct += centeredInput[index]! * referenceSamples[offset + index]!;
|
|
109
|
+
}
|
|
110
|
+
const correlation =
|
|
111
|
+
Math.abs(dotProduct) / Math.sqrt(inputEnergy * referenceEnergy);
|
|
112
|
+
best = Math.max(best, Math.min(correlation, 1));
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function blockAveragePcm16(
|
|
118
|
+
chunk: Buffer,
|
|
119
|
+
downsampleFactor: number,
|
|
120
|
+
): Float64Array {
|
|
121
|
+
const sampleCount = Math.floor(chunk.length / 2);
|
|
122
|
+
const blockCount = Math.floor(sampleCount / downsampleFactor);
|
|
123
|
+
const result = new Float64Array(blockCount);
|
|
124
|
+
for (let block = 0; block < blockCount; block += 1) {
|
|
125
|
+
let sum = 0;
|
|
126
|
+
const firstSample = block * downsampleFactor;
|
|
127
|
+
for (let offset = 0; offset < downsampleFactor; offset += 1) {
|
|
128
|
+
sum += chunk.readInt16LE((firstSample + offset) * 2);
|
|
129
|
+
}
|
|
130
|
+
result[block] = sum / downsampleFactor;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
21
135
|
/**
|
|
22
136
|
* Detect speech activity in a chunk of little-endian signed 16-bit mono
|
|
23
137
|
* PCM samples.
|
|
@@ -34,16 +148,5 @@ export function detectPcm16SpeechActivity(
|
|
|
34
148
|
chunk: Buffer,
|
|
35
149
|
threshold = DEFAULT_SPEECH_ENERGY_THRESHOLD,
|
|
36
150
|
): boolean {
|
|
37
|
-
|
|
38
|
-
if (sampleCount === 0) {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
let totalAmplitude = 0;
|
|
43
|
-
for (let i = 0; i < sampleCount; i++) {
|
|
44
|
-
totalAmplitude += Math.abs(chunk.readInt16LE(i * 2));
|
|
45
|
-
}
|
|
46
|
-
const avgAmplitude = totalAmplitude / sampleCount;
|
|
47
|
-
|
|
48
|
-
return avgAmplitude > threshold;
|
|
151
|
+
return pcm16MeanAmplitude(chunk) > threshold;
|
|
49
152
|
}
|
package/src/stt/types.ts
CHANGED
|
@@ -263,6 +263,14 @@ export interface SttStreamServerPartialEvent {
|
|
|
263
263
|
* provider does not surface confidence on interim chunks.
|
|
264
264
|
*/
|
|
265
265
|
readonly confidence?: number;
|
|
266
|
+
/**
|
|
267
|
+
* All detected languages of this chunk in dominance order (first entry
|
|
268
|
+
* dominant), already normalized to lowercase base subtags (e.g. "en",
|
|
269
|
+
* "hi") by the emitting adapter; consumers must not re-normalize. Only
|
|
270
|
+
* providers with code-switching metadata (currently Deepgram nova-3
|
|
271
|
+
* `multi`) populate this. Absence means "unknown", never English.
|
|
272
|
+
*/
|
|
273
|
+
readonly languages?: readonly string[];
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
/**
|
|
@@ -279,6 +287,14 @@ export interface SttStreamServerFinalEvent {
|
|
|
279
287
|
* provider does not surface confidence on this chunk.
|
|
280
288
|
*/
|
|
281
289
|
readonly confidence?: number;
|
|
290
|
+
/**
|
|
291
|
+
* All detected languages of this chunk in dominance order (first entry
|
|
292
|
+
* dominant), already normalized to lowercase base subtags (e.g. "en",
|
|
293
|
+
* "hi") by the emitting adapter; consumers must not re-normalize. Only
|
|
294
|
+
* providers with code-switching metadata (currently Deepgram nova-3
|
|
295
|
+
* `multi`) populate this. Absence means "unknown", never English.
|
|
296
|
+
*/
|
|
297
|
+
readonly languages?: readonly string[];
|
|
282
298
|
/**
|
|
283
299
|
* True when this final is the flush response to
|
|
284
300
|
* {@link StreamingTranscriber.finalizeUtterance} — i.e. it commits audio
|
|
@@ -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(), () => {});
|