@creativeorange/azure-text-to-speech 2.2.3 → 3.0.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/README.md CHANGED
@@ -0,0 +1,455 @@
1
+ # Azure Text to Speech / Speech to Text
2
+
3
+ Browser package for Azure Cognitive Services Speech with **temporary authorization tokens**. Permanent Azure subscription keys never enter frontend JavaScript.
4
+
5
+ Package: `@creativeorange/azure-text-to-speech`
6
+ Current major version: **3.0.0**
7
+
8
+ ## Why version 3 exists
9
+
10
+ In version 2, constructors accepted a permanent Azure subscription key and used `fromSubscription`. That forced the real API key into browser bundles, DevTools, and memory.
11
+
12
+ Version 3 removes that pattern completely. The permanent key stays on your server. The browser only receives short-lived Azure authorization tokens via your own backend.
13
+
14
+ ## Architecture
15
+
16
+ ```text
17
+ Browser
18
+ → your backend token endpoint
19
+ → Azure issueToken endpoint
20
+ ← temporary Azure token + region
21
+ → Azure Speech SDK (fromAuthorizationToken)
22
+ ```
23
+
24
+ Supported authentication options:
25
+
26
+ 1. `tokenEndpoint` — simple GET endpoint (Craft, classic sites)
27
+ 2. `getAuthorizationToken` — custom async provider
28
+
29
+ There is **no** option to pass a permanent subscription key.
30
+
31
+ The Azure Speech SDK is **bundled** into the published `dist` files, so consumers do not need to install `microsoft-cognitiveservices-speech-sdk` separately. That dependency remains a `devDependency` of this package.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm install @creativeorange/azure-text-to-speech
37
+ ```
38
+
39
+ TypeScript declarations are published at `dist/main.d.ts`.
40
+
41
+ ```ts
42
+ import {
43
+ TextToSpeech,
44
+ SpeechToText,
45
+ type TextToSpeechOptions,
46
+ type SpeechToTextOptions,
47
+ type SpeechAuthorization,
48
+ } from '@creativeorange/azure-text-to-speech';
49
+ ```
50
+
51
+ Always import from the package root. Do not import from `/dist/...` paths.
52
+
53
+ ## Public API
54
+
55
+ ### Text to Speech with `tokenEndpoint`
56
+
57
+ ```ts
58
+ const textToSpeech = new TextToSpeech({
59
+ tokenEndpoint: '/actions/azure-speech/token',
60
+ voice: 'nl-NL-FennaNeural',
61
+ });
62
+
63
+ await textToSpeech.start();
64
+ ```
65
+
66
+ The Speech region comes from the token endpoint response, not from frontend configuration. That prevents mismatches between frontend, backend and Azure resource region.
67
+
68
+ ### Text to Speech with a custom provider
69
+
70
+ ```ts
71
+ const textToSpeech = new TextToSpeech({
72
+ voice: 'nl-NL-FennaNeural',
73
+ getAuthorizationToken: async () => {
74
+ const response = await fetch('/api/azure-speech/token', {
75
+ credentials: 'same-origin',
76
+ cache: 'no-store',
77
+ headers: {
78
+ Accept: 'application/json',
79
+ },
80
+ });
81
+
82
+ if (!response.ok) {
83
+ throw new Error('Could not obtain Azure Speech token');
84
+ }
85
+
86
+ return response.json();
87
+ },
88
+ });
89
+
90
+ await textToSpeech.start();
91
+ ```
92
+
93
+ ### Speech to Text
94
+
95
+ ```ts
96
+ const speechToText = new SpeechToText({
97
+ tokenEndpoint: '/actions/azure-speech/token',
98
+ sourceLanguage: 'nl-NL',
99
+ targetLanguage: 'nl',
100
+ });
101
+
102
+ await speechToText.start();
103
+ ```
104
+
105
+ Continuous recognition can outlive a single Azure token. After recognition starts successfully, Speech-to-Text refreshes the active recognizer token about every eight minutes (`tokenLifetimeMs`) by setting `recognizer.authorizationToken`.
106
+
107
+ ### Token endpoint response
108
+
109
+ ```json
110
+ {
111
+ "token": "temporary-azure-token",
112
+ "region": "westeurope"
113
+ }
114
+ ```
115
+
116
+ ### Options
117
+
118
+ ```ts
119
+ type SpeechAuthorization = {
120
+ token: string;
121
+ region: string;
122
+ };
123
+
124
+ type SpeechAuthenticationOptions = {
125
+ tokenEndpoint?: string;
126
+ getAuthorizationToken?: () => Promise<SpeechAuthorization>;
127
+ tokenRequestOptions?: {
128
+ headers?: Record<string, string>;
129
+ credentials?: RequestCredentials;
130
+ cache?: RequestCache; // default: 'no-store'
131
+ };
132
+ tokenLifetimeMs?: number; // default: 8 minutes
133
+ };
134
+
135
+ type TextToSpeechOptions = SpeechAuthenticationOptions & {
136
+ voice: string;
137
+ rate?: number;
138
+ pitch?: number;
139
+ url?: string; // lexicon URL
140
+ };
141
+
142
+ type SpeechToTextOptions = SpeechAuthenticationOptions & {
143
+ sourceLanguage: string;
144
+ targetLanguage?: string;
145
+ };
146
+ ```
147
+
148
+ Provide **exactly one** of `tokenEndpoint` or `getAuthorizationToken`.
149
+
150
+ ### Token caching and transport
151
+
152
+ - Tokens are cached in memory and reused while valid.
153
+ - Tokens refresh after about **8 minutes** by default.
154
+ - Parallel callers share one in-flight request.
155
+ - `tokenEndpoint` requests use `credentials: 'same-origin'` and `cache: 'no-store'` by default.
156
+ - Tokens are never logged and never included in error events.
157
+
158
+ ### Browser events
159
+
160
+ TTS:
161
+
162
+ - `COAzureTTSStartedPlaying`
163
+ - `COAzureTTSFinishedPlaying`
164
+ - `COAzureTTSStoppedPlaying`
165
+ - `COAzureTTSPausedPlaying`
166
+ - `COAzureTTSResumedPlaying`
167
+ - `COAzureTTSError` → `{ detail: { error: { message, code? } } }`
168
+
169
+ STT:
170
+
171
+ - `COAzureSTTStartedRecording`
172
+ - `COAzureSTTStoppedRecording`
173
+ - `COAzureSTTError` → `{ detail: { error: { message, code? } } }`
174
+
175
+ ## Migrating from v2 to v3
176
+
177
+ ### Old unsafe usage
178
+
179
+ ```ts
180
+ const textToSpeech = new TextToSpeech(
181
+ window.azureSpeechKey,
182
+ 'westeurope',
183
+ 'nl-NL-FennaNeural',
184
+ 0,
185
+ 0
186
+ );
187
+ ```
188
+
189
+ Remove Twig/Vite/window exports of the subscription key.
190
+
191
+ ### New safe usage
192
+
193
+ ```ts
194
+ const textToSpeech = new TextToSpeech({
195
+ tokenEndpoint: '/actions/azure-speech/token',
196
+ voice: 'nl-NL-FennaNeural',
197
+ rate: 0,
198
+ pitch: 0,
199
+ });
200
+ ```
201
+
202
+ ### Migration checklist
203
+
204
+ 1. Update the package to `3.x`.
205
+ 2. Store `AZURE_SPEECH_KEY` and `AZURE_SPEECH_REGION` server-side only.
206
+ 3. Add a backend token endpoint.
207
+ 4. Replace the old constructor with the options object.
208
+ 5. Remove every frontend variable that contained the subscription key.
209
+ 6. Rebuild the frontend.
210
+ 7. Confirm in DevTools that the permanent key no longer appears.
211
+ 8. Test TTS, STT, highlighting, pause/resume, chained playback and prefetching.
212
+ 9. **Rotate the previously exposed Azure subscription key.**
213
+
214
+ ## Craft CMS integration
215
+
216
+ ### `.env`
217
+
218
+ ```dotenv
219
+ AZURE_SPEECH_KEY="replace-with-secret-key"
220
+ AZURE_SPEECH_REGION="westeurope"
221
+ ```
222
+
223
+ Never export these values to Twig globals, Vite `define`, or frontend env files.
224
+
225
+ ### Module registration (`config/app.php`)
226
+
227
+ ```php
228
+ <?php
229
+
230
+ return [
231
+ 'modules' => [
232
+ 'azure-speech' => [
233
+ 'class' => \modules\azurespeech\Module::class,
234
+ ],
235
+ ],
236
+ 'bootstrap' => [
237
+ 'azure-speech',
238
+ ],
239
+ ];
240
+ ```
241
+
242
+ Craft action routing uses:
243
+
244
+ - module ID: `azure-speech`
245
+ - controller ID: `token` (`TokenController`)
246
+ - action: `actionIndex`
247
+
248
+ Resulting URL:
249
+
250
+ ```text
251
+ /actions/azure-speech/token
252
+ ```
253
+
254
+ Ensure the module registers its controller namespace, for example in `modules/azurespeech/Module.php`:
255
+
256
+ ```php
257
+ public function init(): void
258
+ {
259
+ parent::init();
260
+ // Controllers live in modules\azurespeech\controllers
261
+ }
262
+ ```
263
+
264
+ ### Token controller for Craft 4 / Craft 5
265
+
266
+ ```php
267
+ <?php
268
+
269
+ namespace modules\azurespeech\controllers;
270
+
271
+ use Craft;
272
+ use craft\web\Controller;
273
+ use yii\web\Response;
274
+
275
+ class TokenController extends Controller
276
+ {
277
+ // Craft 4 and Craft 5
278
+ protected array|bool|int $allowAnonymous = true;
279
+
280
+ public function actionIndex(): Response
281
+ {
282
+ $key = Craft::parseEnv('$AZURE_SPEECH_KEY');
283
+ $region = Craft::parseEnv('$AZURE_SPEECH_REGION');
284
+
285
+ if (!$key || !$region) {
286
+ return $this->asFailure(
287
+ 'Azure Speech configuration is missing.'
288
+ );
289
+ }
290
+
291
+ $client = Craft::createGuzzleClient();
292
+
293
+ try {
294
+ $azureResponse = $client->post(
295
+ "https://{$region}.api.cognitive.microsoft.com/sts/v1.0/issueToken",
296
+ [
297
+ 'headers' => [
298
+ 'Ocp-Apim-Subscription-Key' => $key,
299
+ ],
300
+ 'timeout' => 10,
301
+ ]
302
+ );
303
+ } catch (\Throwable $exception) {
304
+ Craft::error(
305
+ 'Could not retrieve an Azure Speech token.',
306
+ __METHOD__
307
+ );
308
+
309
+ return $this->asFailure(
310
+ 'Speech authorization is temporarily unavailable.'
311
+ );
312
+ }
313
+
314
+ $craftResponse = $this->asJson([
315
+ 'token' => (string) $azureResponse->getBody(),
316
+ 'region' => $region,
317
+ ]);
318
+
319
+ $craftResponse->getHeaders()->set(
320
+ 'Cache-Control',
321
+ 'private, no-store, max-age=0'
322
+ );
323
+ $craftResponse->getHeaders()->set(
324
+ 'Pragma',
325
+ 'no-cache'
326
+ );
327
+
328
+ return $craftResponse;
329
+ }
330
+ }
331
+ ```
332
+
333
+ ### Token controller property for Craft 3 / older PHP
334
+
335
+ ```php
336
+ // Craft 3 or older PHP versions without union property types
337
+ protected $allowAnonymous = true;
338
+ ```
339
+
340
+ Use the property form that matches your Craft and PHP version. Do not log tokens or exception details to the browser.
341
+
342
+ ### Frontend initialization
343
+
344
+ ```ts
345
+ import {TextToSpeech, SpeechToText} from '@creativeorange/azure-text-to-speech';
346
+
347
+ const textToSpeech = new TextToSpeech({
348
+ tokenEndpoint: '/actions/azure-speech/token',
349
+ voice: 'nl-NL-FennaNeural',
350
+ });
351
+
352
+ textToSpeech.start().catch((error) => {
353
+ console.error('Could not initialize text to speech.', error);
354
+ });
355
+
356
+ const speechToText = new SpeechToText({
357
+ tokenEndpoint: '/actions/azure-speech/token',
358
+ sourceLanguage: 'nl-NL',
359
+ targetLanguage: 'nl',
360
+ });
361
+
362
+ speechToText.start().catch((error) => {
363
+ console.error('Could not initialize speech to text.', error);
364
+ });
365
+ ```
366
+
367
+ ### Protecting the Craft token endpoint
368
+
369
+ A temporary token may appear in the browser. That does **not** mean the endpoint can be public and unlimited.
370
+
371
+ Recommended controls:
372
+
373
+ - Craft rate limiting and/or webserver/proxy rate limiting
374
+ - Cloudflare rate limiting when available
375
+ - Authentication when TTS/STT is not meant for anonymous visitors
376
+ - HTTPS only
377
+ - Short server-side timeouts
378
+ - Azure budget and usage alerts
379
+ - Azure resource networking restrictions when infrastructure allows it
380
+ - Never put the permanent key in responses or logs
381
+ - Return `Cache-Control: private, no-store, max-age=0`
382
+
383
+ **CORS alone is not security.** Endpoints can be called outside a browser.
384
+
385
+ ## Laravel token endpoint
386
+
387
+ ```php
388
+ use Illuminate\Support\Facades\Http;
389
+ use Illuminate\Support\Facades\Route;
390
+
391
+ Route::get('/api/azure-speech/token', function () {
392
+ $region = config('services.azure_speech.region');
393
+
394
+ $azureResponse = Http::timeout(10)
395
+ ->withHeaders([
396
+ 'Ocp-Apim-Subscription-Key' =>
397
+ config('services.azure_speech.key'),
398
+ ])
399
+ ->post(
400
+ "https://{$region}.api.cognitive.microsoft.com/sts/v1.0/issueToken"
401
+ );
402
+
403
+ abort_unless($azureResponse->successful(), 502);
404
+
405
+ return response()
406
+ ->json([
407
+ 'token' => $azureResponse->body(),
408
+ 'region' => $region,
409
+ ])
410
+ ->header(
411
+ 'Cache-Control',
412
+ 'private, no-store, max-age=0'
413
+ )
414
+ ->header('Pragma', 'no-cache');
415
+ })->middleware('throttle:20,1');
416
+ ```
417
+
418
+ Put this route behind authentication whenever the feature is not public.
419
+
420
+ ## Existing browser features
421
+
422
+ Version 3 keeps the existing client behaviour from the development branch:
423
+
424
+ - play / pause / resume / stop
425
+ - word-boundary highlighting
426
+ - chained playback (`co-tts.next`)
427
+ - audio prefetching
428
+ - lexicons via `url`
429
+ - voice / rate / pitch setters
430
+ - existing custom browser events
431
+
432
+ ## Development
433
+
434
+ ```bash
435
+ npm ci
436
+ npm run lint
437
+ npm test
438
+ npm run build
439
+ npm run typecheck
440
+ ```
441
+
442
+ Security sanity checks:
443
+
444
+ ```bash
445
+ grep -R "fromSubscription" src
446
+ grep -R "subscriptionKey" src
447
+ grep -R "Ocp-Apim-Subscription-Key" src
448
+ grep -R "@creativeorange/azure-text-to-speech/dist" .
449
+ ```
450
+
451
+ These should return no matches in package source or active examples.
452
+
453
+ ## License
454
+
455
+ MIT
@@ -0,0 +1,27 @@
1
+ import { TranslationRecognizer } from 'microsoft-cognitiveservices-speech-sdk';
2
+ import { SpeechAuthenticationOptions } from './authentication';
3
+ export type SpeechToTextOptions = SpeechAuthenticationOptions & {
4
+ sourceLanguage: string;
5
+ targetLanguage?: string;
6
+ };
7
+ export declare class SpeechToText {
8
+ region: string;
9
+ sourceLanguage: string;
10
+ targetLanguage: string;
11
+ recognizer: TranslationRecognizer | undefined;
12
+ private readonly authorizationManager;
13
+ private readonly authorizationRefreshIntervalMs;
14
+ private authorizationRefreshTimer?;
15
+ private stopPromise?;
16
+ constructor(options: SpeechToTextOptions);
17
+ start(): Promise<void>;
18
+ registerBindings(node: any): Promise<void>;
19
+ private createSpeechTranslationConfig;
20
+ handleStartModifier(node: any, attr: Attr): Promise<void>;
21
+ handleStopModifier(node: any, _attr: Attr): Promise<void>;
22
+ stop(): Promise<void>;
23
+ private scheduleAuthorizationRefresh;
24
+ private clearAuthorizationRefreshTimer;
25
+ private stopRecognizer;
26
+ private dispatchError;
27
+ }
@@ -0,0 +1,79 @@
1
+ import { SpeechAuthenticationOptions } from './authentication';
2
+ export type TextToSpeechOptions = SpeechAuthenticationOptions & {
3
+ voice: string;
4
+ rate?: number;
5
+ pitch?: number;
6
+ url?: string;
7
+ };
8
+ export declare class TextToSpeech {
9
+ region: string;
10
+ voice: string;
11
+ rate: number;
12
+ pitch: number;
13
+ textToRead: string;
14
+ wordBoundryList: any[];
15
+ clickedNode: any;
16
+ highlightDiv: any;
17
+ speechConfig: any;
18
+ audioConfig: any;
19
+ player: any;
20
+ synthesizer: any;
21
+ previousWordBoundary: any;
22
+ interval: any;
23
+ wordEncounters: number[];
24
+ originalHighlightDivInnerHTML: string;
25
+ currentWord: string;
26
+ currentOffset: number;
27
+ wordBoundaryOffset: number;
28
+ playbackTextOffsetBase: number | undefined;
29
+ prevTextOffset: number;
30
+ url: string;
31
+ prefetchedAudio: Map<string, any>;
32
+ prefetchPromises: Map<string, Promise<any>>;
33
+ activePrefetchedAudioUrl: string;
34
+ playbackSegments: any[];
35
+ private readonly authorizationManager;
36
+ private stopPromise?;
37
+ constructor(options: TextToSpeechOptions);
38
+ start(): Promise<void>;
39
+ setVoice(voice: string): this;
40
+ setRate(rate: number): this;
41
+ setPitch(pitch: number): this;
42
+ registerBindings(node: any): Promise<void>;
43
+ handleIdModifier(node: any, attr: Attr): Promise<void>;
44
+ handleAjaxModifier(node: any, attr: Attr): Promise<void>;
45
+ handleDefault(node: any, attr: Attr): Promise<void>;
46
+ handleWithoutClick(node: any, attr: Attr): Promise<void>;
47
+ handleStopModifier(node: any, _attr: Attr): Promise<void>;
48
+ handlePauseModifier(node: any, _attr: Attr): Promise<void>;
49
+ handleResumeModifier(node: any, _attr: Attr): Promise<void>;
50
+ stopPlayer(): Promise<void>;
51
+ private performStopPlayer;
52
+ private createSpeechConfig;
53
+ startSynthesizer(_node: any, _attr: Attr): Promise<void>;
54
+ collectPlaybackChain(node: any): {
55
+ node: any;
56
+ text: any;
57
+ start: number;
58
+ end: any;
59
+ highlightDiv: any;
60
+ originalHighlightDivInnerHTML: any;
61
+ }[];
62
+ preparePlaybackChain(chain: any[]): void;
63
+ getHighlightDivForNode(node: any): any;
64
+ resetPlaybackSegments(): void;
65
+ updateChainedHighlight(wordBoundary: any): void;
66
+ getNodeText(node: any, attr?: Attr | null): any;
67
+ getPrefetchKey(node: any, text: string): string;
68
+ clearPrefetchedAudio(): void;
69
+ prefetchNextNode(node: any): Promise<void>;
70
+ playPrefetchedNode(node: any): Promise<boolean>;
71
+ clearInterval(): Promise<void>;
72
+ createInterval(): Promise<void>;
73
+ getPosition(string: string, subString: string, textOffset: number): number;
74
+ buildSSML(text: string): string;
75
+ convertHtmlEntities(input: string): string;
76
+ private closeSynthesizer;
77
+ private closeResource;
78
+ private dispatchError;
79
+ }
@@ -0,0 +1,44 @@
1
+ export type SpeechAuthorization = {
2
+ token: string;
3
+ region: string;
4
+ };
5
+ export type SpeechAuthorizationProvider = () => Promise<SpeechAuthorization>;
6
+ export type SpeechTokenRequestOptions = {
7
+ headers?: Record<string, string>;
8
+ credentials?: RequestCredentials;
9
+ cache?: RequestCache;
10
+ };
11
+ export type SpeechAuthenticationOptions = {
12
+ tokenEndpoint?: string;
13
+ getAuthorizationToken?: SpeechAuthorizationProvider;
14
+ tokenRequestOptions?: SpeechTokenRequestOptions;
15
+ tokenLifetimeMs?: number;
16
+ };
17
+ export type SpeechSafeError = {
18
+ message: string;
19
+ code?: string;
20
+ };
21
+ export declare const DEFAULT_TOKEN_LIFETIME_MS: number;
22
+ export declare function createSpeechAuthorizationProvider(options: SpeechAuthenticationOptions): SpeechAuthorizationProvider;
23
+ export declare function validateSpeechAuthorization(value: unknown): SpeechAuthorization;
24
+ export declare function createSafeError(message: string, code?: string): Error & SpeechSafeError;
25
+ export declare function toSafeErrorDetail(error: unknown): SpeechSafeError;
26
+ export declare function sanitizeErrorText(message: string): string;
27
+ export declare function isLikelyAuthorizationError(error: unknown): boolean;
28
+ /**
29
+ * Fetches, validates, caches and refreshes temporary Azure Speech tokens.
30
+ * Never logs authorization tokens.
31
+ */
32
+ export declare class SpeechAuthorizationManager {
33
+ private authorization?;
34
+ private authorizationExpiresAt;
35
+ private authorizationRequest?;
36
+ private readonly provider;
37
+ private readonly tokenLifetimeMs;
38
+ constructor(options: SpeechAuthenticationOptions);
39
+ getTokenLifetimeMs(): number;
40
+ getAuthorization(): Promise<SpeechAuthorization>;
41
+ refreshAuthorization(): Promise<SpeechAuthorization>;
42
+ clearAuthorization(): void;
43
+ private fetchAuthorization;
44
+ }