@ssml-builder-js/azure-tts-client 2.5.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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@ssml-builder-js/azure-tts-client",
3
+ "version": "2.5.0",
4
+ "description": "Azure Text-to-Speech client using the Microsoft Speech SDK",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/nitta-a/SSML-Builder.git",
9
+ "directory": "packages/azure-tts-client"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/nitta-a/SSML-Builder/issues"
13
+ },
14
+ "homepage": "https://github.com/nitta-a/SSML-Builder#azure-tts-client",
15
+ "main": "dist/index.js",
16
+ "module": "dist/index.mjs",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.mjs",
22
+ "require": "./dist/index.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "node --experimental-strip-types --test test/*.test.ts"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^6.0.3"
36
+ },
37
+ "dependencies": {
38
+ "microsoft-cognitiveservices-speech-sdk": "1.51.0"
39
+ }
40
+ }
package/src/client.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { synthesizeSpeech } from "./synthesis.ts";
2
+ import type { AzureTtsClientOptions } from "./types.ts";
3
+
4
+ const ENDPOINT_TEMPLATE = "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1";
5
+
6
+ export class AzureTtsClient {
7
+ readonly #options: AzureTtsClientOptions;
8
+
9
+ constructor(options: AzureTtsClientOptions) {
10
+ this.#options = options;
11
+ }
12
+
13
+ async synthesize(ssml: string): Promise<ArrayBuffer> {
14
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs } = this.#options;
15
+ const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
16
+ this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
17
+
18
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
19
+ return synthesizeSpeech(ssml, config);
20
+ }
21
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,31 @@
1
+ export class AzureTtsError extends Error {
2
+ readonly status: number;
3
+ readonly statusText: string;
4
+ readonly responseBody: string;
5
+ readonly requestId: string | null;
6
+
7
+ constructor(status: number, statusText: string, responseBody: string, requestId: string | null) {
8
+ super(`Azure TTS request failed: ${status} ${statusText}`);
9
+ this.name = "AzureTtsError";
10
+ this.status = status;
11
+ this.statusText = statusText;
12
+ this.responseBody = responseBody;
13
+ this.requestId = requestId;
14
+ }
15
+ }
16
+
17
+ export class AzureTtsSdkError extends AzureTtsError {
18
+ readonly errorDetails: string;
19
+
20
+ constructor(errorDetails: string) {
21
+ super(0, "Speech SDK", errorDetails, null);
22
+ this.name = "AzureTtsSdkError";
23
+ this.message = `Azure TTS synthesis failed: ${errorDetails}`;
24
+ this.errorDetails = errorDetails;
25
+ }
26
+ }
27
+
28
+ export function createSpeechSdkError(error: unknown): AzureTtsSdkError {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return new AzureTtsSdkError(message);
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * azure-tts-client: Azure Text-to-Speech client for SSML playback.
3
+ */
4
+
5
+ export type { AzureTtsClientOptions, AzureTtsLogger, TtsConfig } from "./types.ts";
6
+ export { AzureTtsError, AzureTtsSdkError } from "./errors.ts";
7
+ export { AzureTtsClient } from "./client.ts";
8
+ export { synthesizeSpeech } from "./synthesis.ts";
@@ -0,0 +1,54 @@
1
+ import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
2
+
3
+ export const DEFAULT_OUTPUT_FORMAT = "audio-16khz-128kbitrate-mono-mp3";
4
+
5
+ const OUTPUT_FORMATS: Record<string, SpeechSDK.SpeechSynthesisOutputFormat> = {
6
+ "raw-8khz-8bit-mono-mulaw": SpeechSDK.SpeechSynthesisOutputFormat.Raw8Khz8BitMonoMULaw,
7
+ "riff-16khz-16kbps-mono-siren": SpeechSDK.SpeechSynthesisOutputFormat.Riff16Khz16KbpsMonoSiren,
8
+ "audio-16khz-16kbps-mono-siren": SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz16KbpsMonoSiren,
9
+ "audio-16khz-32kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3,
10
+ "audio-16khz-128kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz128KBitRateMonoMp3,
11
+ "audio-16khz-64kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz64KBitRateMonoMp3,
12
+ "audio-24khz-48kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3,
13
+ "audio-24khz-96kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz96KBitRateMonoMp3,
14
+ "audio-24khz-160kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3,
15
+ "raw-16khz-16bit-mono-truesilk": SpeechSDK.SpeechSynthesisOutputFormat.Raw16Khz16BitMonoTrueSilk,
16
+ "riff-16khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Riff16Khz16BitMonoPcm,
17
+ "riff-8khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Riff8Khz16BitMonoPcm,
18
+ "riff-24khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm,
19
+ "riff-8khz-8bit-mono-mulaw": SpeechSDK.SpeechSynthesisOutputFormat.Riff8Khz8BitMonoMULaw,
20
+ "raw-16khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Raw16Khz16BitMonoPcm,
21
+ "raw-24khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Raw24Khz16BitMonoPcm,
22
+ "raw-8khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Raw8Khz16BitMonoPcm,
23
+ "ogg-16khz-16bit-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Ogg16Khz16BitMonoOpus,
24
+ "ogg-24khz-16bit-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Ogg24Khz16BitMonoOpus,
25
+ "raw-48khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Raw48Khz16BitMonoPcm,
26
+ "riff-48khz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Riff48Khz16BitMonoPcm,
27
+ "audio-48khz-96kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio48Khz96KBitRateMonoMp3,
28
+ "audio-48khz-192kbitrate-mono-mp3": SpeechSDK.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3,
29
+ "ogg-48khz-16bit-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Ogg48Khz16BitMonoOpus,
30
+ "webm-16khz-16bit-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Webm16Khz16BitMonoOpus,
31
+ "webm-24khz-16bit-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Webm24Khz16BitMonoOpus,
32
+ "webm-24khz-16bit-24kbps-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Webm24Khz16Bit24KbpsMonoOpus,
33
+ "raw-24khz-16bit-mono-truesilk": SpeechSDK.SpeechSynthesisOutputFormat.Raw24Khz16BitMonoTrueSilk,
34
+ "raw-8khz-8bit-mono-alaw": SpeechSDK.SpeechSynthesisOutputFormat.Raw8Khz8BitMonoALaw,
35
+ "riff-8khz-8bit-mono-alaw": SpeechSDK.SpeechSynthesisOutputFormat.Riff8Khz8BitMonoALaw,
36
+ "audio-16khz-16bit-32kbps-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz16Bit32KbpsMonoOpus,
37
+ "audio-24khz-16bit-48kbps-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz16Bit48KbpsMonoOpus,
38
+ "audio-24khz-16bit-24kbps-mono-opus": SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz16Bit24KbpsMonoOpus,
39
+ "raw-22050hz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Raw22050Hz16BitMonoPcm,
40
+ "riff-22050hz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Riff22050Hz16BitMonoPcm,
41
+ "raw-44100hz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Raw44100Hz16BitMonoPcm,
42
+ "riff-44100hz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Riff44100Hz16BitMonoPcm,
43
+ "amr-wb-16000hz": SpeechSDK.SpeechSynthesisOutputFormat.AmrWb16000Hz,
44
+ "g722-16khz-64kbps": SpeechSDK.SpeechSynthesisOutputFormat.G72216Khz64Kbps,
45
+ };
46
+
47
+ export function resolveOutputFormat(outputFormat: string): SpeechSDK.SpeechSynthesisOutputFormat {
48
+ const resolvedFormat = OUTPUT_FORMATS[outputFormat];
49
+ if (resolvedFormat === undefined) {
50
+ throw new Error(`Unsupported Azure Speech output format: ${outputFormat}`);
51
+ }
52
+
53
+ return resolvedFormat;
54
+ }
@@ -0,0 +1,17 @@
1
+ import { SpeechConfig } from "microsoft-cognitiveservices-speech-sdk";
2
+ import { DEFAULT_OUTPUT_FORMAT, resolveOutputFormat } from "./outputFormats.ts";
3
+ import type { TtsConfig } from "./types.ts";
4
+
5
+ export function resolveEndpoint(config: TtsConfig): string {
6
+ const endpoint = config.endpoint?.trim() || "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1";
7
+ return endpoint.replace(/\{region\}/g, encodeURIComponent(config.region));
8
+ }
9
+
10
+ export function createSpeechConfig(config: TtsConfig): SpeechConfig {
11
+ const { outputFormat = DEFAULT_OUTPUT_FORMAT, subscriptionKey } = config;
12
+
13
+ const endpoint = new URL(resolveEndpoint(config));
14
+ const speechConfig = SpeechConfig.fromEndpoint(endpoint, subscriptionKey);
15
+ speechConfig.speechSynthesisOutputFormat = resolveOutputFormat(outputFormat);
16
+ return speechConfig;
17
+ }
@@ -0,0 +1,76 @@
1
+ import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
2
+ import { createSpeechSdkError } from "./errors.ts";
3
+ import { createSpeechConfig } from "./speechConfig.ts";
4
+ import type { TtsConfig } from "./types.ts";
5
+
6
+ function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer: SpeechSDK.SpeechSynthesizer): void {
7
+ try {
8
+ synthesizer.close();
9
+ } catch {}
10
+
11
+ try {
12
+ speechConfig.close();
13
+ } catch {}
14
+ }
15
+
16
+ export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise<ArrayBuffer> {
17
+ if (config.signal?.aborted) {
18
+ throw createSpeechSdkError("Speech synthesis was cancelled.");
19
+ }
20
+
21
+ const speechConfig = createSpeechConfig(config);
22
+ const synthesizer = new SpeechSDK.SpeechSynthesizer(speechConfig, null);
23
+
24
+ return await new Promise<ArrayBuffer>((resolve, reject) => {
25
+ let resourcesClosed = false;
26
+ let settled = false;
27
+ let timeout: ReturnType<typeof setTimeout> | undefined;
28
+ let abortHandler: (() => void) | undefined;
29
+ const cleanup = () => {
30
+ if (timeout) clearTimeout(timeout);
31
+ if (abortHandler) config.signal?.removeEventListener("abort", abortHandler);
32
+ };
33
+ const closeResources = () => {
34
+ if (resourcesClosed) return;
35
+ resourcesClosed = true;
36
+ closeSpeechResources(speechConfig, synthesizer);
37
+ };
38
+ const rejectWithError = (error: unknown) => {
39
+ if (settled) return;
40
+ settled = true;
41
+ cleanup();
42
+ closeResources();
43
+ reject(createSpeechSdkError(error));
44
+ };
45
+
46
+ const cb = (result: SpeechSDK.SpeechSynthesisResult) => {
47
+ if (settled) return;
48
+ const { reason, errorDetails } = result;
49
+ if (reason !== SpeechSDK.ResultReason.SynthesizingAudioCompleted) {
50
+ const err = errorDetails || `Speech synthesis failed with reason ${reason}.`;
51
+ rejectWithError(err);
52
+ return;
53
+ }
54
+ settled = true;
55
+ cleanup();
56
+ closeResources();
57
+ resolve(result.audioData);
58
+ };
59
+
60
+ try {
61
+ if (config.signal) {
62
+ abortHandler = () => rejectWithError("Speech synthesis was cancelled.");
63
+ config.signal.addEventListener("abort", abortHandler, { once: true });
64
+ }
65
+ if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
66
+ timeout = setTimeout(
67
+ () => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
68
+ config.timeoutMs,
69
+ );
70
+ }
71
+ synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
72
+ } catch (error) {
73
+ rejectWithError(error);
74
+ }
75
+ });
76
+ }
package/src/types.ts ADDED
@@ -0,0 +1,25 @@
1
+ export interface TtsConfig {
2
+ signal?: AbortSignal;
3
+ timeoutMs?: number;
4
+ endpoint?: string;
5
+ subscriptionKey: string;
6
+ region: string;
7
+ outputFormat?: string;
8
+ }
9
+
10
+ export interface AzureTtsLogger {
11
+ debug?: (...args: unknown[]) => void;
12
+ info?: (...args: unknown[]) => void;
13
+ warn?: (...args: unknown[]) => void;
14
+ error?: (...args: unknown[]) => void;
15
+ }
16
+
17
+ export interface AzureTtsClientOptions {
18
+ signal?: AbortSignal;
19
+ timeoutMs?: number;
20
+ subscriptionKey: string;
21
+ region: string;
22
+ endpoint?: string;
23
+ outputFormat?: string;
24
+ logger?: AzureTtsLogger;
25
+ }
@@ -0,0 +1,121 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { type TestContext } from "node:test";
3
+ import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
4
+ import { AzureTtsClient, synthesizeSpeech } from "../src/index.ts";
5
+
6
+ function installSuccessfulSpeechSdkMock(testContext: TestContext, audio: ArrayBuffer): { endpoint?: URL } {
7
+ const captured: { endpoint?: URL } = {};
8
+ const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
9
+
10
+ testContext.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (endpoint, subscriptionKey) => {
11
+ captured.endpoint = endpoint;
12
+ return originalFromEndpoint(endpoint, String(subscriptionKey));
13
+ });
14
+ testContext.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "speakSsmlAsync", (_ssml, callback) => {
15
+ callback?.({
16
+ audioData: audio,
17
+ errorDetails: "",
18
+ reason: SpeechSDK.ResultReason.SynthesizingAudioCompleted,
19
+ } as SpeechSDK.SpeechSynthesisResult);
20
+ });
21
+ testContext.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => {});
22
+
23
+ return captured;
24
+ }
25
+
26
+ test("synthesizeSpeech replaces every endpoint region placeholder", async (t) => {
27
+ const audio = new ArrayBuffer(1);
28
+ const speechSdkMock = installSuccessfulSpeechSdkMock(t, audio);
29
+
30
+ const result = await synthesizeSpeech("<speak>Hello</speak>", {
31
+ endpoint: "https://{region}.example.test/{region}",
32
+ subscriptionKey: "subscription-key",
33
+ region: "japan-east",
34
+ });
35
+
36
+ assert.equal(speechSdkMock.endpoint?.href, "https://japan-east.example.test/japan-east");
37
+ assert.strictEqual(result, audio);
38
+ });
39
+
40
+ test("synthesizeSpeech URL-encodes special regions in endpoint paths", async (t) => {
41
+ const audio = new ArrayBuffer(1);
42
+ const speechSdkMock = installSuccessfulSpeechSdkMock(t, audio);
43
+
44
+ await synthesizeSpeech("<speak>Hello</speak>", {
45
+ endpoint: "https://speech.example.test/{region}/{region}",
46
+ subscriptionKey: "subscription-key",
47
+ region: "japan east",
48
+ });
49
+
50
+ assert.equal(speechSdkMock.endpoint?.href, "https://speech.example.test/japan%20east/japan%20east");
51
+ });
52
+
53
+ test("AzureTtsClient reports Speech SDK callback failures", async (t) => {
54
+ const errorDetails = "network unavailable";
55
+ const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
56
+
57
+ t.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (endpoint, subscriptionKey) =>
58
+ originalFromEndpoint(endpoint, String(subscriptionKey)),
59
+ );
60
+ t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "speakSsmlAsync", (_ssml, _callback, errorCallback) => {
61
+ errorCallback?.(errorDetails);
62
+ });
63
+ t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => {});
64
+
65
+ await assert.rejects(
66
+ new AzureTtsClient({
67
+ endpoint: "https://speech.example.test/cognitiveservices/v1",
68
+ subscriptionKey: "subscription-key",
69
+ region: "japaneast",
70
+ }).synthesize("<speak>Hello</speak>"),
71
+ (error: unknown) => {
72
+ assert.equal(error instanceof Error ? error.message : error, "Azure TTS synthesis failed: network unavailable");
73
+ assert.ok(error instanceof Error);
74
+ assert.equal(error.name, "AzureTtsSdkError");
75
+ return true;
76
+ },
77
+ );
78
+ });
79
+
80
+ test("synthesizeSpeech aborts a timed-out synthesis and closes resources", async (t) => {
81
+ const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
82
+ let closeCount = 0;
83
+ t.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (endpoint, subscriptionKey) =>
84
+ originalFromEndpoint(endpoint, String(subscriptionKey)),
85
+ );
86
+ t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "speakSsmlAsync", () => {});
87
+ t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => {
88
+ closeCount += 1;
89
+ });
90
+
91
+ await assert.rejects(
92
+ synthesizeSpeech("<speak>Hello</speak>", {
93
+ endpoint: "https://speech.example.test/cognitiveservices/v1",
94
+ subscriptionKey: "subscription-key",
95
+ region: "japaneast",
96
+ timeoutMs: 10,
97
+ }),
98
+ /timed out after 10 ms/,
99
+ );
100
+ assert.equal(closeCount, 1);
101
+ });
102
+
103
+ test("synthesizeSpeech can be cancelled with an AbortSignal", async (t) => {
104
+ const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
105
+ const controller = new AbortController();
106
+ t.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (endpoint, subscriptionKey) =>
107
+ originalFromEndpoint(endpoint, String(subscriptionKey)),
108
+ );
109
+ t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "speakSsmlAsync", () => {});
110
+ t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => {});
111
+
112
+ const promise = synthesizeSpeech("<speak>Hello</speak>", {
113
+ endpoint: "https://speech.example.test/cognitiveservices/v1",
114
+ subscriptionKey: "subscription-key",
115
+ region: "japaneast",
116
+ signal: controller.signal,
117
+ });
118
+ controller.abort();
119
+
120
+ await assert.rejects(promise, /cancelled/);
121
+ });
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AzureTtsError, AzureTtsSdkError, createSpeechSdkError } from "../src/errors.ts";
4
+
5
+ test("AzureTtsError exposes HTTP response metadata", () => {
6
+ const error = new AzureTtsError(401, "Unauthorized", '{"error":"invalid key"}', "request-id");
7
+
8
+ assert.ok(error instanceof Error);
9
+ assert.equal(error.name, "AzureTtsError");
10
+ assert.equal(error.message, "Azure TTS request failed: 401 Unauthorized");
11
+ assert.equal(error.status, 401);
12
+ assert.equal(error.statusText, "Unauthorized");
13
+ assert.equal(error.responseBody, '{"error":"invalid key"}');
14
+ assert.equal(error.requestId, "request-id");
15
+ });
16
+
17
+ test("AzureTtsSdkError preserves SDK error details", () => {
18
+ const error = new AzureTtsSdkError("The SSML is invalid.");
19
+
20
+ assert.ok(error instanceof AzureTtsError);
21
+ assert.equal(error.name, "AzureTtsSdkError");
22
+ assert.equal(error.message, "Azure TTS synthesis failed: The SSML is invalid.");
23
+ assert.equal(error.status, 0);
24
+ assert.equal(error.statusText, "Speech SDK");
25
+ assert.equal(error.responseBody, "The SSML is invalid.");
26
+ assert.equal(error.requestId, null);
27
+ assert.equal(error.errorDetails, "The SSML is invalid.");
28
+ });
29
+
30
+ test("createSpeechSdkError normalizes Error and unknown values", () => {
31
+ assert.equal(
32
+ createSpeechSdkError(new Error("network unavailable")).message,
33
+ "Azure TTS synthesis failed: network unavailable",
34
+ );
35
+ assert.equal(createSpeechSdkError("request failed").errorDetails, "request failed");
36
+ });
@@ -0,0 +1,139 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { type TestContext } from "node:test";
3
+ import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
4
+ import { AzureTtsClient, AzureTtsError, AzureTtsSdkError, synthesizeSpeech } from "../src/index.ts";
5
+
6
+ type SpeechSdkMock = {
7
+ endpoint?: URL;
8
+ subscriptionKey?: string;
9
+ speechConfig?: SpeechSDK.SpeechConfig;
10
+ ssml?: string;
11
+ closeCount: number;
12
+ };
13
+
14
+ function installSpeechSdkMock(testContext: TestContext, audio: ArrayBuffer): SpeechSdkMock {
15
+ const captured: SpeechSdkMock = { closeCount: 0 };
16
+ const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
17
+
18
+ testContext.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (endpoint, subscriptionKey) => {
19
+ captured.endpoint = endpoint;
20
+ captured.subscriptionKey = String(subscriptionKey);
21
+ const speechConfig = originalFromEndpoint(endpoint, String(subscriptionKey));
22
+ captured.speechConfig = speechConfig;
23
+ return speechConfig;
24
+ });
25
+ testContext.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "speakSsmlAsync", (ssml, callback) => {
26
+ captured.ssml = ssml;
27
+ callback?.({
28
+ audioData: audio,
29
+ errorDetails: "",
30
+ reason: SpeechSDK.ResultReason.SynthesizingAudioCompleted,
31
+ } as SpeechSDK.SpeechSynthesisResult);
32
+ });
33
+ testContext.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => {
34
+ captured.closeCount += 1;
35
+ });
36
+
37
+ return captured;
38
+ }
39
+
40
+ function installSpeechSdkErrorMock(testContext: TestContext, errorDetails: string): SpeechSdkMock {
41
+ const captured = installSpeechSdkMock(testContext, new ArrayBuffer(0));
42
+ testContext.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "speakSsmlAsync", (ssml, _callback, errorCallback) => {
43
+ captured.ssml = ssml;
44
+ errorCallback?.(errorDetails);
45
+ });
46
+ return captured;
47
+ }
48
+
49
+ test("synthesizeSpeech sends SSML using the Speech SDK", async (t) => {
50
+ const mockAudio = new ArrayBuffer(3);
51
+ new Uint8Array(mockAudio).set([4, 5, 6]);
52
+ const speechSdkMock = installSpeechSdkMock(t, mockAudio);
53
+ const ssml = "<speak>Hello</speak>";
54
+
55
+ const audio = await synthesizeSpeech(ssml, {
56
+ endpoint: "https://speech.example.test/cognitiveservices/v1",
57
+ subscriptionKey: "subscription-key",
58
+ region: "japaneast",
59
+ });
60
+
61
+ assert.equal(speechSdkMock.endpoint?.href, "https://speech.example.test/cognitiveservices/v1");
62
+ assert.equal(speechSdkMock.subscriptionKey, "subscription-key");
63
+ assert.equal(speechSdkMock.ssml, ssml);
64
+ assert.equal(
65
+ speechSdkMock.speechConfig?.speechSynthesisOutputFormat,
66
+ SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz128KBitRateMonoMp3,
67
+ );
68
+ assert.strictEqual(audio, mockAudio);
69
+ assert.equal(speechSdkMock.closeCount, 1);
70
+ });
71
+
72
+ test("synthesize uses the configured output format", async (t) => {
73
+ const mockAudio = new ArrayBuffer(3);
74
+ const speechSdkMock = installSpeechSdkMock(t, mockAudio);
75
+ const ssml = "<speak>Hello</speak>";
76
+
77
+ const audio = await new AzureTtsClient({
78
+ subscriptionKey: "subscription-key",
79
+ region: "japaneast",
80
+ outputFormat: "audio-24khz-160kbitrate-mono-mp3",
81
+ }).synthesize(ssml);
82
+
83
+ assert.equal(
84
+ speechSdkMock.speechConfig?.speechSynthesisOutputFormat,
85
+ SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3,
86
+ );
87
+ assert.strictEqual(audio, mockAudio);
88
+ });
89
+
90
+ test("synthesize sends SSML to the regional Azure endpoint", async (t) => {
91
+ const mockAudio = new ArrayBuffer(3);
92
+ const speechSdkMock = installSpeechSdkMock(t, mockAudio);
93
+ const ssml = "<speak>Hello</speak>";
94
+
95
+ const audio = await new AzureTtsClient({
96
+ subscriptionKey: "subscription-key",
97
+ region: "japaneast",
98
+ }).synthesize(ssml);
99
+
100
+ assert.equal(speechSdkMock.endpoint?.href, "https://japaneast.tts.speech.microsoft.com/cognitiveservices/v1");
101
+ assert.equal(speechSdkMock.ssml, ssml);
102
+ assert.strictEqual(audio, mockAudio);
103
+ });
104
+
105
+ test("synthesize falls back when endpoint is an empty string", async (t) => {
106
+ const mockAudio = new ArrayBuffer(1);
107
+ const speechSdkMock = installSpeechSdkMock(t, mockAudio);
108
+
109
+ await new AzureTtsClient({
110
+ endpoint: " ",
111
+ subscriptionKey: "subscription-key",
112
+ region: "japaneast",
113
+ }).synthesize("<speak>Hello</speak>");
114
+
115
+ assert.equal(speechSdkMock.endpoint?.href, "https://japaneast.tts.speech.microsoft.com/cognitiveservices/v1");
116
+ });
117
+
118
+ test("synthesize reports Speech SDK synthesis errors", async (t) => {
119
+ const errorDetails = "The SSML is invalid.";
120
+ installSpeechSdkErrorMock(t, errorDetails);
121
+
122
+ await assert.rejects(
123
+ new AzureTtsClient({
124
+ subscriptionKey: "subscription-key",
125
+ region: "japaneast",
126
+ }).synthesize("<speak>Hello</speak>"),
127
+ (error: unknown) => {
128
+ assert.ok(error instanceof AzureTtsError);
129
+ assert.ok(error instanceof AzureTtsSdkError);
130
+ assert.equal(error.message, "Azure TTS synthesis failed: The SSML is invalid.");
131
+ assert.equal(error.status, 0);
132
+ assert.equal(error.statusText, "Speech SDK");
133
+ assert.equal(error.responseBody, errorDetails);
134
+ assert.equal(error.requestId, null);
135
+ assert.equal(error.errorDetails, errorDetails);
136
+ return true;
137
+ },
138
+ );
139
+ });
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
4
+ import { DEFAULT_OUTPUT_FORMAT, resolveOutputFormat } from "../src/outputFormats.ts";
5
+
6
+ test("defines the default Azure Speech output format", () => {
7
+ assert.equal(DEFAULT_OUTPUT_FORMAT, "audio-16khz-128kbitrate-mono-mp3");
8
+ assert.equal(
9
+ resolveOutputFormat(DEFAULT_OUTPUT_FORMAT),
10
+ SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz128KBitRateMonoMp3,
11
+ );
12
+ });
13
+
14
+ test("resolves supported output formats", () => {
15
+ assert.equal(
16
+ resolveOutputFormat("audio-16khz-32kbitrate-mono-mp3"),
17
+ SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3,
18
+ );
19
+ assert.equal(
20
+ resolveOutputFormat("audio-24khz-48kbitrate-mono-mp3"),
21
+ SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3,
22
+ );
23
+ assert.equal(
24
+ resolveOutputFormat("audio-24khz-160kbitrate-mono-mp3"),
25
+ SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3,
26
+ );
27
+ assert.equal(
28
+ resolveOutputFormat("ogg-48khz-16bit-mono-opus"),
29
+ SpeechSDK.SpeechSynthesisOutputFormat.Ogg48Khz16BitMonoOpus,
30
+ );
31
+ });
32
+
33
+ test("rejects unsupported output formats", () => {
34
+ assert.throws(
35
+ () => resolveOutputFormat("unsupported-format"),
36
+ new Error("Unsupported Azure Speech output format: unsupported-format"),
37
+ );
38
+ });