@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 +496 -0
- package/dist/co-azure-tts.es.js +437 -169
- package/dist/co-azure-tts.umd.js +10 -10
- package/package.json +21 -3
- package/src/SpeechToText.ts +106 -40
- package/src/TextToSpeech.ts +243 -132
- package/src/authentication.ts +228 -0
- package/src/main.ts +7 -0
- package/.eslintrc.js +0 -30
- package/index.html +0 -578
- package/nuxt/plugins/azure-speech-to-text.client.js +0 -27
- package/nuxt/plugins/azure-text-to-speech.client.js +0 -28
- package/tsconfig.json +0 -21
- package/vite.config.ts +0 -26
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
|