@ssml-builder-js/azure-tts-client 2.11.0 → 2.13.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 +17 -0
- package/dist/index.d.mts +123 -1
- package/dist/index.d.ts +123 -1
- package/dist/index.js +177 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +172 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/client.ts +38 -2
- package/src/index.ts +25 -1
- package/src/safe.ts +72 -0
- package/src/synthesis.ts +139 -4
- package/src/types.ts +59 -0
- package/test/synthesis.test.ts +40 -1
- package/test/v213-pipeline.test.ts +68 -0
package/src/safe.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { validateAzureSsml, type AzureValidationOptions, type SsmlDiagnostic } from "@ssml-builder-js/ssml-core";
|
|
2
|
+
import { AzureTtsError, createSpeechSdkError } from "./errors.ts";
|
|
3
|
+
import type { AzureTtsClient } from "./client.ts";
|
|
4
|
+
import type { SsmlSynthesisResult } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface SsmlValidationError {
|
|
7
|
+
readonly kind: "validation";
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly diagnostics: readonly SsmlDiagnostic[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Result<T, E> =
|
|
13
|
+
| { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
|
|
14
|
+
| {
|
|
15
|
+
readonly ok: false;
|
|
16
|
+
readonly success: false;
|
|
17
|
+
readonly status: "validation-error" | "azure-api-error";
|
|
18
|
+
readonly error: E;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SynthesisResult<T, E> = Result<T, E>;
|
|
22
|
+
|
|
23
|
+
export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
|
|
24
|
+
export type ValidationErrorResult = Extract<
|
|
25
|
+
Result<never, SsmlValidationError>,
|
|
26
|
+
{ readonly status: "validation-error" }
|
|
27
|
+
>;
|
|
28
|
+
export type AzureApiErrorResult = Extract<Result<never, AzureTtsError>, { readonly status: "azure-api-error" }>;
|
|
29
|
+
|
|
30
|
+
export type SsmlSynthesisSafeResult =
|
|
31
|
+
| Result<SsmlSynthesisResult, never>
|
|
32
|
+
| Result<never, SsmlValidationError>
|
|
33
|
+
| Result<never, AzureTtsError>;
|
|
34
|
+
|
|
35
|
+
export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
|
|
36
|
+
/** Optional nested form for callers that want to keep validation settings grouped. */
|
|
37
|
+
validation?: AzureValidationOptions;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SynthesisClient {
|
|
41
|
+
synthesizeSsml(ssml: string): Promise<SsmlSynthesisResult>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validates SSML before invoking Azure and converts validation/API failures to one result shape. */
|
|
45
|
+
export async function synthesizeSsmlSafe(
|
|
46
|
+
client: Pick<AzureTtsClient, "synthesizeSsml"> | SynthesisClient,
|
|
47
|
+
ssml: string,
|
|
48
|
+
options: SynthesizeSsmlSafeOptions = {},
|
|
49
|
+
): Promise<SsmlSynthesisSafeResult> {
|
|
50
|
+
const validationOptions = options.validation ?? options;
|
|
51
|
+
const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
|
|
52
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
53
|
+
if (errors.length > 0) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
success: false,
|
|
57
|
+
status: "validation-error",
|
|
58
|
+
error: {
|
|
59
|
+
kind: "validation",
|
|
60
|
+
message: "SSML validation failed; the Azure Speech API was not called.",
|
|
61
|
+
diagnostics: errors,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
return { ok: true, success: true, status: "success", value: await client.synthesizeSsml(ssml) };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const azureError = error instanceof AzureTtsError ? error : createSpeechSdkError(error);
|
|
70
|
+
return { ok: false, success: false, status: "azure-api-error", error: azureError };
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/synthesis.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
|
|
2
2
|
import { createSpeechSdkError } from "./errors.ts";
|
|
3
3
|
import { createSpeechConfig } from "./speechConfig.ts";
|
|
4
|
-
import type { TtsConfig } from "./types.ts";
|
|
4
|
+
import type { SsmlSynthesisChunk, SsmlSynthesisResult, TtsConfig } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer: SpeechSDK.SpeechSynthesizer): void {
|
|
7
7
|
try {
|
|
@@ -13,7 +13,9 @@ function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer:
|
|
|
13
13
|
} catch {}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_000;
|
|
17
|
+
|
|
18
|
+
export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
17
19
|
if (config.signal?.aborted) {
|
|
18
20
|
throw createSpeechSdkError("Speech synthesis was cancelled.");
|
|
19
21
|
}
|
|
@@ -21,7 +23,7 @@ export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise
|
|
|
21
23
|
const speechConfig = createSpeechConfig(config);
|
|
22
24
|
const synthesizer = new SpeechSDK.SpeechSynthesizer(speechConfig, null);
|
|
23
25
|
|
|
24
|
-
return await new Promise<
|
|
26
|
+
return await new Promise<SsmlSynthesisResult>((resolve, reject) => {
|
|
25
27
|
let resourcesClosed = false;
|
|
26
28
|
let settled = false;
|
|
27
29
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -43,6 +45,23 @@ export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise
|
|
|
43
45
|
reject(createSpeechSdkError(error));
|
|
44
46
|
};
|
|
45
47
|
|
|
48
|
+
const boundaries: SsmlSynthesisResult["boundaries"] = [];
|
|
49
|
+
const visemes: SsmlSynthesisResult["visemes"] = [];
|
|
50
|
+
const bookmarks: SsmlSynthesisResult["bookmarks"] = [];
|
|
51
|
+
synthesizer.wordBoundary = (_sender, event) => {
|
|
52
|
+
boundaries.push({
|
|
53
|
+
text: event.text,
|
|
54
|
+
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
55
|
+
durationMs: ticksToMilliseconds(event.duration),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
synthesizer.visemeReceived = (_sender, event) => {
|
|
59
|
+
visemes.push({ visemeId: event.visemeId, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
|
|
60
|
+
};
|
|
61
|
+
synthesizer.bookmarkReached = (_sender, event) => {
|
|
62
|
+
bookmarks.push({ name: event.text, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
|
|
63
|
+
};
|
|
64
|
+
|
|
46
65
|
const cb = (result: SpeechSDK.SpeechSynthesisResult) => {
|
|
47
66
|
if (settled) return;
|
|
48
67
|
const { reason, errorDetails } = result;
|
|
@@ -54,7 +73,33 @@ export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise
|
|
|
54
73
|
settled = true;
|
|
55
74
|
cleanup();
|
|
56
75
|
closeResources();
|
|
57
|
-
|
|
76
|
+
const eventDurationMs = Math.max(
|
|
77
|
+
0,
|
|
78
|
+
...(boundaries ?? []).map((boundary) => boundary.audioOffsetMs + boundary.durationMs),
|
|
79
|
+
...(visemes ?? []).map((viseme) => viseme.audioOffsetMs),
|
|
80
|
+
...(bookmarks ?? []).map((bookmark) => bookmark.audioOffsetMs),
|
|
81
|
+
);
|
|
82
|
+
const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
|
|
83
|
+
const requestId = (result as SpeechSDK.SpeechSynthesisResult & { resultId?: string }).resultId;
|
|
84
|
+
const addSourceMetadata = <T extends { audioOffsetMs: number }>(event: T): T => ({
|
|
85
|
+
...event,
|
|
86
|
+
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
87
|
+
...(requestId ? { requestId } : {}),
|
|
88
|
+
});
|
|
89
|
+
const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
|
|
90
|
+
const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
|
|
91
|
+
const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
|
|
92
|
+
resolve({
|
|
93
|
+
audioData: result.audioData,
|
|
94
|
+
durationMs,
|
|
95
|
+
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
96
|
+
...(requestId ? { requestId } : {}),
|
|
97
|
+
...(sourceBoundaries.length > 0
|
|
98
|
+
? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries }
|
|
99
|
+
: {}),
|
|
100
|
+
...(sourceVisemes.length > 0 ? { visemes: sourceVisemes } : {}),
|
|
101
|
+
...(sourceBookmarks.length > 0 ? { bookmarks: sourceBookmarks } : {}),
|
|
102
|
+
});
|
|
58
103
|
};
|
|
59
104
|
|
|
60
105
|
try {
|
|
@@ -74,3 +119,93 @@ export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise
|
|
|
74
119
|
}
|
|
75
120
|
});
|
|
76
121
|
}
|
|
122
|
+
|
|
123
|
+
/** Synthesizes chunks sequentially, annotates synchronization events, and merges the results. */
|
|
124
|
+
export async function synthesizeSsmlChunks(
|
|
125
|
+
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
126
|
+
config: TtsConfig,
|
|
127
|
+
): Promise<SsmlSynthesisResult> {
|
|
128
|
+
const results: SsmlSynthesisResult[] = [];
|
|
129
|
+
const totalChunks = chunks.length;
|
|
130
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
131
|
+
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
132
|
+
const result = await synthesizeSsml(input.ssml, {
|
|
133
|
+
...config,
|
|
134
|
+
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
135
|
+
onProgress: undefined,
|
|
136
|
+
});
|
|
137
|
+
results.push(result);
|
|
138
|
+
config.onProgress?.({
|
|
139
|
+
currentChunk: index + 1,
|
|
140
|
+
totalChunks,
|
|
141
|
+
percent: totalChunks === 0 ? 100 : Math.round(((index + 1) / totalChunks) * 100),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return mergeSynthesisResults(results);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
|
|
148
|
+
export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[]): SsmlSynthesisResult {
|
|
149
|
+
const audioLength = results.reduce((total, result) => total + result.audioData.byteLength, 0);
|
|
150
|
+
const audioData = new Uint8Array(audioLength);
|
|
151
|
+
const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
|
|
152
|
+
const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
|
|
153
|
+
const bookmarks: NonNullable<SsmlSynthesisResult["bookmarks"]> = [];
|
|
154
|
+
let byteOffset = 0;
|
|
155
|
+
let durationOffset = 0;
|
|
156
|
+
|
|
157
|
+
for (const result of results) {
|
|
158
|
+
audioData.set(new Uint8Array(result.audioData), byteOffset);
|
|
159
|
+
byteOffset += result.audioData.byteLength;
|
|
160
|
+
const chunkBoundaries =
|
|
161
|
+
result.boundaries && result.boundaries.length > 0
|
|
162
|
+
? result.boundaries
|
|
163
|
+
: (result.wordBoundary ?? result.wordBoundaries ?? []);
|
|
164
|
+
for (const boundary of chunkBoundaries) {
|
|
165
|
+
const textRange = boundary.textRange ?? result.textRange;
|
|
166
|
+
const requestId = boundary.requestId ?? result.requestId;
|
|
167
|
+
boundaries.push({
|
|
168
|
+
...boundary,
|
|
169
|
+
audioOffsetMs: boundary.audioOffsetMs + durationOffset,
|
|
170
|
+
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
171
|
+
...(requestId ? { requestId } : {}),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
for (const viseme of result.visemes ?? []) {
|
|
175
|
+
const textRange = viseme.textRange ?? result.textRange;
|
|
176
|
+
const requestId = viseme.requestId ?? result.requestId;
|
|
177
|
+
visemes.push({
|
|
178
|
+
...viseme,
|
|
179
|
+
audioOffsetMs: viseme.audioOffsetMs + durationOffset,
|
|
180
|
+
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
181
|
+
...(requestId ? { requestId } : {}),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
for (const bookmark of result.bookmarks ?? []) {
|
|
185
|
+
const textRange = bookmark.textRange ?? result.textRange;
|
|
186
|
+
const requestId = bookmark.requestId ?? result.requestId;
|
|
187
|
+
bookmarks.push({
|
|
188
|
+
...bookmark,
|
|
189
|
+
audioOffsetMs: bookmark.audioOffsetMs + durationOffset,
|
|
190
|
+
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
191
|
+
...(requestId ? { requestId } : {}),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
durationOffset += Math.max(0, result.durationMs);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
audioData: audioData.buffer,
|
|
199
|
+
durationMs: durationOffset,
|
|
200
|
+
...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
|
|
201
|
+
...(visemes.length > 0 ? { visemes } : {}),
|
|
202
|
+
...(bookmarks.length > 0 ? { bookmarks } : {}),
|
|
203
|
+
...(results.length === 1 && results[0]?.requestId ? { requestId: results[0].requestId } : {}),
|
|
204
|
+
...(results.length === 1 && results[0]?.textRange ? { textRange: { ...results[0].textRange } } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Backward-compatible audio-only synthesis helper. */
|
|
209
|
+
export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise<ArrayBuffer> {
|
|
210
|
+
return (await synthesizeSsml(ssml, config)).audioData;
|
|
211
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -5,6 +5,64 @@ export interface TtsConfig {
|
|
|
5
5
|
subscriptionKey: string;
|
|
6
6
|
region: string;
|
|
7
7
|
outputFormat?: string;
|
|
8
|
+
/** Original plain-text range represented by this synthesis request. */
|
|
9
|
+
sourceTextRange?: { start: number; end: number };
|
|
10
|
+
/** Reports completion of a chunk when using synthesizeSsmlChunks. */
|
|
11
|
+
onProgress?: (event: { currentChunk: number; totalChunks: number; percent: number }) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SsmlSynthesisBoundary {
|
|
15
|
+
text: string;
|
|
16
|
+
audioOffsetMs: number;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
textRange?: { start: number; end: number };
|
|
19
|
+
requestId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SsmlSynthesisViseme {
|
|
23
|
+
visemeId: number;
|
|
24
|
+
audioOffsetMs: number;
|
|
25
|
+
textRange?: { start: number; end: number };
|
|
26
|
+
requestId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SsmlSynthesisBookmark {
|
|
30
|
+
name: string;
|
|
31
|
+
audioOffsetMs: number;
|
|
32
|
+
textRange?: { start: number; end: number };
|
|
33
|
+
requestId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Audio and Azure Speech synchronization events emitted for one SSML request. */
|
|
37
|
+
export interface SsmlSynthesisResult {
|
|
38
|
+
audioData: ArrayBuffer;
|
|
39
|
+
durationMs: number;
|
|
40
|
+
boundaries?: SsmlSynthesisBoundary[];
|
|
41
|
+
/** Alias matching the Azure Speech event name. */
|
|
42
|
+
wordBoundary?: SsmlSynthesisBoundary[];
|
|
43
|
+
/** Alias for consumers that use Azure's word-boundary terminology. */
|
|
44
|
+
wordBoundaries?: SsmlSynthesisBoundary[];
|
|
45
|
+
visemes?: SsmlSynthesisViseme[];
|
|
46
|
+
bookmarks?: SsmlSynthesisBookmark[];
|
|
47
|
+
/** Request identifier returned by Azure Speech, when available. */
|
|
48
|
+
requestId?: string;
|
|
49
|
+
/** Original plain-text range represented by the result. */
|
|
50
|
+
textRange?: { start: number; end: number };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SsmlSynthesisChunk {
|
|
54
|
+
ssml: string;
|
|
55
|
+
originalTextRange?: { start: number; end: number };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SynthesizeChunksOptions {
|
|
59
|
+
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SynthesisProgressEvent {
|
|
63
|
+
currentChunk: number;
|
|
64
|
+
totalChunks: number;
|
|
65
|
+
percent: number;
|
|
8
66
|
}
|
|
9
67
|
|
|
10
68
|
export interface AzureTtsLogger {
|
|
@@ -22,4 +80,5 @@ export interface AzureTtsClientOptions {
|
|
|
22
80
|
endpoint?: string;
|
|
23
81
|
outputFormat?: string;
|
|
24
82
|
logger?: AzureTtsLogger;
|
|
83
|
+
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
25
84
|
}
|
package/test/synthesis.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test, { type TestContext } from "node:test";
|
|
3
3
|
import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
|
|
4
|
-
import { synthesizeSpeech } from "../src/index.ts";
|
|
4
|
+
import { synthesizeSpeech, synthesizeSsml } from "../src/index.ts";
|
|
5
5
|
|
|
6
6
|
const endpoint = "https://speech.example.test/cognitiveservices/v1";
|
|
7
7
|
const subscriptionKey = "subscription-key";
|
|
@@ -51,6 +51,45 @@ test("synthesizeSpeech aborts the SDK request and settles its promise", async (t
|
|
|
51
51
|
assert.equal(closeCount, 1);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test("synthesizeSsml returns word boundaries, visemes, bookmarks, and duration", async (t) => {
|
|
55
|
+
const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
|
|
56
|
+
t.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (speechEndpoint, key) =>
|
|
57
|
+
originalFromEndpoint(speechEndpoint, String(key)),
|
|
58
|
+
);
|
|
59
|
+
t.mock.method(
|
|
60
|
+
SpeechSDK.SpeechSynthesizer.prototype,
|
|
61
|
+
"speakSsmlAsync",
|
|
62
|
+
function (this: SpeechSDK.SpeechSynthesizer, _ssml, callback) {
|
|
63
|
+
this.wordBoundary?.(this, {
|
|
64
|
+
text: "Hello",
|
|
65
|
+
audioOffset: 1_000_000,
|
|
66
|
+
duration: 250_000,
|
|
67
|
+
} as SpeechSDK.SpeechSynthesisWordBoundaryEventArgs);
|
|
68
|
+
this.visemeReceived?.(this, { visemeId: 4, audioOffset: 2_000_000 } as SpeechSDK.SpeechSynthesisVisemeEventArgs);
|
|
69
|
+
this.bookmarkReached?.(this, {
|
|
70
|
+
text: "chapter-1",
|
|
71
|
+
audioOffset: 3_000_000,
|
|
72
|
+
} as SpeechSDK.SpeechSynthesisBookmarkEventArgs);
|
|
73
|
+
callback?.({
|
|
74
|
+
audioData: new ArrayBuffer(2),
|
|
75
|
+
audioDuration: 4_000_000,
|
|
76
|
+
errorDetails: "",
|
|
77
|
+
reason: SpeechSDK.ResultReason.SynthesizingAudioCompleted,
|
|
78
|
+
} as SpeechSDK.SpeechSynthesisResult);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => undefined);
|
|
82
|
+
|
|
83
|
+
const result = await synthesizeSsml("<speak>Hello</speak>", { endpoint, subscriptionKey, region });
|
|
84
|
+
|
|
85
|
+
assert.deepEqual(result.boundaries, [{ text: "Hello", audioOffsetMs: 100, durationMs: 25 }]);
|
|
86
|
+
assert.deepEqual(result.wordBoundary, result.boundaries);
|
|
87
|
+
assert.deepEqual(result.wordBoundaries, result.boundaries);
|
|
88
|
+
assert.deepEqual(result.visemes, [{ visemeId: 4, audioOffsetMs: 200 }]);
|
|
89
|
+
assert.deepEqual(result.bookmarks, [{ name: "chapter-1", audioOffsetMs: 300 }]);
|
|
90
|
+
assert.equal(result.durationMs, 400);
|
|
91
|
+
});
|
|
92
|
+
|
|
54
93
|
test("synthesizeSpeech aborts the SDK request on timeout and settles its promise", async (t) => {
|
|
55
94
|
let closeCount = 0;
|
|
56
95
|
let resultCallback: ((result: SpeechSDK.SpeechSynthesisResult) => void) | undefined;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mergeSynthesisResults, synthesizeSsmlSafe } from "../src/index.ts";
|
|
4
|
+
|
|
5
|
+
const audio = (values: number[]): ArrayBuffer => Uint8Array.from(values).buffer;
|
|
6
|
+
|
|
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
|
+
]);
|
|
24
|
+
|
|
25
|
+
assert.deepEqual([...new Uint8Array(result.audioData)], [1, 2, 3, 4, 5]);
|
|
26
|
+
assert.equal(result.durationMs, 350);
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
result.boundaries?.map(({ audioOffsetMs }) => audioOffsetMs),
|
|
29
|
+
[20, 110],
|
|
30
|
+
);
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
result.visemes?.map(({ audioOffsetMs }) => audioOffsetMs),
|
|
33
|
+
[40, 115],
|
|
34
|
+
);
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
result.bookmarks?.map(({ audioOffsetMs }) => audioOffsetMs),
|
|
37
|
+
[50, 125],
|
|
38
|
+
);
|
|
39
|
+
assert.deepEqual(result.boundaries?.[1]?.textRange, { start: 3, end: 6 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("synthesizeSsmlSafe blocks invalid SSML without calling the client", async () => {
|
|
43
|
+
let calls = 0;
|
|
44
|
+
const result = await synthesizeSsmlSafe(
|
|
45
|
+
{
|
|
46
|
+
synthesizeSsml: async () => {
|
|
47
|
+
calls += 1;
|
|
48
|
+
return { audioData: new ArrayBuffer(0), durationMs: 0 };
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"<speak>",
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
assert.equal(result.ok, false);
|
|
55
|
+
assert.equal(result.status, "validation-error");
|
|
56
|
+
assert.equal(calls, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("synthesizeSsmlSafe returns a successful result for valid SSML", async () => {
|
|
60
|
+
const expected = { audioData: audio([1]), durationMs: 10 };
|
|
61
|
+
const result = await synthesizeSsmlSafe(
|
|
62
|
+
{ synthesizeSsml: async () => expected },
|
|
63
|
+
'<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">Hello</voice></speak>',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
assert.equal(result.ok, true);
|
|
67
|
+
if (result.ok) assert.equal(result.value, expected);
|
|
68
|
+
});
|