@creativeorange/azure-text-to-speech 2.2.3 → 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.
@@ -5,9 +5,21 @@ import {
5
5
  SpeechSynthesizer,
6
6
  SpeechSynthesisOutputFormat,
7
7
  } from 'microsoft-cognitiveservices-speech-sdk';
8
+ import {
9
+ SpeechAuthenticationOptions,
10
+ SpeechAuthorizationManager,
11
+ toSafeErrorDetail,
12
+ } from './authentication';
13
+
14
+ export type TextToSpeechOptions = SpeechAuthenticationOptions & {
15
+ region?: string;
16
+ voice: string;
17
+ rate?: number;
18
+ pitch?: number;
19
+ url?: string;
20
+ };
8
21
 
9
22
  export class TextToSpeech {
10
- key: string;
11
23
  region: string;
12
24
  voice: string;
13
25
  rate: number;
@@ -42,13 +54,19 @@ export class TextToSpeech {
42
54
  activePrefetchedAudioUrl: string = '';
43
55
  playbackSegments: any[] = [];
44
56
 
45
- constructor(key: string, region: string, voice: string, rate: number = 0, pitch: number = 0, url: string = '') {
46
- this.key = key;
47
- this.region = region;
48
- this.voice = voice;
49
- this.rate = rate;
50
- this.pitch = pitch;
51
- this.url = url;
57
+ private readonly authorizationManager: SpeechAuthorizationManager;
58
+
59
+ constructor(options: TextToSpeechOptions) {
60
+ if (!options || typeof options.voice !== 'string' || options.voice.trim() === '') {
61
+ throw new Error('A voice is required.');
62
+ }
63
+
64
+ this.authorizationManager = new SpeechAuthorizationManager(options);
65
+ this.region = options.region?.trim() ?? '';
66
+ this.voice = options.voice;
67
+ this.rate = options.rate ?? 0;
68
+ this.pitch = options.pitch ?? 0;
69
+ this.url = options.url ?? '';
52
70
  }
53
71
 
54
72
  async start() {
@@ -121,7 +139,7 @@ export class TextToSpeech {
121
139
  if (referenceDiv.hasAttribute('co-tts.text') && referenceDiv.getAttribute('co-tts.text') !== '') {
122
140
  this.textToRead = referenceDiv.getAttribute('co-tts.text') ?? '';
123
141
  } else {
124
- this.textToRead = referenceDiv.innerText;
142
+ this.textToRead = referenceDiv.innerText ?? referenceDiv.textContent ?? '';
125
143
  }
126
144
 
127
145
  if (referenceDiv.hasAttribute('co-tts.highlight')) {
@@ -137,7 +155,7 @@ export class TextToSpeech {
137
155
  }
138
156
  }
139
157
 
140
- this.startSynthesizer(node, attr);
158
+ await this.startSynthesizer(node, attr);
141
159
  });
142
160
  }
143
161
 
@@ -152,7 +170,7 @@ export class TextToSpeech {
152
170
 
153
171
  this.textToRead = await response.text();
154
172
 
155
- this.startSynthesizer(node, attr);
173
+ await this.startSynthesizer(node, attr);
156
174
  });
157
175
  }
158
176
 
@@ -173,12 +191,12 @@ export class TextToSpeech {
173
191
  }
174
192
  }
175
193
  if (attr.value === '') {
176
- this.textToRead = node.innerText;
194
+ this.textToRead = node.innerText ?? node.textContent ?? '';
177
195
  } else {
178
196
  this.textToRead = attr.value;
179
197
  }
180
198
 
181
- this.startSynthesizer(node, attr);
199
+ await this.startSynthesizer(node, attr);
182
200
  });
183
201
  }
184
202
 
@@ -200,12 +218,12 @@ export class TextToSpeech {
200
218
  }
201
219
  }
202
220
  if (attr.value === '') {
203
- this.textToRead = node.innerText;
221
+ this.textToRead = node.innerText ?? node.textContent ?? '';
204
222
  } else {
205
223
  this.textToRead = attr.value;
206
224
  }
207
225
 
208
- this.startSynthesizer(node, attr);
226
+ await this.startSynthesizer(node, attr);
209
227
  }
210
228
 
211
229
  async handleStopModifier(node: any, attr: Attr) {
@@ -251,80 +269,105 @@ export class TextToSpeech {
251
269
  URL.revokeObjectURL(this.activePrefetchedAudioUrl);
252
270
  this.activePrefetchedAudioUrl = '';
253
271
  }
272
+ this.closeSynthesizer();
254
273
  this.player = undefined;
274
+ this.audioConfig = undefined;
275
+ this.speechConfig = undefined;
255
276
  this.highlightDiv = undefined;
256
277
  this.prevTextOffset = 0;
257
278
  this.playbackTextOffsetBase = undefined;
258
279
  }
