@ssml-builder-js/azure-tts-client 2.14.0 → 2.16.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/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { SsmlTextRange } from "@ssml-builder-js/ssml-core";
1
+ import type { SsmlSourceMarker, SsmlSourceTextSegment, SsmlTextRange } from "@ssml-builder-js/ssml-core";
2
+ import type { AzureTtsOutputFormat } from "./outputFormats.ts";
2
3
 
3
4
  export interface TtsConfig {
4
5
  signal?: AbortSignal;
@@ -14,6 +15,29 @@ export interface TtsConfig {
14
15
  /** Metadata used to map synchronization events back to the source document. */
15
16
  chunkIndex?: number;
16
17
  sourceNodePath?: string[];
18
+ /** Exact source text segments used to map individual Azure events. */
19
+ sourceTextSegments?: SsmlSourceTextSegment[];
20
+ sourceMarkers?: SsmlSourceMarker[];
21
+ concurrency?: number;
22
+ retryOptions?: RetryOptions;
23
+ }
24
+
25
+ export type MappingStatus = "exact" | "fallback" | "unmapped";
26
+
27
+ export interface AudioSpecification {
28
+ format: string;
29
+ mimeType: string;
30
+ codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
31
+ sampleRate: number;
32
+ channels: number;
33
+ bitrate?: number;
34
+ isCompressed: boolean;
35
+ }
36
+
37
+ export interface RetryOptions {
38
+ maxRetries: number;
39
+ initialDelayMs: number;
40
+ maxDelayMs: number;
17
41
  }
18
42
 
19
43
  export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
@@ -32,6 +56,7 @@ export interface SsmlSynthesisBoundary {
32
56
  /** Audio offset within the originating chunk before merge. */
33
57
  chunkAudioOffsetMs?: number;
34
58
  requestId?: string;
59
+ mappingStatus: MappingStatus;
35
60
  }
36
61
 
37
62
  export interface SsmlSynthesisViseme {
@@ -43,6 +68,7 @@ export interface SsmlSynthesisViseme {
43
68
  originalTextRange?: SsmlTextRange;
44
69
  chunkAudioOffsetMs?: number;
45
70
  requestId?: string;
71
+ mappingStatus: MappingStatus;
46
72
  }
47
73
 
48
74
  export interface SsmlSynthesisBookmark {
@@ -54,6 +80,7 @@ export interface SsmlSynthesisBookmark {
54
80
  originalTextRange?: SsmlTextRange;
55
81
  chunkAudioOffsetMs?: number;
56
82
  requestId?: string;
83
+ mappingStatus: MappingStatus;
57
84
  }
58
85
 
59
86
  /** Audio and Azure Speech synchronization events emitted for one SSML request. */
@@ -71,16 +98,31 @@ export interface SsmlSynthesisResult {
71
98
  requestId?: string;
72
99
  /** Original plain-text range represented by the result. */
73
100
  textRange?: { start: number; end: number };
101
+ /** MIME type of a result produced by an explicit merge operation. */
102
+ mimeType?: string;
103
+ audioSpec?: AudioSpecification;
104
+ }
105
+
106
+ export interface MergedSynthesisResult extends SsmlSynthesisResult {
107
+ mimeType: string;
74
108
  }
75
109
 
76
110
  export interface SsmlSynthesisChunk {
77
111
  ssml: string;
78
112
  originalTextRange?: { start: number; end: number };
79
113
  sourceNodePath?: string[];
114
+ sourceTextSegments?: SsmlSourceTextSegment[];
115
+ sourceMarkers?: SsmlSourceMarker[];
80
116
  }
81
117
 
82
118
  export interface SynthesizeChunksOptions {
83
119
  onProgress?: (event: SynthesisProgressEvent) => void;
120
+ outputFormat?: AzureTtsOutputFormat | string;
121
+ signal?: AbortSignal;
122
+ timeoutMs?: number;
123
+ sourceNodePath?: string[];
124
+ concurrency?: number;
125
+ retryOptions?: RetryOptions;
84
126
  }
85
127
 
86
128
  export interface SynthesisProgressEvent {
@@ -93,6 +135,9 @@ export interface SynthesisProgressEvent {
93
135
  status: SynthesisChunkStatus;
94
136
  durationMs: number;
95
137
  error?: unknown;
138
+ retryAttempt?: number;
139
+ nextRetryDelayMs?: number;
140
+ isRetrying?: boolean;
96
141
  }
97
142
 
98
143
  export interface AzureTtsLogger {
@@ -111,4 +156,6 @@ export interface AzureTtsClientOptions {
111
156
  outputFormat?: string;
112
157
  logger?: AzureTtsLogger;
113
158
  onProgress?: (event: SynthesisProgressEvent) => void;
159
+ concurrency?: number;
160
+ retryOptions?: RetryOptions;
114
161
  }
@@ -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);
@@ -31,7 +31,7 @@ const validSsml = (text: string) =>
31
31
  `<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
32
32
 
33
33
  test("mergeAudioBuffers rebuilds one valid WAV header", () => {
34
- const merged = mergeAudioBuffers([wav([1, 2]), wav([3, 4, 5])], "riff-16khz-16bit-mono-pcm");
34
+ const merged = mergeAudioBuffers([wav([1, 2]), wav([3, 4, 5])], { format: "riff-16khz-16bit-mono-pcm" });
35
35
  const bytes = new Uint8Array(merged);
36
36
  const view = new DataView(merged);
37
37
  assert.equal(new TextDecoder().decode(bytes.slice(0, 4)), "RIFF");
@@ -51,14 +51,18 @@ test("mergeAudioBuffers removes per-buffer ID3 tags from MP3 streams", () => {
51
51
  second.set(tag);
52
52
  second[tag.length] = 3;
53
53
  assert.deepEqual(
54
- [...new Uint8Array(mergeAudioBuffers([first.buffer, second.buffer], "audio-16khz-128kbitrate-mono-mp3"))],
54
+ [
55
+ ...new Uint8Array(
56
+ mergeAudioBuffers([first.buffer, second.buffer], { format: "audio-16khz-128kbitrate-mono-mp3" }),
57
+ ),
58
+ ],
55
59
  [1, 2, 3],
56
60
  );
57
61
  });
58
62
 
59
63
  test("mergeAudioBuffers rejects container formats that require remultiplexing", () => {
60
64
  assert.throws(
61
- () => mergeAudioBuffers([new ArrayBuffer(1)], "webm-24khz-16bit-mono-opus"),
65
+ () => mergeAudioBuffers([new ArrayBuffer(1)], { format: "webm-24khz-16bit-mono-opus" }),
62
66
  (error: unknown) => error instanceof UnsupportedMergeFormatError,
63
67
  );
64
68
  });
@@ -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
+ });
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ AudioFormatMismatchError,
5
+ AzureTtsError,
6
+ mergeAudioBuffers,
7
+ mergeSynthesisResults,
8
+ synthesizeSsmlChunksSafe,
9
+ } from "../src/index.ts";
10
+
11
+ const validSsml = (text: string) =>
12
+ `<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
13
+
14
+ function wav(data: number[], sampleRate: number): ArrayBuffer {
15
+ const output = new Uint8Array(44 + data.length + (data.length & 1));
16
+ const view = new DataView(output.buffer);
17
+ output.set(new TextEncoder().encode("RIFF"), 0);
18
+ output.set(new TextEncoder().encode("WAVEfmt "), 8);
19
+ view.setUint32(4, output.length - 8, true);
20
+ view.setUint32(16, 16, true);
21
+ view.setUint16(20, 1, true);
22
+ view.setUint16(22, 1, true);
23
+ view.setUint32(24, sampleRate, true);
24
+ view.setUint32(28, sampleRate * 2, true);
25
+ view.setUint16(32, 2, true);
26
+ view.setUint16(34, 16, true);
27
+ output.set(new TextEncoder().encode("data"), 36);
28
+ view.setUint32(40, data.length, true);
29
+ output.set(data, 44);
30
+ return output.buffer;
31
+ }
32
+
33
+ test("chunk merging rejects incompatible WAV sample rates", () => {
34
+ assert.throws(
35
+ () => mergeAudioBuffers([wav([1, 2], 16_000), wav([3, 4], 24_000)], { format: "riff-16khz-16bit-mono-pcm" }),
36
+ (error: unknown) => error instanceof AudioFormatMismatchError,
37
+ );
38
+ });
39
+
40
+ test("safe chunk synthesis retries transient errors and preserves chunk order", async () => {
41
+ let attempts = 0;
42
+ let oneAttempts = 0;
43
+ let active = 0;
44
+ let maximumActive = 0;
45
+ const completed: string[] = [];
46
+ const retryEvents: number[] = [];
47
+ const result = await synthesizeSsmlChunksSafe(
48
+ {
49
+ synthesizeSsml: async (ssml) => {
50
+ attempts += 1;
51
+ active += 1;
52
+ maximumActive = Math.max(maximumActive, active);
53
+ await new Promise((resolve) => setTimeout(resolve, ssml.includes("one") ? 5 : 1));
54
+ active -= 1;
55
+ if (ssml.includes("one")) {
56
+ oneAttempts += 1;
57
+ if (oneAttempts < 3) throw new AzureTtsError(503, "Unavailable", "", null);
58
+ }
59
+ completed.push(ssml.includes("one") ? "one" : "two");
60
+ return { audioData: Uint8Array.of(ssml.includes("one") ? 1 : 2).buffer, durationMs: 10 };
61
+ },
62
+ },
63
+ [validSsml("one"), validSsml("two")],
64
+ {
65
+ concurrency: 2,
66
+ retryOptions: { maxRetries: 2, initialDelayMs: 0, maxDelayMs: 0 },
67
+ onProgress: (event) => {
68
+ if (event.isRetrying && event.retryAttempt) retryEvents.push(event.retryAttempt);
69
+ },
70
+ },
71
+ );
72
+ assert.equal(result.ok, true);
73
+ assert.equal(maximumActive, 2);
74
+ assert.deepEqual(retryEvents, [1, 2]);
75
+ assert.equal(attempts, 4);
76
+ if (result.ok) assert.deepEqual([...new Uint8Array(result.value.audioData)], [1, 2]);
77
+ });
78
+
79
+ test("safe chunk synthesis does not retry permanent HTTP errors", async () => {
80
+ let calls = 0;
81
+ const result = await synthesizeSsmlChunksSafe(
82
+ {
83
+ synthesizeSsml: async () => {
84
+ calls += 1;
85
+ throw new AzureTtsError(400, "Bad Request", "", null);
86
+ },
87
+ },
88
+ [validSsml("bad")],
89
+ { retryOptions: { maxRetries: 3, initialDelayMs: 0, maxDelayMs: 0 } },
90
+ );
91
+ assert.equal(result.ok, false);
92
+ assert.equal(calls, 1);
93
+ });
94
+
95
+ test("custom mergers receive input specifications and cancellation", async () => {
96
+ const controller = new AbortController();
97
+ let received: { format: string; inputSpecs: unknown[]; signal: AbortSignal } | undefined;
98
+ const result = await mergeSynthesisResults([{ audioData: Uint8Array.of(1).buffer, durationMs: 1 }], {
99
+ format: "webm-24khz-16bit-mono-opus",
100
+ signal: controller.signal,
101
+ customMerger: (buffers, context) => {
102
+ received = { format: context.format, inputSpecs: context.inputSpecs, signal: context.signal };
103
+ return buffers[0] ?? new ArrayBuffer(0);
104
+ },
105
+ });
106
+ assert.equal(result.mimeType, "audio/webm");
107
+ assert.equal(received?.format, "webm-24khz-16bit-mono-opus");
108
+ assert.equal(received?.inputSpecs.length, 1);
109
+ assert.equal(received?.signal, controller.signal);
110
+ });