@quatrain/speech-api 0.0.0-stage → 0.1.1

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/LICENSE.md ADDED
@@ -0,0 +1,15 @@
1
+ # LICENSE UPDATE NOTICE
2
+
3
+ As of 01/01/2026, Quatrain Core is licensed under the **GNU Affero General Public License v3.0 (AGPL v3)**.
4
+ Previous versions remain under the MIT License.
5
+
6
+ ## Why AGPL?
7
+
8
+ We believe in open collaboration for the development ecosystem. The AGPL ensures that any modification or deployment of this BaaS stack, including over a network, benefits the entire community.
9
+
10
+ ## Commercial Services & Enterprise Usage
11
+
12
+ We provide official deployment services, technical training, and certification for Quatrain Core.
13
+ For organizations requiring a non-copyleft license (commercial license) or custom proprietary integrations, please contact the copyright holder: **Quatrain Technologies**.
14
+
15
+ Copyright © 2024-2026 Quatrain Technologies. All Rights Reserved.
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # @quatrain/speech-api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Remote speech adapter for [`@quatrain/speech`](../speech): plays the audio returned by any speech API endpoint, so the server can use the speech model of its choice.
4
+
5
+ - `prepare()` starts the request immediately: with `SpeechController`, the next sentence downloads while the current one plays.
6
+ - Endpoint contract: `POST` JSON (default body `{ text, locale, voice, style }`, customizable with `buildBody`), answering either audio bytes (`Content-Type: audio/*`) or JSON `{ audioBase64, mimeType? }`.
7
+ - `rate` is applied at playback; `cancel()` aborts the download and stops playback.
8
+
9
+ ```typescript
10
+ import { ApiSpeechSynthesizer } from '@quatrain/speech-api'
11
+
12
+ const cloud = new ApiSpeechSynthesizer({ endpoint: '/api/speak', headers: { Authorization: `Bearer ${token}` } })
13
+ ```
@@ -0,0 +1,36 @@
1
+ import type { PreparedUtteranceInterface, SpeechOptionsInterface, SpeechSynthesizerInterface } from '@quatrain/speech';
2
+ export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
3
+ export interface AudioLike {
4
+ playbackRate: number;
5
+ onplaying: ((event: Event) => void) | null;
6
+ onended: ((event: Event) => void) | null;
7
+ onerror: ((event: Event | string) => void) | null;
8
+ play(): Promise<void>;
9
+ pause(): void;
10
+ }
11
+ export interface AudioSourceInterface {
12
+ url: string;
13
+ release(): void;
14
+ }
15
+ export interface ApiSpeechSynthesizerOptionsInterface {
16
+ endpoint: string;
17
+ buildBody?: (text: string, options: SpeechOptionsInterface) => Record<string, unknown>;
18
+ headers?: Record<string, string>;
19
+ fetchImpl?: FetchLike;
20
+ createAudio?: (url: string) => AudioLike;
21
+ createObjectUrl?: (blob: Blob) => AudioSourceInterface;
22
+ isOnline?: () => boolean;
23
+ }
24
+ export declare function isAudioPayload(value: unknown): value is {
25
+ audioBase64: string;
26
+ mimeType?: string;
27
+ };
28
+ export declare class ApiSpeechSynthesizer implements SpeechSynthesizerInterface {
29
+ private readonly settings;
30
+ readonly id = "api";
31
+ constructor(settings: ApiSpeechSynthesizerOptionsInterface);
32
+ isAvailable(): Promise<boolean>;
33
+ private fetchAudio;
34
+ prepare(text: string, options: SpeechOptionsInterface): PreparedUtteranceInterface;
35
+ }
36
+ //# sourceMappingURL=ApiSpeechSynthesizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ApiSpeechSynthesizer.d.ts","sourceRoot":"","sources":["../src/ApiSpeechSynthesizer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAmB,sBAAsB,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAA;AAGvI,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AAG7E,MAAM,WAAW,SAAS;IACvB,YAAY,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IAC1C,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IACxC,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IACjD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,KAAK,IAAI,IAAI,CAAA;CACf;AAGD,MAAM,WAAW,oBAAoB;IAElC,GAAG,EAAE,MAAM,CAAA;IAEX,OAAO,IAAI,IAAI,CAAA;CACjB;AAGD,MAAM,WAAW,oCAAoC;IAElD,QAAQ,EAAE,MAAM,CAAA;IAEhB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,sBAAsB,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAEtF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEhC,SAAS,CAAC,EAAE,SAAS,CAAA;IAErB,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,SAAS,CAAA;IAExC,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,oBAAoB,CAAA;IAEtD,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAA;CAC1B;AAQD,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CASlG;AAiBD,qBAAa,oBAAqB,YAAW,0BAA0B;IAMxD,OAAO,CAAC,QAAQ,CAAC,QAAQ;IALrC,QAAQ,CAAC,EAAE,SAAQ;gBAKU,QAAQ,EAAE,oCAAoC;IAKrE,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;YAevB,UAAU;IA8BxB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,sBAAsB,GAAG,0BAA0B;CA0CpF"}
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApiSpeechSynthesizer = void 0;
4
+ exports.isAudioPayload = isAudioPayload;
5
+ function isAudioPayload(value) {
6
+ return (typeof value === 'object' &&
7
+ value !== null &&
8
+ 'audioBase64' in value &&
9
+ typeof value.audioBase64 === 'string' &&
10
+ value.audioBase64.length > 0 &&
11
+ (!('mimeType' in value) || value.mimeType === undefined || typeof value.mimeType === 'string'));
12
+ }
13
+ function defaultObjectUrl(blob) {
14
+ const url = URL.createObjectURL(blob);
15
+ return { url, release: () => URL.revokeObjectURL(url) };
16
+ }
17
+ class ApiSpeechSynthesizer {
18
+ settings;
19
+ id = 'api';
20
+ constructor(settings) {
21
+ this.settings = settings;
22
+ }
23
+ async isAvailable() {
24
+ if (this.settings.isOnline) {
25
+ return this.settings.isOnline();
26
+ }
27
+ return typeof navigator === 'undefined' || navigator.onLine !== false;
28
+ }
29
+ async fetchAudio(text, options, signal) {
30
+ const fetchImpl = this.settings.fetchImpl ?? ((url, init) => fetch(url, init));
31
+ const body = this.settings.buildBody
32
+ ? this.settings.buildBody(text, options)
33
+ : { text, locale: options.locale, voice: options.voice, style: options.style };
34
+ const response = await fetchImpl(this.settings.endpoint, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json', ...this.settings.headers },
37
+ body: JSON.stringify(body),
38
+ signal,
39
+ });
40
+ if (!response.ok) {
41
+ return null;
42
+ }
43
+ const contentType = response.headers.get('Content-Type') ?? '';
44
+ if (contentType.startsWith('audio/')) {
45
+ return (this.settings.createObjectUrl ?? defaultObjectUrl)(await response.blob());
46
+ }
47
+ const payload = await response.json();
48
+ if (!isAudioPayload(payload)) {
49
+ return null;
50
+ }
51
+ return { url: `data:${payload.mimeType ?? 'audio/wav'};base64,${payload.audioBase64}`, release: () => undefined };
52
+ }
53
+ prepare(text, options) {
54
+ const controller = new AbortController();
55
+ let cancelled = false;
56
+ let audio = null;
57
+ let finish = null;
58
+ const source = this.fetchAudio(text, options, controller.signal).catch(() => null);
59
+ return {
60
+ play: async (onStarted) => {
61
+ const ready = await source;
62
+ if (cancelled) {
63
+ ready?.release();
64
+ return 'interrupted';
65
+ }
66
+ if (!ready) {
67
+ return 'failed';
68
+ }
69
+ const createAudio = this.settings.createAudio ?? ((url) => new Audio(url));
70
+ const element = createAudio(ready.url);
71
+ audio = element;
72
+ element.playbackRate = options.rate ?? 1;
73
+ return new Promise((resolve) => {
74
+ finish = (reason) => {
75
+ finish = null;
76
+ audio = null;
77
+ ready.release();
78
+ resolve(reason);
79
+ };
80
+ element.onplaying = () => onStarted?.();
81
+ element.onended = () => finish?.('completed');
82
+ element.onerror = () => finish?.('failed');
83
+ element.play().catch(() => finish?.(cancelled ? 'interrupted' : 'failed'));
84
+ });
85
+ },
86
+ cancel: () => {
87
+ cancelled = true;
88
+ controller.abort();
89
+ audio?.pause();
90
+ finish?.('interrupted');
91
+ },
92
+ };
93
+ }
94
+ }
95
+ exports.ApiSpeechSynthesizer = ApiSpeechSynthesizer;
96
+ //# sourceMappingURL=ApiSpeechSynthesizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ApiSpeechSynthesizer.js","sourceRoot":"","sources":["../src/ApiSpeechSynthesizer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export { ApiSpeechSynthesizer, isAudioPayload } from './ApiSpeechSynthesizer';
2
+ export type { AudioLike, AudioSourceInterface, FetchLike, ApiSpeechSynthesizerOptionsInterface } from './ApiSpeechSynthesizer';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAC7E,YAAY,EAAE,SAAS,EAAE,oBAAoB,EAAE,SAAS,EAAE,oCAAoC,EAAE,MAAM,wBAAwB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isAudioPayload = exports.ApiSpeechSynthesizer = void 0;
4
+ var ApiSpeechSynthesizer_1 = require("./ApiSpeechSynthesizer");
5
+ Object.defineProperty(exports, "ApiSpeechSynthesizer", { enumerable: true, get: function () { return ApiSpeechSynthesizer_1.ApiSpeechSynthesizer; } });
6
+ Object.defineProperty(exports, "isAudioPayload", { enumerable: true, get: function () { return ApiSpeechSynthesizer_1.isAudioPayload; } });
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,+DAA6E;AAApE,4HAAA,oBAAoB,OAAA;AAAE,sHAAA,cAAc,OAAA"}
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@quatrain/speech-api",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "license": "AGPL-3.0-only",
5
+ "description": "Remote speech adapter for @quatrain/speech: plays audio returned by a speech API endpoint, sentence by sentence",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bun": "src/index.ts",
9
+ "files": [
10
+ "LICENSE.md",
11
+ "src/",
12
+ "dist/",
13
+ "README.md",
14
+ "NOTICE.md"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Quatrain/Core.git",
19
+ "directory": "packages/speech-api"
20
+ },
21
+ "author": "Quatrain Développement SAS <developers@quatrain.com>",
22
+ "dependencies": {
23
+ "@quatrain/speech": "^0.1.1"
24
+ },
25
+ "devDependencies": {
26
+ "@tsconfig/recommended": "^1.0.1",
27
+ "@types/jest": "^29.5.12",
28
+ "@types/node": "^22.10.1",
29
+ "jest": "^29.7.0",
30
+ "ts-jest": "^29.4.6",
31
+ "typescript": "^5.1.5"
32
+ },
33
+ "scripts": {
34
+ "test-ci": "jest --runInBand",
35
+ "build": "tsc",
36
+ "wbuild": "tsc --watch",
37
+ "bump-to": "yarn version"
38
+ },
39
+ "typedoc": {
40
+ "entryPoint": "src/index.ts"
41
+ }
6
42
  }
