@creativeorange/azure-text-to-speech 2.2.2 → 3.0.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/README.md CHANGED
@@ -0,0 +1,496 @@
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:
11
+
12
+ ```ts
13
+ SpeechConfig.fromSubscription(key, region);
14
+ SpeechTranslationConfig.fromSubscription(key, region);
15
+ ```
16
+
17
+ That forced the real API key into browser bundles, DevTools, and memory. Anyone who can open the page can extract the key and consume your Azure Speech quota.
18
+
19
+ Version 3 removes that pattern completely. The permanent key stays on your server. The browser only receives short-lived Azure authorization tokens.
20
+
21
+ ## Architecture
22
+
23
+ ```text
24
+ Browser
25
+ → your backend token endpoint
26
+ → Azure issueToken endpoint
27
+ ← temporary Azure token
28
+ → Azure Speech SDK (fromAuthorizationToken)
29
+ ```
30
+
31
+ Supported authentication options:
32
+
33
+ 1. `tokenEndpoint` — simple GET endpoint (Craft, classic sites)
34
+ 2. `getAuthorizationToken` — custom async provider (Laravel APIs, auth headers, CSRF, etc.)
35
+
36
+ There is **no** option to pass a permanent subscription key.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ npm install @creativeorange/azure-text-to-speech
42
+ ```
43
+
44
+ ## Public API
45
+
46
+ ### Text to Speech with `tokenEndpoint`
47
+
48
+ ```ts
49
+ import {TextToSpeech} from '@creativeorange/azure-text-to-speech';
50
+
51
+ const textToSpeech = new TextToSpeech({
52
+ tokenEndpoint: '/actions/azure-speech/token',
53
+ voice: 'nl-NL-FennaNeural',
54
+ region: 'westeurope',
55
+ });
56
+
57
+ await textToSpeech.start();
58
+ ```
59
+
60
+ ### Text to Speech with a custom provider
61
+
62
+ ```ts
63
+ import {TextToSpeech} from '@creativeorange/azure-text-to-speech';
64
+
65
+ const textToSpeech = new TextToSpeech({
66
+ voice: 'nl-NL-FennaNeural',
67
+ region: 'westeurope',
68
+ getAuthorizationToken: async () => {
69
+ const response = await fetch('/api/azure-speech/token', {
70
+ credentials: 'same-origin',
71
+ headers: {
72
+ Accept: 'application/json',
73
+ },
74
+ });
75
+
76
+ if (!response.ok) {
77
+ throw new Error('Could not obtain Azure Speech token');
78
+ }
79
+
80
+ return response.json();
81
+ },
82
+ });
83
+
84
+ await textToSpeech.start();
85
+ ```
86
+
87
+ ### Speech to Text
88
+
89
+ ```ts
90
+ import {SpeechToText} from '@creativeorange/azure-text-to-speech';
91
+
92
+ const speechToText = new SpeechToText({
93
+ tokenEndpoint: '/actions/azure-speech/token',
94
+ region: 'westeurope',
95
+ sourceLanguage: 'nl-NL',
96
+ targetLanguage: 'nl',
97
+ });
98
+
99
+ await speechToText.start();
100
+ ```
101
+
102
+ ### Token endpoint response
103
+
104
+ ```json
105
+ {
106
+ "token": "temporary-azure-token",
107
+ "region": "westeurope"
108
+ }
109
+ ```
110
+
111
+ ### Options
112
+
113
+ ```ts
114
+ type SpeechAuthorization = {
115
+ token: string;
116
+ region: string;
117
+ };
118
+
119
+ type SpeechAuthenticationOptions = {
120
+ tokenEndpoint?: string;
121
+ getAuthorizationToken?: () => Promise<SpeechAuthorization>;
122
+ tokenRequestOptions?: {
123
+ headers?: Record<string, string>;
124
+ credentials?: RequestCredentials;
125
+ };
126
+ tokenLifetimeMs?: number; // default: 8 minutes
127
+ };
128
+
129
+ type TextToSpeechOptions = SpeechAuthenticationOptions & {
130
+ region?: string;
131
+ voice: string;
132
+ rate?: number;
133
+ pitch?: number;
134
+ url?: string; // lexicon URL
135
+ };
136
+
137
+ type SpeechToTextOptions = SpeechAuthenticationOptions & {
138
+ region?: string;
139
+ sourceLanguage: string;
140
+ targetLanguage?: string;
141
+ };
142
+ ```
143
+
144
+ Provide **exactly one** of `tokenEndpoint` or `getAuthorizationToken`:
145
+
146
+ - both → `Provide either tokenEndpoint or getAuthorizationToken, not both.`
147
+ - neither → `A tokenEndpoint or getAuthorizationToken provider is required.`
148
+
149
+ ### Token caching
150
+
151
+ Tokens are cached in memory:
152
+
153
+ - fetched on first use
154
+ - reused while still valid
155
+ - refreshed after about **8 minutes** by default (`tokenLifetimeMs`)
156
+ - parallel callers share one in-flight request
157
+ - invalid/empty token or region values throw clear errors
158
+ - tokens are never logged and never included in error events
159
+
160
+ ### Browser events
161
+
162
+ TTS:
163
+
164
+ - `COAzureTTSStartedPlaying`
165
+ - `COAzureTTSFinishedPlaying`
166
+ - `COAzureTTSStoppedPlaying`
167
+ - `COAzureTTSPausedPlaying`
168
+ - `COAzureTTSResumedPlaying`
169
+ - `COAzureTTSError` → `{ detail: { error: { message, code? } } }`
170
+
171
+ STT:
172
+
173
+ - `COAzureSTTStartedRecording`
174
+ - `COAzureSTTStoppedRecording`
175
+ - `COAzureSTTError` → `{ detail: { error: { message, code? } } }`
176
+
177
+ Error payloads never contain tokens or authorization headers.
178
+
179
+ ## Migrating from v2 to v3
180
+
181
+ This is a **breaking** change. The old positional constructor is removed on purpose.
182
+
183
+ ### Old unsafe usage
184
+
185
+ ```ts
186
+ const textToSpeech = new TextToSpeech(
187
+ window.azureSpeechKey,
188
+ 'westeurope',
189
+ 'nl-NL-FennaNeural',
190
+ 0,
191
+ 0
192
+ );
193
+ ```
194
+
195
+ Or Twig leaking the key into the browser:
196
+
197
+ ```twig
198
+ <script>
199
+ window.azureSpeechKey = '{{ craft.app.config.general.azureSpeechKey }}';
200
+ </script>
201
+ ```
202
+
203
+ Remove these patterns.
204
+
205
+ ### New safe usage
206
+
207
+ ```ts
208
+ const textToSpeech = new TextToSpeech({
209
+ tokenEndpoint: '/actions/azure-speech/token',
210
+ region: 'westeurope',
211
+ voice: 'nl-NL-FennaNeural',
212
+ rate: 0,
213
+ pitch: 0,
214
+ });
215
+ ```
216
+
217
+ ### Migration checklist
218
+
219
+ 1. Update the package to `3.x`.
220
+ 2. Store `AZURE_SPEECH_KEY` and `AZURE_SPEECH_REGION` server-side only.
221
+ 3. Add a backend token endpoint (Craft / Laravel / other).
222
+ 4. Replace the old constructor with the options object.
223
+ 5. Remove every Twig, JavaScript, Vite, Nuxt, or window variable that contained the subscription key.
224
+ 6. Rebuild the frontend.
225
+ 7. Confirm in DevTools that the permanent key no longer appears in network responses meant for the page, JS bundles, or globals.
226
+ 8. Test TTS, STT, highlighting, pause/resume, chained playback, and prefetching.
227
+ 9. **Rotate the previously exposed Azure subscription key.** Treat it as compromised.
228
+
229
+ ## Craft CMS integration
230
+
231
+ ### `.env`
232
+
233
+ ```dotenv
234
+ AZURE_SPEECH_KEY="replace-with-secret-key"
235
+ AZURE_SPEECH_REGION="westeurope"
236
+ ```
237
+
238
+ Never export these values to Twig globals, Vite `define`, or frontend env files.
239
+
240
+ ### Module registration (`config/app.php`)
241
+
242
+ ```php
243
+ <?php
244
+
245
+ return [
246
+ 'modules' => [
247
+ 'azure-speech' => [
248
+ 'class' => \modules\azurespeech\Module::class,
249
+ ],
250
+ ],
251
+ 'bootstrap' => [
252
+ 'azure-speech',
253
+ ],
254
+ ];
255
+ ```
256
+
257
+ With controller ID `token`, the action URL becomes:
258
+
259
+ ```text
260
+ /actions/azure-speech/token
261
+ ```
262
+
263
+ ### Token controller
264
+
265
+ ```php
266
+ <?php
267
+
268
+ namespace modules\azurespeech\controllers;
269
+
270
+ use Craft;
271
+ use craft\web\Controller;
272
+ use yii\web\Response;
273
+
274
+ class TokenController extends Controller
275
+ {
276
+ protected array|bool $allowAnonymous = true;
277
+
278
+ public function actionIndex(): Response
279
+ {
280
+ $key = Craft::parseEnv('$AZURE_SPEECH_KEY');
281
+ $region = Craft::parseEnv('$AZURE_SPEECH_REGION');
282
+
283
+ if (!$key || !$region) {
284
+ return $this->asFailure(
285
+ 'Azure Speech configuration is missing.'
286
+ );
287
+ }
288
+
289
+ $client = Craft::createGuzzleClient();
290
+
291
+ try {
292
+ $response = $client->post(
293
+ "https://{$region}.api.cognitive.microsoft.com/sts/v1.0/issueToken",
294
+ [
295
+ 'headers' => [
296
+ 'Ocp-Apim-Subscription-Key' => $key,
297
+ ],
298
+ 'timeout' => 10,
299
+ ]
300
+ );
301
+ } catch (\Throwable $exception) {
302
+ Craft::error(
303
+ 'Could not retrieve an Azure Speech token.',
304
+ __METHOD__
305
+ );
306
+
307
+ return $this->asFailure(
308
+ 'Speech authorization is temporarily unavailable.'
309
+ );
310
+ }
311
+
312
+ return $this->asJson([
313
+ 'token' => (string) $response->getBody(),
314
+ 'region' => $region,
315
+ ]);
316
+ }
317
+ }
318
+ ```
319
+
320
+ For older Craft/PHP versions where a typed property is undesirable:
321
+
322
+ ```php
323
+ protected $allowAnonymous = true;
324
+ ```
325
+
326
+ Use the property form that matches your Craft and PHP version.
327
+
328
+ ### Frontend initialization
329
+
330
+ ```ts
331
+ import {TextToSpeech} from '@creativeorange/azure-text-to-speech';
332
+
333
+ const textToSpeech = new TextToSpeech({
334
+ tokenEndpoint: '/actions/azure-speech/token',
335
+ region: 'westeurope',
336
+ voice: 'nl-NL-FennaNeural',
337
+ });
338
+
339
+ textToSpeech.start().catch((error) => {
340
+ console.error('Could not initialize text to speech.', error);
341
+ });
342
+ ```
343
+
344
+ ```ts
345
+ import {SpeechToText} from '@creativeorange/azure-text-to-speech';
346
+
347
+ const speechToText = new SpeechToText({
348
+ tokenEndpoint: '/actions/azure-speech/token',
349
+ region: 'westeurope',
350
+ sourceLanguage: 'nl-NL',
351
+ targetLanguage: 'nl',
352
+ });
353
+
354
+ speechToText.start().catch((error) => {
355
+ console.error('Could not initialize speech to text.', error);
356
+ });
357
+ ```
358
+
359
+ ### Protecting the Craft token endpoint
360
+
361
+ A temporary token may appear in the browser. That does **not** mean the endpoint can be public and unlimited.
362
+
363
+ Recommended controls:
364
+
365
+ - Craft rate limiting and/or webserver/proxy rate limiting
366
+ - Cloudflare rate limiting when available
367
+ - Authentication when TTS/STT is not meant for anonymous visitors
368
+ - HTTPS only
369
+ - Short server-side timeouts (for example 10 seconds)
370
+ - Azure budget and usage alerts
371
+ - Azure resource networking restrictions when infrastructure allows it
372
+ - Never put the permanent key in responses or logs
373
+
374
+ **CORS alone is not security.** Endpoints can be called outside a browser with `curl` or scripts. Rate limiting, auth, and Azure monitoring remain necessary.
375
+
376
+ ## Laravel token endpoint
377
+
378
+ ```php
379
+ use Illuminate\Support\Facades\Http;
380
+ use Illuminate\Support\Facades\Route;
381
+
382
+ Route::get('/api/azure-speech/token', function () {
383
+ $region = config('services.azure_speech.region');
384
+
385
+ $response = Http::timeout(10)
386
+ ->withHeaders([
387
+ 'Ocp-Apim-Subscription-Key' =>
388
+ config('services.azure_speech.key'),
389
+ ])
390
+ ->post(
391
+ "https://{$region}.api.cognitive.microsoft.com/sts/v1.0/issueToken"
392
+ );
393
+
394
+ abort_unless($response->successful(), 502);
395
+
396
+ return response()->json([
397
+ 'token' => $response->body(),
398
+ 'region' => $region,
399
+ ]);
400
+ })->middleware('throttle:20,1');
401
+ ```
402
+
403
+ Put this route behind authentication whenever the feature is not public.
404
+
405
+ Example `config/services.php` entries:
406
+
407
+ ```php
408
+ 'azure_speech' => [
409
+ 'key' => env('AZURE_SPEECH_KEY'),
410
+ 'region' => env('AZURE_SPEECH_REGION', 'westeurope'),
411
+ ],
412
+ ```
413
+
414
+ Frontend:
415
+
416
+ ```ts
417
+ const textToSpeech = new TextToSpeech({
418
+ tokenEndpoint: '/api/azure-speech/token',
419
+ region: 'westeurope',
420
+ voice: 'nl-NL-FennaNeural',
421
+ });
422
+ ```
423
+
424
+ Or with CSRF / custom headers:
425
+
426
+ ```ts
427
+ const textToSpeech = new TextToSpeech({
428
+ voice: 'nl-NL-FennaNeural',
429
+ region: 'westeurope',
430
+ tokenEndpoint: '/api/azure-speech/token',
431
+ tokenRequestOptions: {
432
+ headers: {
433
+ 'X-CSRF-TOKEN': document
434
+ .querySelector('meta[name="csrf-token"]')
435
+ ?.getAttribute('content') ?? '',
436
+ },
437
+ },
438
+ });
439
+ ```
440
+
441
+ ## Existing browser features
442
+
443
+ Version 3 keeps the existing client behaviour from the development branch:
444
+
445
+ - play / pause / resume / stop
446
+ - word-boundary highlighting
447
+ - chained playback (`co-tts.next`)
448
+ - audio prefetching
449
+ - lexicons via `url`
450
+ - voice / rate / pitch setters
451
+ - existing custom browser events
452
+
453
+ ## HTML attributes (unchanged)
454
+
455
+ TTS examples:
456
+
457
+ ```html
458
+ <button co-tts="Hello world">Play</button>
459
+ <button co-tts.pause>Pause</button>
460
+ <button co-tts.resume>Resume</button>
461
+ <button co-tts.stop>Stop</button>
462
+
463
+ <div id="article" co-tts.highlight co-tts.text="Read this text" co-tts.next="next-block">
464
+ Read this text
465
+ </div>
466
+ ```
467
+
468
+ STT examples:
469
+
470
+ ```html
471
+ <button co-stt.start="transcript">Start</button>
472
+ <button co-stt.stop>Stop</button>
473
+ <div id="transcript"></div>
474
+ ```
475
+
476
+ ## Development
477
+
478
+ ```bash
479
+ npm install
480
+ npm run build
481
+ npm test
482
+ npm run lint
483
+ ```
484
+
485
+ Security sanity checks:
486
+
487
+ ```bash
488
+ grep -R "fromSubscription" src
489
+ grep -R "subscriptionKey" src
490
+ ```
491
+
492
+ Both should return no matches in package source.
493
+
494
+ ## License
495
+
496
+ MIT