@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.
@@ -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);
@@ -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
- audioData: audio([1, 2]),
11
- durationMs: 100,
12
- boundaries: [{ text: "one", audioOffsetMs: 20, durationMs: 30, textRange: { start: 0, end: 3 }, requestId: "a" }],
13
- visemes: [{ visemeId: 1, audioOffsetMs: 40 }],
14
- bookmarks: [{ name: "first", audioOffsetMs: 50 }],
15
- },
16
- {
17
- audioData: audio([3, 4, 5]),
18
- durationMs: 250,
19
- boundaries: [{ text: "two", audioOffsetMs: 10, durationMs: 20, textRange: { start: 3, end: 6 }, requestId: "b" }],
20
- visemes: [{ visemeId: 2, audioOffsetMs: 15 }],
21
- bookmarks: [{ name: "second", audioOffsetMs: 25 }],
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
+ });