259
280
 
260
- async startSynthesizer(node: any, attr: Attr) {
261
- this.speechConfig = SpeechConfig.fromSubscription(this.key, this.region);
281
+ private async createSpeechConfig(): Promise<SpeechConfig> {
282
+ const authorization =
283
+ await this.authorizationManager.getAuthorization();
262
284
 
263
- this.speechConfig.speechSynthesisVoiceName = `Microsoft Server Speech Text to Speech Voice (${this.voice})`;
264
- this.speechConfig.speechSynthesisOutputFormat = SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3;
285
+ const speechConfig = SpeechConfig.fromAuthorizationToken(
286
+ authorization.token,
287
+ authorization.region,
288
+ );
265
289
 
266
- this.player = new SpeakerAudioDestination();
290
+ speechConfig.speechSynthesisVoiceName =
291
+ `Microsoft Server Speech Text to Speech Voice (${this.voice})`;
267
292
 
268
- this.audioConfig = AudioConfig.fromSpeakerOutput(this.player);
269
- this.synthesizer = new SpeechSynthesizer(this.speechConfig, this.audioConfig);
293
+ speechConfig.speechSynthesisOutputFormat =
294
+ SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3;
270
295
 
271
- this.synthesizer.wordBoundary = (s: any, e: any) => {
272
- this.wordBoundryList.push(e);
273
- };
296
+ this.region = authorization.region;
274
297
 
275
- const playbackChain = this.collectPlaybackChain(this.clickedNode);
276
- const isChainedPlayback = playbackChain.length > 1;
298
+ return speechConfig;
299
+ }
277
300
 
278
- if (isChainedPlayback) {
279
- this.preparePlaybackChain(playbackChain);
280
- } else {
281
- this.playbackSegments = [];
282
- }
301
+ async startSynthesizer(node: any, attr: Attr) {
302
+ try {
303
+ this.speechConfig = await this.createSpeechConfig();
283
304
 
284
- this.player.onAudioEnd = async () => {
285
- const wasChainedPlayback = this.playbackSegments.length > 0;
286
- this.stopPlayer();
305
+ this.player = new SpeakerAudioDestination();
287
306
 
288
- if (wasChainedPlayback) {
289
- document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
307
+ this.audioConfig = AudioConfig.fromSpeakerOutput(this.player);
308
+ this.synthesizer = new SpeechSynthesizer(this.speechConfig, this.audioConfig);
290
309
 
291
- return;
310
+ this.synthesizer.wordBoundary = (s: any, e: any) => {
311
+ this.wordBoundryList.push(e);
312
+ };
313
+
314
+ const playbackChain = this.collectPlaybackChain(this.clickedNode);
315
+ const isChainedPlayback = playbackChain.length > 1;
316
+
317
+ if (isChainedPlayback) {
318
+ this.preparePlaybackChain(playbackChain);
319
+ } else {
320
+ this.playbackSegments = [];
292
321
  }
293
322
 
294
- if (this.clickedNode.hasAttribute('co-tts.next')) {
295
- const nextNode = document.getElementById(this.clickedNode.getAttribute('co-tts.next'));
323
+ this.player.onAudioEnd = async () => {
324
+ const wasChainedPlayback = this.playbackSegments.length > 0;
325
+ this.stopPlayer();
326
+
327
+ if (wasChainedPlayback) {
328
+ document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
296
329
 
297
- if (nextNode && await this.playPrefetchedNode(nextNode)) {
298
330
  return;
299
331
  }
300
332
 
301
- if (nextNode && nextNode.attributes.getNamedItem('co-tts.text')) {
302
- this.handleWithoutClick(nextNode, nextNode.attributes.getNamedItem('co-tts.text'));
303
- } else if (nextNode) {
304
- nextNode.dispatchEvent(new Event('click'));
333
+ if (this.clickedNode.hasAttribute('co-tts.next')) {
334
+ const nextNode = document.getElementById(this.clickedNode.getAttribute('co-tts.next'));
335
+
336
+ if (nextNode && await this.playPrefetchedNode(nextNode)) {
337
+ return;
338
+ }
339
+
340
+ if (nextNode && nextNode.attributes.getNamedItem('co-tts.text')) {
341
+ this.handleWithoutClick(nextNode, nextNode.attributes.getNamedItem('co-tts.text'));
342
+ } else if (nextNode) {
343
+ nextNode.dispatchEvent(new Event('click'));
344
+ }
345
+ } else {
346
+ document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
305
347
  }
306
- } else {
307
- document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
308
- }
309
- };
348
+ };
310
349
 
311
- this.player.onAudioStart = async () => {
312
- document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
313
- };
350
+ this.player.onAudioStart = async () => {
351
+ document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
352
+ };
314
353
 
315
- if (!isChainedPlayback) {
316
- this.prefetchNextNode(this.clickedNode);
317
- }
354
+ if (!isChainedPlayback) {
355
+ this.prefetchNextNode(this.clickedNode);
356
+ }
318
357
 
319
- this.synthesizer.speakSsmlAsync(this.buildSSML(this.textToRead),
320
- () => {
321
- this.synthesizer.close();
322
- this.synthesizer = undefined;
323
- },
324
- () => {
325
- this.synthesizer.close();
326
- this.synthesizer = undefined;
327
- });
358
+ this.synthesizer.speakSsmlAsync(this.buildSSML(this.textToRead),
359
+ () => {
360
+ this.closeSynthesizer();
361
+ },
362
+ (error: unknown) => {
363
+ this.dispatchError(error, 'SYNTHESIS_ERROR');
364
+ this.closeSynthesizer();
365
+ this.stopPlayer();
366
+ });
367
+ } catch (error) {
368
+ this.dispatchError(error);
369
+ await this.stopPlayer();
370
+ }
328
371
  }
329
372
 
330
373
  collectPlaybackChain(node: any) {
@@ -456,7 +499,7 @@ export class TextToSpeech {
456
499
  return node.getAttribute('co-tts.text') ?? '';
457
500
  }
458
501
 
459
- return node.innerText;
502
+ return node.innerText ?? node.textContent ?? '';
460
503
  }
461
504
 
462
505
  getPrefetchKey(node: any, text: string) {
@@ -505,46 +548,57 @@ export class TextToSpeech {
505
548
  return;
506
549
  }
507
550
 
508
- const speechConfig = SpeechConfig.fromSubscription(this.key, this.region);
509
- speechConfig.speechSynthesisVoiceName = `Microsoft Server Speech Text to Speech Voice (${this.voice})`;
510
- speechConfig.speechSynthesisOutputFormat = SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3;
511
-
512
- const synthesizer = new SpeechSynthesizer(speechConfig, null);
513
- const wordBoundryList: any[] = [];
514
-
515
- synthesizer.wordBoundary = (s: any, e: any) => {
516
- wordBoundryList.push(e);
517
- };
518
-
519
- const prefetchPromise = new Promise((resolve) => {
520
- synthesizer.speakSsmlAsync(this.buildSSML(text),
521
- (result: any) => {
522
- synthesizer.close();
523
-
524
- if (!result?.audioData) {
525
- resolve(null);
526
-
527
- return;
528
- }
529
-
530
- const blob = new Blob([result.audioData], {type: 'audio/mpeg'});
531
- const url = URL.createObjectURL(blob);
532
- const prefetch = {
533
- key,
534
- nodeId: nextNode.id,
535
- text,
536
- url,
537
- wordBoundryList,
538
- };
539
-
540
- this.prefetchedAudio.set(key, prefetch);
541
- resolve(prefetch);
542
- },
543
- () => {
544
- synthesizer.close();
545
- resolve(null);
551
+ let synthesizer: SpeechSynthesizer | undefined;
552
+
553
+ const prefetchPromise = (async () => {
554
+ try {
555
+ const speechConfig = await this.createSpeechConfig();
556
+ synthesizer = new SpeechSynthesizer(speechConfig, null);
557
+ const wordBoundryList: any[] = [];
558
+
559
+ synthesizer.wordBoundary = (s: any, e: any) => {
560
+ wordBoundryList.push(e);
561
+ };
562
+
563
+ return await new Promise((resolve) => {
564
+ synthesizer?.speakSsmlAsync(this.buildSSML(text),
565
+ (result: any) => {
566
+ this.closeResource(synthesizer);
567
+ synthesizer = undefined;
568
+
569
+ if (!result?.audioData) {
570
+ resolve(null);
571
+
572
+ return;
573
+ }
574
+
575
+ const blob = new Blob([result.audioData], {type: 'audio/mpeg'});
576
+ const url = URL.createObjectURL(blob);
577
+ const prefetch = {
578
+ key,
579
+ nodeId: nextNode.id,
580
+ text,
581
+ url,
582
+ wordBoundryList,
583
+ };
584
+
585
+ this.prefetchedAudio.set(key, prefetch);
586
+ resolve(prefetch);
587
+ },
588
+ (error: unknown) => {
589
+ this.closeResource(synthesizer);
590
+ synthesizer = undefined;
591
+ this.dispatchError(error, 'PREFETCH_ERROR');
592
+ resolve(null);
593
+ });
546
594
  });
547
- }).finally(() => {
595
+ } catch (error) {
596
+ this.closeResource(synthesizer);
597
+ synthesizer = undefined;
598
+ this.dispatchError(error);
599
+ return null;
600
+ }
601
+ })().finally(() => {
548
602
  this.prefetchPromises.delete(key);
549
603
  });
550
604
 
@@ -588,38 +642,53 @@ export class TextToSpeech {
588
642
 
589
643
  await this.createInterval();
590
644
 
591
- const audio = new Audio(prefetch.url);
592
- this.activePrefetchedAudioUrl = prefetch.url;
593
- this.player = audio;
645
+ try {
646
+ const audio = new Audio(prefetch.url);
647
+ this.activePrefetchedAudioUrl = prefetch.url;
648
+ this.player = audio;
594
649
 
595
- audio.addEventListener('play', () => {
596
- document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
597
- }, {once: true});
650
+ audio.addEventListener('play', () => {
651
+ document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
652
+ }, {once: true});
598
653
 
599
- audio.addEventListener('ended', async () => {
600
- this.stopPlayer();
654
+ audio.addEventListener('ended', async () => {
655
+ this.stopPlayer();
601
656
 
602
- if (this.clickedNode.hasAttribute('co-tts.next')) {
603
- const nextNode = document.getElementById(this.clickedNode.getAttribute('co-tts.next'));
657
+ if (this.clickedNode.hasAttribute('co-tts.next')) {
658
+ const nextNode = document.getElementById(this.clickedNode.getAttribute('co-tts.next'));
604
659
 
605
- if (nextNode && await this.playPrefetchedNode(nextNode)) {
606
- return;
607
- }
660
+ if (nextNode && await this.playPrefetchedNode(nextNode)) {
661
+ return;
662
+ }
608
663
 
609
- if (nextNode && nextNode.attributes.getNamedItem('co-tts.text')) {
610
- this.handleWithoutClick(nextNode, nextNode.attributes.getNamedItem('co-tts.text'));
611
- } else if (nextNode) {
612
- nextNode.dispatchEvent(new Event('click'));
664
+ if (nextNode && nextNode.attributes.getNamedItem('co-tts.text')) {
665
+ this.handleWithoutClick(nextNode, nextNode.attributes.getNamedItem('co-tts.text'));
666
+ } else if (nextNode) {
667
+ nextNode.dispatchEvent(new Event('click'));
668
+ }
669
+ } else {
670
+ document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
613
671
  }
614
- } else {
615
- document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
616
- }
617
- }, {once: true});
618
-
619
- this.prefetchNextNode(node);
620
- await audio.play();
672
+ }, {once: true});
673
+
674
+ audio.addEventListener('error', () => {
675
+ this.dispatchError(
676
+ new Error('Prefetched audio resource closed unexpectedly.'),
677
+ 'AUDIO_RESOURCE_ERROR',
678
+ );
679
+ this.stopPlayer();
680
+ }, {once: true});
681
+
682
+ this.prefetchNextNode(node);
683
+ await audio.play();
684
+
685
+ return true;
686
+ } catch (error) {
687
+ this.dispatchError(error, 'AUDIO_RESOURCE_ERROR');
688
+ await this.stopPlayer();
621
689
 
622
- return true;
690
+ return false;
691
+ }
623
692
  }
624
693
 
625
694
  async clearInterval() {
@@ -742,4 +811,36 @@ export class TextToSpeech {
742
811
 
743
812
  return p.innerHTML;
744
813
  }
814
+
815
+ private closeSynthesizer() {
816
+ this.closeResource(this.synthesizer);
817
+ this.synthesizer = undefined;
818
+ }
819
+
820
+ private closeResource(resource: {close?: () => void} | undefined) {
821
+ if (!resource || typeof resource.close !== 'function') {
822
+ return;
823
+ }
824
+
825
+ try {
826
+ resource.close();
827
+ } catch {
828
+ // Resource may already be closed.
829
+ }
830
+ }
831
+
832
+ private dispatchError(error: unknown, code?: string) {
833
+ const detail = toSafeErrorDetail(error);
834
+ if (code && !detail.code) {
835
+ detail.code = code;
836
+ }
837
+
838
+ document.dispatchEvent(
839
+ new CustomEvent('COAzureTTSError', {
840
+ detail: {
841
+ error: detail,
842
+ },
843
+ }),
844
+ );
845
+ }
745
846
  }