@@ -0,0 +1,146 @@
1
+ import { ApiSpeechSynthesizer, isAudioPayload, type AudioLike, type FetchLike } from './ApiSpeechSynthesizer'
2
+
3
+ /** Fake audio element, controlled by the test. */
4
+ interface FakeAudioInterface extends AudioLike {
5
+ url: string
6
+ paused: boolean
7
+ }
8
+
9
+ /**
10
+ * Builds a fake audio factory.
11
+ *
12
+ * @param rejectPlay - Make `play()` reject (autoplay refused).
13
+ * @returns The factory and the created elements.
14
+ */
15
+ function fakeAudio(rejectPlay = false): { createAudio: (url: string) => AudioLike; elements: FakeAudioInterface[] } {
16
+ const elements: FakeAudioInterface[] = []
17
+ return {
18
+ elements,
19
+ createAudio: (url) => {
20
+ const element: FakeAudioInterface = {
21
+ url,
22
+ paused: false,
23
+ playbackRate: 1,
24
+ onplaying: null,
25
+ onended: null,
26
+ onerror: null,
27
+ play: () => (rejectPlay ? Promise.reject(new Error('NotAllowedError')) : Promise.resolve()),
28
+ pause: () => {
29
+ element.paused = true
30
+ },
31
+ }
32
+ elements.push(element)
33
+ return element
34
+ },
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Builds a fake fetch answering a JSON payload.
40
+ *
41
+ * @param status - HTTP status.
42
+ * @param body - JSON body.
43
+ * @returns The fake and its recorded calls.
44
+ */
45
+ function jsonFetch(status: number, body: unknown): { fetchImpl: FetchLike; calls: RequestInit[] } {
46
+ const calls: RequestInit[] = []
47
+ return {
48
+ calls,
49
+ fetchImpl: async (_url, init) => {
50
+ calls.push(init)
51
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
52
+ },
53
+ }
54
+ }
55
+
56
+ /** Lets pending promise callbacks run. */
57
+ const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
58
+
59
+ describe('isAudioPayload', () => {
60
+ it('accepts a non-empty audioBase64 with an optional mimeType', () => {
61
+ expect(isAudioPayload({ audioBase64: 'AAAA' })).toBe(true)
62
+ expect(isAudioPayload({ audioBase64: 'AAAA', mimeType: 'audio/mpeg' })).toBe(true)
63
+ expect(isAudioPayload({ audioBase64: '' })).toBe(false)
64
+ expect(isAudioPayload({ audioBase64: 'AAAA', mimeType: 3 })).toBe(false)
65
+ expect(isAudioPayload(null)).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('ApiSpeechSynthesizer', () => {
70
+ it('follows the network status', async () => {
71
+ expect(await new ApiSpeechSynthesizer({ endpoint: '/speak', isOnline: () => false }).isAvailable()).toBe(false)
72
+ expect(await new ApiSpeechSynthesizer({ endpoint: '/speak', isOnline: () => true }).isAvailable()).toBe(true)
73
+ })
74
+
75
+ it('requests the audio at prepare time and plays it with the rate', async () => {
76
+ const { fetchImpl, calls } = jsonFetch(200, { audioBase64: 'AAAA' })
77
+ const { createAudio, elements } = fakeAudio()
78
+ const synthesizer = new ApiSpeechSynthesizer({ endpoint: '/speak', fetchImpl, createAudio })
79
+ const utterance = synthesizer.prepare('Hello there.', { locale: 'en-US', voice: 'v1', style: 'warm', rate: 1.2 })
80
+ expect(calls).toHaveLength(1)
81
+ expect(calls[0].body).toBe(JSON.stringify({ text: 'Hello there.', locale: 'en-US', voice: 'v1', style: 'warm' }))
82
+
83
+ let started = false
84
+ const playing = utterance.play(() => {
85
+ started = true
86
+ })
87
+ await flush()
88
+ expect(elements[0].url).toBe('data:audio/wav;base64,AAAA')
89
+ expect(elements[0].playbackRate).toBe(1.2)
90
+ elements[0].onplaying?.(new Event('playing'))
91
+ elements[0].onended?.(new Event('ended'))
92
+ expect(await playing).toBe('completed')
93
+ expect(started).toBe(true)
94
+ })
95
+
96
+ it('plays binary audio responses through an object URL', async () => {
97
+ const released: string[] = []
98
+ const { createAudio, elements } = fakeAudio()
99
+ const synthesizer = new ApiSpeechSynthesizer({
100
+ endpoint: '/speak',
101
+ fetchImpl: async () => new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { 'Content-Type': 'audio/mpeg' } }),
102
+ createAudio,
103
+ createObjectUrl: () => ({ url: 'blob:1', release: () => released.push('blob:1') }),
104
+ })
105
+ const playing = synthesizer.prepare('Hi.', { locale: 'en-US' }).play()
106
+ await flush()
107
+ elements[0].onended?.(new Event('ended'))
108
+ expect(await playing).toBe('completed')
109
+ expect(elements[0].url).toBe('blob:1')
110
+ expect(released).toEqual(['blob:1'])
111
+ })
112
+
113
+ it('uses a custom body builder', async () => {
114
+ const { fetchImpl, calls } = jsonFetch(200, { audioBase64: 'AAAA' })
115
+ new ApiSpeechSynthesizer({ endpoint: '/speak', fetchImpl, buildBody: (text) => ({ input: text }) }).prepare('Hi.', { locale: 'en-US' })
116
+ expect(calls[0].body).toBe(JSON.stringify({ input: 'Hi.' }))
117
+ })
118
+
119
+ it('fails on HTTP errors, bad payloads and refused playback', async () => {
120
+ const options = { locale: 'en-US' }
121
+ expect(await new ApiSpeechSynthesizer({ endpoint: '/speak', fetchImpl: jsonFetch(502, {}).fetchImpl }).prepare('Hi.', options).play()).toBe('failed')
122
+ expect(await new ApiSpeechSynthesizer({ endpoint: '/speak', fetchImpl: jsonFetch(200, { nope: 1 }).fetchImpl }).prepare('Hi.', options).play()).toBe('failed')
123
+ const refused = new ApiSpeechSynthesizer({ endpoint: '/speak', fetchImpl: jsonFetch(200, { audioBase64: 'AAAA' }).fetchImpl, createAudio: fakeAudio(true).createAudio })
124
+ expect(await refused.prepare('Hi.', options).play()).toBe('failed')
125
+ })
126
+
127
+ it('aborts the download and stops playback on cancel', async () => {
128
+ const signals: (AbortSignal | null | undefined)[] = []
129
+ const { createAudio, elements } = fakeAudio()
130
+ const synthesizer = new ApiSpeechSynthesizer({
131
+ endpoint: '/speak',
132
+ fetchImpl: async (_url, init) => {
133
+ signals.push(init.signal)
134
+ return new Response(JSON.stringify({ audioBase64: 'AAAA' }), { status: 200 })
135
+ },
136
+ createAudio,
137
+ })
138
+ const utterance = synthesizer.prepare('Hi.', { locale: 'en-US' })
139
+ const playing = utterance.play()
140
+ await flush()
141
+ utterance.cancel()
142
+ expect(await playing).toBe('interrupted')
143
+ expect(signals[0]?.aborted).toBe(true)
144
+ expect(elements[0].paused).toBe(true)
145
+ })
146
+ })
@@ -0,0 +1,172 @@
1
+ import type { PreparedUtteranceInterface, SpeechEndReason, SpeechOptionsInterface, SpeechSynthesizerInterface } from '@quatrain/speech'
2
+
3
+ /** Minimal `fetch` signature (injectable in tests). */
4
+ export type FetchLike = (url: string, init: RequestInit) => Promise<Response>
5
+
6
+ /** Subset of `HTMLAudioElement` used by the adapter. */
7
+ export interface AudioLike {
8
+ playbackRate: number
9
+ onplaying: ((event: Event) => void) | null
10
+ onended: ((event: Event) => void) | null
11
+ onerror: ((event: Event | string) => void) | null
12
+ play(): Promise<void>
13
+ pause(): void
14
+ }
15
+
16
+ /** Audio returned by the endpoint, ready to be played. */
17
+ export interface AudioSourceInterface {
18
+ /** `data:` or `blob:` URL. */
19
+ url: string
20
+ /** Releases the URL (blob URLs). */
21
+ release(): void
22
+ }
23
+
24
+ /** Adapter settings. */
25
+ export interface ApiSpeechSynthesizerOptionsInterface {
26
+ /** Endpoint synthesizing one text: `POST` JSON, answers audio bytes (`audio/*`) or `{ audioBase64, mimeType? }`. */
27
+ endpoint: string
28
+ /** Builds the JSON body (defaults to `{ text, locale, voice, style }`). */
29
+ buildBody?: (text: string, options: SpeechOptionsInterface) => Record<string, unknown>
30
+ /** Extra request headers. */
31
+ headers?: Record<string, string>
32
+ /** HTTP implementation (defaults to the global `fetch`). */
33
+ fetchImpl?: FetchLike
34
+ /** Audio element factory (defaults to `new Audio(url)`). */
35
+ createAudio?: (url: string) => AudioLike
36
+ /** Turns audio bytes into a playable URL (defaults to `URL.createObjectURL`). */
37
+ createObjectUrl?: (blob: Blob) => AudioSourceInterface
38
+ /** Network status (defaults to `navigator.onLine`, `true` outside browsers). */
39
+ isOnline?: () => boolean
40
+ }
41
+
42
+ /**
43
+ * Type guard for the JSON audio payload.
44
+ *
45
+ * @param value - Parsed JSON.
46
+ * @returns `true` when the payload carries a non-empty `audioBase64` string.
47
+ */
48
+ export function isAudioPayload(value: unknown): value is { audioBase64: string; mimeType?: string } {
49
+ return (
50
+ typeof value === 'object' &&
51
+ value !== null &&
52
+ 'audioBase64' in value &&
53
+ typeof value.audioBase64 === 'string' &&
54
+ value.audioBase64.length > 0 &&
55
+ (!('mimeType' in value) || value.mimeType === undefined || typeof value.mimeType === 'string')
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Default conversion of audio bytes into a blob URL.
61
+ *
62
+ * @param blob - Audio bytes.
63
+ * @returns The playable URL.
64
+ */
65
+ function defaultObjectUrl(blob: Blob): AudioSourceInterface {
66
+ const url = URL.createObjectURL(blob)
67
+ return { url, release: () => URL.revokeObjectURL(url) }
68
+ }
69
+
70
+ /**
71
+ * Cloud speech through any HTTP endpoint (the endpoint calls the speech model of its choice).
72
+ * `prepare` starts the request immediately, so the next sentence downloads while the current one plays.
73
+ */
74
+ export class ApiSpeechSynthesizer implements SpeechSynthesizerInterface {
75
+ readonly id = 'api'
76
+
77
+ /**
78
+ * @param settings - Endpoint and injectable dependencies.
79
+ */
80
+ constructor(private readonly settings: ApiSpeechSynthesizerOptionsInterface) {}
81
+
82
+ /**
83
+ * @returns `true` when the device is online (the endpoint itself is only checked when speaking).
84
+ */
85
+ async isAvailable(): Promise<boolean> {
86
+ if (this.settings.isOnline) {
87
+ return this.settings.isOnline()
88
+ }
89
+ return typeof navigator === 'undefined' || navigator.onLine !== false
90
+ }
91
+
92
+ /**
93
+ * Downloads the audio of one text.
94
+ *
95
+ * @param text - Text.
96
+ * @param options - Speech options.
97
+ * @param signal - Abort signal.
98
+ * @returns The playable audio, or `null` when the endpoint produced none.
99
+ */
100
+ private async fetchAudio(text: string, options: SpeechOptionsInterface, signal: AbortSignal): Promise<AudioSourceInterface | null> {
101
+ const fetchImpl = this.settings.fetchImpl ?? ((url: string, init: RequestInit) => fetch(url, init))
102
+ const body = this.settings.buildBody
103
+ ? this.settings.buildBody(text, options)
104
+ : { text, locale: options.locale, voice: options.voice, style: options.style }
105
+ const response = await fetchImpl(this.settings.endpoint, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json', ...this.settings.headers },
108
+ body: JSON.stringify(body),
109
+ signal,
110
+ })
111
+ if (!response.ok) {
112
+ return null
113
+ }
114
+ const contentType = response.headers.get('Content-Type') ?? ''
115
+ if (contentType.startsWith('audio/')) {
116
+ return (this.settings.createObjectUrl ?? defaultObjectUrl)(await response.blob())
117
+ }
118
+ const payload: unknown = await response.json()
119
+ if (!isAudioPayload(payload)) {
120
+ return null
121
+ }
122
+ return { url: `data:${payload.mimeType ?? 'audio/wav'};base64,${payload.audioBase64}`, release: () => undefined }
123
+ }
124
+
125
+ /**
126
+ * @param text - Text to speak.
127
+ * @param options - Speech options (`rate` is applied at playback).
128
+ * @returns The prepared utterance; its audio starts downloading now.
129
+ */
130
+ prepare(text: string, options: SpeechOptionsInterface): PreparedUtteranceInterface {
131
+ const controller = new AbortController()
132
+ let cancelled = false
133
+ let audio: AudioLike | null = null
134
+ let finish: ((reason: SpeechEndReason) => void) | null = null
135
+ const source = this.fetchAudio(text, options, controller.signal).catch(() => null)
136
+
137
+ return {
138
+ play: async (onStarted) => {
139
+ const ready = await source
140
+ if (cancelled) {
141
+ ready?.release()
142
+ return 'interrupted'
143
+ }
144
+ if (!ready) {
145
+ return 'failed'
146
+ }
147
+ const createAudio = this.settings.createAudio ?? ((url: string): AudioLike => new Audio(url))
148
+ const element = createAudio(ready.url)
149
+ audio = element
150
+ element.playbackRate = options.rate ?? 1
151
+ return new Promise<SpeechEndReason>((resolve) => {
152
+ finish = (reason) => {
153
+ finish = null
154
+ audio = null
155
+ ready.release()
156
+ resolve(reason)
157
+ }
158
+ element.onplaying = () => onStarted?.()
159
+ element.onended = () => finish?.('completed')
160
+ element.onerror = () => finish?.('failed')
161
+ element.play().catch(() => finish?.(cancelled ? 'interrupted' : 'failed'))
162
+ })
163
+ },
164
+ cancel: () => {
165
+ cancelled = true
166
+ controller.abort()
167
+ audio?.pause()
168
+ finish?.('interrupted')
169
+ },
170
+ }
171
+ }
172
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { ApiSpeechSynthesizer, isAudioPayload } from './ApiSpeechSynthesizer'
2
+ export type { AudioLike, AudioSourceInterface, FetchLike, ApiSpeechSynthesizerOptionsInterface } from './ApiSpeechSynthesizer'