@ssml-builder-js/azure-tts-client 2.13.0 → 2.15.0
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/CHANGELOG.md +22 -0
- package/dist/index.d.mts +169 -17
- package/dist/index.d.ts +169 -17
- package/dist/index.js +619 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +606 -45
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +39 -7
- package/src/errors.ts +74 -0
- package/src/index.ts +25 -3
- package/src/outputFormats.ts +14 -3
- package/src/safe.ts +273 -23
- package/src/synthesis.ts +435 -26
- package/src/types.ts +48 -2
- package/src/voiceCatalog.ts +15 -0
- package/test/synthesis.test.ts +64 -0
- package/test/v213-pipeline.test.ts +27 -16
- package/test/v214-pipeline.test.ts +114 -0
- package/test/v215-pipeline.test.ts +104 -0
package/src/voiceCatalog.ts
CHANGED
|
@@ -10,6 +10,9 @@ export interface AzureVoiceCatalogVoice {
|
|
|
10
10
|
locale: string;
|
|
11
11
|
secondaryLocales?: readonly string[];
|
|
12
12
|
styles?: readonly string[];
|
|
13
|
+
supportedTags?: readonly string[];
|
|
14
|
+
unsupportedTags?: readonly string[];
|
|
15
|
+
models?: readonly string[];
|
|
13
16
|
regions: readonly string[];
|
|
14
17
|
status?: "ga" | "preview" | "deprecated";
|
|
15
18
|
}
|
|
@@ -33,6 +36,9 @@ interface AzureVoiceApiRecord {
|
|
|
33
36
|
ShortName?: unknown;
|
|
34
37
|
Status?: unknown;
|
|
35
38
|
StyleList?: unknown;
|
|
39
|
+
SupportedTags?: unknown;
|
|
40
|
+
UnsupportedTags?: unknown;
|
|
41
|
+
Models?: unknown;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
function stringValue(value: unknown): string | undefined {
|
|
@@ -92,6 +98,9 @@ export async function fetchAzureVoiceCatalog(options: FetchAzureVoiceCatalogOpti
|
|
|
92
98
|
const secondaryLocales = stringList(record.SecondaryLocaleList);
|
|
93
99
|
const styles = stringList(record.StyleList);
|
|
94
100
|
const status = normalizeStatus(record.Status);
|
|
101
|
+
const supportedTags = stringList(record.SupportedTags);
|
|
102
|
+
const unsupportedTags = stringList(record.UnsupportedTags);
|
|
103
|
+
const models = stringList(record.Models);
|
|
95
104
|
const merged: AzureVoiceCatalogVoice = {
|
|
96
105
|
name: existing?.name ?? name,
|
|
97
106
|
locale: existing?.locale ?? locale,
|
|
@@ -101,6 +110,12 @@ export async function fetchAzureVoiceCatalog(options: FetchAzureVoiceCatalogOpti
|
|
|
101
110
|
if (mergedSecondaryLocales.length > 0) merged.secondaryLocales = mergedSecondaryLocales;
|
|
102
111
|
const mergedStyles = [...new Set([...(existing?.styles ?? []), ...styles])];
|
|
103
112
|
if (mergedStyles.length > 0) merged.styles = mergedStyles;
|
|
113
|
+
const mergedSupportedTags = [...new Set([...(existing?.supportedTags ?? []), ...supportedTags])];
|
|
114
|
+
if (mergedSupportedTags.length > 0) merged.supportedTags = mergedSupportedTags;
|
|
115
|
+
const mergedUnsupportedTags = [...new Set([...(existing?.unsupportedTags ?? []), ...unsupportedTags])];
|
|
116
|
+
if (mergedUnsupportedTags.length > 0) merged.unsupportedTags = mergedUnsupportedTags;
|
|
117
|
+
const mergedModels = [...new Set([...(existing?.models ?? []), ...models])];
|
|
118
|
+
if (mergedModels.length > 0) merged.models = mergedModels;
|
|
104
119
|
if (status) merged.status = status;
|
|
105
120
|
else if (existing?.status) merged.status = existing.status;
|
|
106
121
|
voices.set(key, merged);
|
package/test/synthesis.test.ts
CHANGED
|
@@ -90,6 +90,70 @@ test("synthesizeSsml returns word boundaries, visemes, bookmarks, and duration",
|
|
|
90
90
|
assert.equal(result.durationMs, 400);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test("synthesizeSsml maps each synchronization event to its source range and node", async (t) => {
|
|
94
|
+
const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
|
|
95
|
+
t.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (speechEndpoint, key) =>
|
|
96
|
+
originalFromEndpoint(speechEndpoint, String(key)),
|
|
97
|
+
);
|
|
98
|
+
t.mock.method(
|
|
99
|
+
SpeechSDK.SpeechSynthesizer.prototype,
|
|
100
|
+
"speakSsmlAsync",
|
|
101
|
+
function (this: SpeechSDK.SpeechSynthesizer, _ssml, callback) {
|
|
102
|
+
this.wordBoundary?.(this, {
|
|
103
|
+
text: "Hello",
|
|
104
|
+
textOffset: 7,
|
|
105
|
+
audioOffset: 0,
|
|
106
|
+
duration: 100_000,
|
|
107
|
+
} as SpeechSDK.SpeechSynthesisWordBoundaryEventArgs);
|
|
108
|
+
this.visemeReceived?.(this, {
|
|
109
|
+
visemeId: 3,
|
|
110
|
+
textOffset: 7,
|
|
111
|
+
audioOffset: 100_000,
|
|
112
|
+
} as SpeechSDK.SpeechSynthesisVisemeEventArgs);
|
|
113
|
+
this.bookmarkReached?.(this, {
|
|
114
|
+
text: "chapter",
|
|
115
|
+
audioOffset: 200_000,
|
|
116
|
+
} as SpeechSDK.SpeechSynthesisBookmarkEventArgs);
|
|
117
|
+
callback?.({
|
|
118
|
+
audioData: new ArrayBuffer(1),
|
|
119
|
+
audioDuration: 300_000,
|
|
120
|
+
errorDetails: "",
|
|
121
|
+
reason: SpeechSDK.ResultReason.SynthesizingAudioCompleted,
|
|
122
|
+
} as SpeechSDK.SpeechSynthesisResult);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => undefined);
|
|
126
|
+
|
|
127
|
+
const result = await synthesizeSsml(
|
|
128
|
+
'<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">prefix <prosody rate="slow">Hello</prosody><bookmark mark="chapter"/></voice></speak>',
|
|
129
|
+
{
|
|
130
|
+
endpoint,
|
|
131
|
+
subscriptionKey,
|
|
132
|
+
region,
|
|
133
|
+
sourceTextRange: { start: 100, end: 112 },
|
|
134
|
+
sourceTextSegments: [
|
|
135
|
+
{ text: "prefix ", range: { start: 100, end: 107 }, sourceNodePath: ["speak", "voice[0]"] },
|
|
136
|
+
{ text: "Hello", range: { start: 107, end: 112 }, sourceNodePath: ["speak", "voice[0]", "prosody[1]"] },
|
|
137
|
+
],
|
|
138
|
+
sourceMarkers: [
|
|
139
|
+
{
|
|
140
|
+
kind: "bookmark",
|
|
141
|
+
name: "chapter",
|
|
142
|
+
originalTextRange: { start: 112, end: 112 },
|
|
143
|
+
sourceNodePath: ["speak", "voice[0]", "bookmark[2]"],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(result.boundaries?.[0]?.originalTextRange, { start: 107, end: 112 });
|
|
150
|
+
assert.deepEqual(result.boundaries?.[0]?.sourceNodePath, ["speak", "voice[0]", "prosody[1]"]);
|
|
151
|
+
assert.deepEqual(result.visemes?.[0]?.originalTextRange, { start: 107, end: 107 });
|
|
152
|
+
assert.deepEqual(result.visemes?.[0]?.sourceNodePath, ["speak", "voice[0]", "prosody[1]"]);
|
|
153
|
+
assert.deepEqual(result.bookmarks?.[0]?.originalTextRange, { start: 112, end: 112 });
|
|
154
|
+
assert.deepEqual(result.bookmarks?.[0]?.sourceNodePath, ["speak", "voice[0]", "bookmark[2]"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
93
157
|
test("synthesizeSpeech aborts the SDK request on timeout and settles its promise", async (t) => {
|
|
94
158
|
let closeCount = 0;
|
|
95
159
|
let resultCallback: ((result: SpeechSDK.SpeechSynthesisResult) => void) | undefined;
|
|
@@ -5,22 +5,29 @@ import { mergeSynthesisResults, synthesizeSsmlSafe } from "../src/index.ts";
|
|
|
5
5
|
const audio = (values: number[]): ArrayBuffer => Uint8Array.from(values).buffer;
|
|
6
6
|
|
|
7
7
|
test("mergeSynthesisResults concatenates audio and offsets synchronization events", () => {
|
|
8
|
-
const result = mergeSynthesisResults(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
8
|
+
const result = mergeSynthesisResults(
|
|
9
|
+
[
|
|
10
|
+
{
|
|
11
|
+
audioData: audio([1, 2]),
|
|
12
|
+
durationMs: 100,
|
|
13
|
+
boundaries: [
|
|
14
|
+
{ text: "one", audioOffsetMs: 20, durationMs: 30, textRange: { start: 0, end: 3 }, requestId: "a" },
|
|
15
|
+
],
|
|
16
|
+
visemes: [{ visemeId: 1, audioOffsetMs: 40 }],
|
|
17
|
+
bookmarks: [{ name: "first", audioOffsetMs: 50 }],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
audioData: audio([3, 4, 5]),
|
|
21
|
+
durationMs: 250,
|
|
22
|
+
boundaries: [
|
|
23
|
+
{ text: "two", audioOffsetMs: 10, durationMs: 20, textRange: { start: 3, end: 6 }, requestId: "b" },
|
|
24
|
+
],
|
|
25
|
+
visemes: [{ visemeId: 2, audioOffsetMs: 15 }],
|
|
26
|
+
bookmarks: [{ name: "second", audioOffsetMs: 25 }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
{ format: "audio-16khz-128kbitrate-mono-mp3" },
|
|
30
|
+
);
|
|
24
31
|
|
|
25
32
|
assert.deepEqual([...new Uint8Array(result.audioData)], [1, 2, 3, 4, 5]);
|
|
26
33
|
assert.equal(result.durationMs, 350);
|
|
@@ -37,6 +44,10 @@ test("mergeSynthesisResults concatenates audio and offsets synchronization event
|
|
|
37
44
|
[50, 125],
|
|
38
45
|
);
|
|
39
46
|
assert.deepEqual(result.boundaries?.[1]?.textRange, { start: 3, end: 6 });
|
|
47
|
+
assert.equal(result.boundaries?.[0]?.chunkIndex, 0);
|
|
48
|
+
assert.equal(result.boundaries?.[1]?.chunkIndex, 1);
|
|
49
|
+
assert.equal(result.boundaries?.[1]?.chunkAudioOffsetMs, 10);
|
|
50
|
+
assert.deepEqual(result.boundaries?.[1]?.originalTextRange, { start: 3, end: 6 });
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
test("synthesizeSsmlSafe blocks invalid SSML without calling the client", async () => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
ChunkValidationError,
|
|
5
|
+
UnsupportedMergeFormatError,
|
|
6
|
+
mergeAudioBuffers,
|
|
7
|
+
synthesizeSsmlChunksSafe,
|
|
8
|
+
} from "../src/index.ts";
|
|
9
|
+
|
|
10
|
+
function wav(data: number[], sampleRate = 16_000): ArrayBuffer {
|
|
11
|
+
const pcm = Uint8Array.from(data);
|
|
12
|
+
const output = new Uint8Array(44 + pcm.length + (pcm.length & 1));
|
|
13
|
+
const view = new DataView(output.buffer);
|
|
14
|
+
output.set(new TextEncoder().encode("RIFF"), 0);
|
|
15
|
+
output.set(new TextEncoder().encode("WAVEfmt "), 8);
|
|
16
|
+
view.setUint32(4, output.length - 8, true);
|
|
17
|
+
view.setUint32(16, 16, true);
|
|
18
|
+
view.setUint16(20, 1, true);
|
|
19
|
+
view.setUint16(22, 1, true);
|
|
20
|
+
view.setUint32(24, sampleRate, true);
|
|
21
|
+
view.setUint32(28, sampleRate * 2, true);
|
|
22
|
+
view.setUint16(32, 2, true);
|
|
23
|
+
view.setUint16(34, 16, true);
|
|
24
|
+
output.set(new TextEncoder().encode("data"), 36);
|
|
25
|
+
view.setUint32(40, pcm.length, true);
|
|
26
|
+
output.set(pcm, 44);
|
|
27
|
+
return output.buffer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const validSsml = (text: string) =>
|
|
31
|
+
`<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
|
|
32
|
+
|
|
33
|
+
test("mergeAudioBuffers rebuilds one valid WAV header", () => {
|
|
34
|
+
const merged = mergeAudioBuffers([wav([1, 2]), wav([3, 4, 5])], { format: "riff-16khz-16bit-mono-pcm" });
|
|
35
|
+
const bytes = new Uint8Array(merged);
|
|
36
|
+
const view = new DataView(merged);
|
|
37
|
+
assert.equal(new TextDecoder().decode(bytes.slice(0, 4)), "RIFF");
|
|
38
|
+
assert.equal(new TextDecoder().decode(bytes.slice(8, 12)), "WAVE");
|
|
39
|
+
assert.equal(view.getUint32(4, true), merged.byteLength - 8);
|
|
40
|
+
assert.equal(view.getUint32(40, true), 5);
|
|
41
|
+
assert.deepEqual([...bytes.slice(44, 49)], [1, 2, 3, 4, 5]);
|
|
42
|
+
assert.equal(bytes[49], 0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("mergeAudioBuffers removes per-buffer ID3 tags from MP3 streams", () => {
|
|
46
|
+
const tag = Uint8Array.from([0x49, 0x44, 0x33, 4, 0, 0, 0, 0, 0, 0]);
|
|
47
|
+
const first = new Uint8Array(tag.length + 2);
|
|
48
|
+
first.set(tag);
|
|
49
|
+
first.set([1, 2], tag.length);
|
|
50
|
+
const second = new Uint8Array(tag.length + 1);
|
|
51
|
+
second.set(tag);
|
|
52
|
+
second[tag.length] = 3;
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
[
|
|
55
|
+
...new Uint8Array(
|
|
56
|
+
mergeAudioBuffers([first.buffer, second.buffer], { format: "audio-16khz-128kbitrate-mono-mp3" }),
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
[1, 2, 3],
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("mergeAudioBuffers rejects container formats that require remultiplexing", () => {
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => mergeAudioBuffers([new ArrayBuffer(1)], { format: "webm-24khz-16bit-mono-opus" }),
|
|
66
|
+
(error: unknown) => error instanceof UnsupportedMergeFormatError,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("synthesizeSsmlChunksSafe validates all chunks before calling Azure", async () => {
|
|
71
|
+
let calls = 0;
|
|
72
|
+
const progress: string[] = [];
|
|
73
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
74
|
+
{
|
|
75
|
+
synthesizeSsml: async () => {
|
|
76
|
+
calls += 1;
|
|
77
|
+
return { audioData: new ArrayBuffer(0), durationMs: 0 };
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
[validSsml("ok"), "<speak>"],
|
|
81
|
+
{ onProgress: (event) => progress.push(`${event.chunkIndex}:${event.status}`) },
|
|
82
|
+
);
|
|
83
|
+
assert.equal(result.ok, false);
|
|
84
|
+
assert.equal(result.status, "validation-error");
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
assert.ok(result.error instanceof ChunkValidationError);
|
|
87
|
+
assert.equal(result.error.chunkIndex, 1);
|
|
88
|
+
}
|
|
89
|
+
assert.equal(calls, 0);
|
|
90
|
+
assert.deepEqual(progress, ["0:pending", "1:pending", "1:failed"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("synthesizeSsmlChunksSafe reports structured lifecycle progress", async () => {
|
|
94
|
+
const progress: Array<{ chunkIndex: number; status: string; durationMs: number }> = [];
|
|
95
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
96
|
+
{
|
|
97
|
+
synthesizeSsml: async () => ({ audioData: Uint8Array.of(1).buffer, durationMs: 20 }),
|
|
98
|
+
},
|
|
99
|
+
[
|
|
100
|
+
{ ssml: validSsml("one"), originalTextRange: { start: 0, end: 3 }, sourceNodePath: ["speak", "voice[0]"] },
|
|
101
|
+
{ ssml: validSsml("two"), originalTextRange: { start: 3, end: 6 }, sourceNodePath: ["speak", "voice[0]"] },
|
|
102
|
+
],
|
|
103
|
+
{
|
|
104
|
+
onProgress: (event) =>
|
|
105
|
+
progress.push({ chunkIndex: event.chunkIndex, status: event.status, durationMs: event.durationMs }),
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
assert.equal(result.ok, true);
|
|
109
|
+
assert.deepEqual(
|
|
110
|
+
progress.map(({ chunkIndex, status }) => `${chunkIndex}:${status}`),
|
|
111
|
+
["0:pending", "1:pending", "0:synthesizing", "0:success", "1:synthesizing", "1:success"],
|
|
112
|
+
);
|
|
113
|
+
assert.ok(progress.every(({ durationMs }) => durationMs >= 0));
|
|
114
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AzureTtsError,
|
|
5
|
+
SynthesisCancelledError,
|
|
6
|
+
SynthesisTimeoutError,
|
|
7
|
+
UnsupportedMergeFormatError,
|
|
8
|
+
mergeSynthesisResults,
|
|
9
|
+
synthesizeSsmlChunksSafe,
|
|
10
|
+
synthesizeSsmlSafe,
|
|
11
|
+
} from "../src/index.ts";
|
|
12
|
+
|
|
13
|
+
const audio = (values: number[]): ArrayBuffer => Uint8Array.from(values).buffer;
|
|
14
|
+
const validSsml = (text: string) =>
|
|
15
|
+
`<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
|
|
16
|
+
|
|
17
|
+
test("merge APIs require a format at runtime and expose MIME metadata", async () => {
|
|
18
|
+
assert.throws(
|
|
19
|
+
() => mergeSynthesisResults([{ audioData: audio([1]), durationMs: 1 }], undefined as never),
|
|
20
|
+
(error: unknown) => error instanceof UnsupportedMergeFormatError && error.kind === "unsupported-format-error",
|
|
21
|
+
);
|
|
22
|
+
const merged = mergeSynthesisResults([{ audioData: audio([1]), durationMs: 1 }], {
|
|
23
|
+
format: "webm-24khz-16bit-mono-opus",
|
|
24
|
+
customMerger: (buffers) => buffers[0] ?? new ArrayBuffer(0),
|
|
25
|
+
});
|
|
26
|
+
assert.equal((await merged).mimeType, "audio/webm");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("safe synthesis returns each discriminated error kind", async () => {
|
|
30
|
+
const validation = await synthesizeSsmlSafe(
|
|
31
|
+
{ synthesizeSsml: async () => ({ audioData: new ArrayBuffer(0), durationMs: 0 }) },
|
|
32
|
+
"<speak>",
|
|
33
|
+
);
|
|
34
|
+
assert.equal(validation.ok, false);
|
|
35
|
+
if (!validation.ok) assert.equal(validation.error.kind, "validation-error");
|
|
36
|
+
|
|
37
|
+
const azure = await synthesizeSsmlSafe(
|
|
38
|
+
{
|
|
39
|
+
synthesizeSsml: async () => {
|
|
40
|
+
throw new AzureTtsError(429, "Too Many Requests", "{}", "req");
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
validSsml("hello"),
|
|
44
|
+
);
|
|
45
|
+
assert.equal(azure.ok, false);
|
|
46
|
+
if (!azure.ok) {
|
|
47
|
+
assert.equal(azure.error.kind, "azure-api-error");
|
|
48
|
+
assert.equal(azure.error.status, 429);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const error of [new SynthesisTimeoutError("timed out"), new SynthesisCancelledError()]) {
|
|
52
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
53
|
+
{
|
|
54
|
+
synthesizeSsml: async () => {
|
|
55
|
+
throw error;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
[validSsml("hello")],
|
|
59
|
+
{ outputFormat: "audio-16khz-128kbitrate-mono-mp3" },
|
|
60
|
+
);
|
|
61
|
+
assert.equal(result.ok, false);
|
|
62
|
+
if (!result.ok) assert.equal(result.error.kind, error.kind);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("chunk safe synthesis propagates output, cancellation, timeout, and source path settings", async () => {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const calls: Array<{ options?: Record<string, unknown> }> = [];
|
|
69
|
+
let validatorSignal: AbortSignal | undefined;
|
|
70
|
+
let validatorPath: readonly string[] | undefined;
|
|
71
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
72
|
+
{
|
|
73
|
+
synthesizeSsml: async (_ssml, options) => {
|
|
74
|
+
calls.push({ options: options as Record<string, unknown> });
|
|
75
|
+
return { audioData: new ArrayBuffer(0), durationMs: 0 };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
[
|
|
79
|
+
{
|
|
80
|
+
ssml: `${validSsml("hello").replace("</voice>", '<audio src="https://example.test/a.mp3"/></voice>')}`,
|
|
81
|
+
originalTextRange: { start: 10, end: 15 },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
{
|
|
85
|
+
allowExternalAudio: true,
|
|
86
|
+
outputFormat: "audio-16khz-128kbitrate-mono-mp3",
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
timeoutMs: 1234,
|
|
89
|
+
sourceNodePath: ["speak", "voice[0]"],
|
|
90
|
+
urlValidator: async (_url, context, signal) => {
|
|
91
|
+
validatorPath = context.sourceNodePath;
|
|
92
|
+
validatorSignal = signal;
|
|
93
|
+
return true;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
assert.equal(result.ok, true);
|
|
98
|
+
assert.equal(calls[0]?.options?.outputFormat, "audio-16khz-128kbitrate-mono-mp3");
|
|
99
|
+
assert.equal(calls[0]?.options?.signal, controller.signal);
|
|
100
|
+
assert.equal(calls[0]?.options?.timeoutMs, 1234);
|
|
101
|
+
assert.deepEqual(calls[0]?.options?.sourceNodePath, ["speak", "voice[0]"]);
|
|
102
|
+
assert.equal(validatorSignal, controller.signal);
|
|
103
|
+
assert.deepEqual(validatorPath, ["speak", "voice[0]"]);
|
|
104
|
+
});
|