@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.
@@ -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;
@@ -34,6 +46,7 @@ export class TextToSpeech {
34
46
  currentWord: string = '';
35
47
  currentOffset: number = 0;
36
48
  wordBoundaryOffset: number = 0;
49
+ playbackTextOffsetBase: number | undefined;
37
50
  prevTextOffset: number = 0;
38
51
  url: string = '';
39
52
  prefetchedAudio: Map<string, any> = new Map();
@@ -41,13 +54,19 @@ export class TextToSpeech {
41
54
  activePrefetchedAudioUrl: string = '';
42
55
  playbackSegments: any[] = [];
43
56
 
44
- constructor(key: string, region: string, voice: string, rate: number = 0, pitch: number = 0, url: string = '') {
45
- this.key = key;
46
- this.region = region;
47
- this.voice = voice;
48
- this.rate = rate;
49
- this.pitch = pitch;
50
- 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 ?? '';
51
70
  }
52
71
 
53
72
  async start() {
@@ -120,7 +139,7 @@ export class TextToSpeech {
120
139
  if (referenceDiv.hasAttribute('co-tts.text') && referenceDiv.getAttribute('co-tts.text') !== '') {
121
140
  this.textToRead = referenceDiv.getAttribute('co-tts.text') ?? '';
122
141
  } else {
123
- this.textToRead = referenceDiv.innerText;
142
+ this.textToRead = referenceDiv.innerText ?? referenceDiv.textContent ?? '';
124
143
  }
125
144
 
126
145
  if (referenceDiv.hasAttribute('co-tts.highlight')) {
@@ -136,7 +155,7 @@ export class TextToSpeech {
136
155
  }
137
156
  }
138
157
 
139
- this.startSynthesizer(node, attr);
158
+ await this.startSynthesizer(node, attr);
140
159
  });
141
160
  }
142
161
 
@@ -151,7 +170,7 @@ export class TextToSpeech {
151
170
 
152
171
  this.textToRead = await response.text();
153
172
 
154
- this.startSynthesizer(node, attr);
173
+ await this.startSynthesizer(node, attr);
155
174
  });
156
175
  }
157
176
 
@@ -172,12 +191,12 @@ export class TextToSpeech {
172
191
  }
173
192
  }
174
193
  if (attr.value === '') {
175
- this.textToRead = node.innerText;
194
+ this.textToRead = node.innerText ?? node.textContent ?? '';
176
195
  } else {
177
196
  this.textToRead = attr.value;
178
197
  }
179
198
 
180
- this.startSynthesizer(node, attr);
199
+ await this.startSynthesizer(node, attr);
181
200
  });
182
201
  }
183
202
 
@@ -199,12 +218,12 @@ export class TextToSpeech {
199
218
  }
200
219
  }
201
220
  if (attr.value === '') {
202
- this.textToRead = node.innerText;
221
+ this.textToRead = node.innerText ?? node.textContent ?? '';
203
222
  } else {
204
223
  this.textToRead = attr.value;
205
224
  }
206
225
 
207
- this.startSynthesizer(node, attr);
226
+ await this.startSynthesizer(node, attr);
208
227
  }
209
228
 
210
229
  async handleStopModifier(node: any, attr: Attr) {
@@ -250,79 +269,105 @@ export class TextToSpeech {
250
269
  URL.revokeObjectURL(this.activePrefetchedAudioUrl);
251
270
  this.activePrefetchedAudioUrl = '';
252
271
  }
272
+ this.closeSynthesizer();
253
273
  this.player = undefined;
274
+ this.audioConfig = undefined;
275
+ this.speechConfig = undefined;
254
276
  this.highlightDiv = undefined;
255
277
  this.prevTextOffset = 0;
278
+ this.playbackTextOffsetBase = undefined;
256
279
  }
257
280
 
258
- async startSynthesizer(node: any, attr: Attr) {
259
- this.speechConfig = SpeechConfig.fromSubscription(this.key, this.region);
281
+ private async createSpeechConfig(): Promise<SpeechConfig> {
282
+ const authorization =
283
+ await this.authorizationManager.getAuthorization();
260
284
 
261
- this.speechConfig.speechSynthesisVoiceName = `Microsoft Server Speech Text to Speech Voice (${this.voice})`;
262
- this.speechConfig.speechSynthesisOutputFormat = SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3;
285
+ const speechConfig = SpeechConfig.fromAuthorizationToken(
286
+ authorization.token,
287
+ authorization.region,
288
+ );
263
289
 
264
- this.player = new SpeakerAudioDestination();
290
+ speechConfig.speechSynthesisVoiceName =
291
+ `Microsoft Server Speech Text to Speech Voice (${this.voice})`;
265
292
 
266
- this.audioConfig = AudioConfig.fromSpeakerOutput(this.player);
267
- this.synthesizer = new SpeechSynthesizer(this.speechConfig, this.audioConfig);
293
+ speechConfig.speechSynthesisOutputFormat =
294
+ SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3;
268
295
 
269
- this.synthesizer.wordBoundary = (s: any, e: any) => {
270
- this.wordBoundryList.push(e);
271
- };
296
+ this.region = authorization.region;
272
297
 
273
- const playbackChain = this.collectPlaybackChain(this.clickedNode);
274
- const isChainedPlayback = playbackChain.length > 1;
298
+ return speechConfig;
299
+ }
275
300
 
276
- if (isChainedPlayback) {
277
- this.preparePlaybackChain(playbackChain);
278
- } else {
279
- this.playbackSegments = [];
280
- }
301
+ async startSynthesizer(node: any, attr: Attr) {
302
+ try {
303
+ this.speechConfig = await this.createSpeechConfig();
281
304
 
282
- this.player.onAudioEnd = async () => {
283
- const wasChainedPlayback = this.playbackSegments.length > 0;
284
- this.stopPlayer();
305
+ this.player = new SpeakerAudioDestination();
285
306
 
286
- if (wasChainedPlayback) {
287
- document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
307
+ this.audioConfig = AudioConfig.fromSpeakerOutput(this.player);
308
+ this.synthesizer = new SpeechSynthesizer(this.speechConfig, this.audioConfig);
288
309
 
289
- 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 = [];
290
321
  }
291
322
 
292
- if (this.clickedNode.hasAttribute('co-tts.next')) {
293
- 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', {}));
294
329
 
295
- if (nextNode && await this.playPrefetchedNode(nextNode)) {
296
330
  return;
297
331
  }
298
332
 
299
- if (nextNode && nextNode.attributes.getNamedItem('co-tts.text')) {
300
- this.handleWithoutClick(nextNode, nextNode.attributes.getNamedItem('co-tts.text'));
301
- } else if (nextNode) {
302
- 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', {}));
303
347
  }
304
- } else {
305
- document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
306
- }
307
- };
348
+ };
308
349
 
309
- this.player.onAudioStart = async () => {
310
- document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
311
- };
350
+ this.player.onAudioStart = async () => {
351
+ document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
352
+ };
312
353
 
313
- if (!isChainedPlayback) {
314
- this.prefetchNextNode(this.clickedNode);
315
- }
354
+ if (!isChainedPlayback) {
355
+ this.prefetchNextNode(this.clickedNode);
356
+ }
316
357
 
317
- this.synthesizer.speakSsmlAsync(this.buildSSML(this.textToRead),
318
- () => {
319
- this.synthesizer.close();
320
- this.synthesizer = undefined;
321
- },
322
- () => {
323
- this.synthesizer.close();
324
- this.synthesizer = undefined;
325
- });
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
+ }
326
371
  }
327
372
 
328
373
  collectPlaybackChain(node: any) {
@@ -372,6 +417,7 @@ export class TextToSpeech {
372
417
  this.wordEncounters = [];
373
418
  this.previousWordBoundary = undefined;
374
419
  this.prevTextOffset = 0;
420
+ this.playbackTextOffsetBase = undefined;
375
421
  this.currentWord = '';
376
422
  this.currentOffset = 0;
377
423
  this.wordBoundaryOffset = 0;
@@ -408,8 +454,13 @@ export class TextToSpeech {
408
454
  return;
409
455
  }
410
456
 
457
+ if (this.playbackTextOffsetBase === undefined) {
458
+ this.playbackTextOffsetBase = wordBoundary.textOffset;
459
+ }
460
+
461
+ const normalizedTextOffset = Math.max(0, wordBoundary.textOffset - this.playbackTextOffsetBase);
411
462
  const segment = this.playbackSegments.find((candidate) => (
412
- wordBoundary.textOffset >= candidate.start && wordBoundary.textOffset < candidate.end
463
+ normalizedTextOffset >= candidate.start && normalizedTextOffset < candidate.end
413
464
  ));
414
465
 
415
466
  this.resetPlaybackSegments();
@@ -420,7 +471,7 @@ export class TextToSpeech {
420
471
  return;
421
472
  }
422
473
 
423
- const relativeTextOffset = wordBoundary.textOffset - segment.start;
474
+ const relativeTextOffset = normalizedTextOffset - segment.start;
424
475
  const currentOffset = this.getPosition(segment.originalHighlightDivInnerHTML, wordBoundary.text, relativeTextOffset);
425
476
 
426
477
  if (currentOffset === Number.MAX_SAFE_INTEGER) {
@@ -448,7 +499,7 @@ export class TextToSpeech {
448
499
  return node.getAttribute('co-tts.text') ?? '';
449
500
  }
450
501
 
451
- return node.innerText;
502
+ return node.innerText ?? node.textContent ?? '';
452
503
  }
453
504
 
454
505
  getPrefetchKey(node: any, text: string) {
@@ -497,46 +548,57 @@ export class TextToSpeech {
497
548
  return;
498
549
  }
499
550
 
500
- const speechConfig = SpeechConfig.fromSubscription(this.key, this.region);
501
- speechConfig.speechSynthesisVoiceName = `Microsoft Server Speech Text to Speech Voice (${this.voice})`;
502
- speechConfig.speechSynthesisOutputFormat = SpeechSynthesisOutputFormat.Audio24Khz160KBitRateMonoMp3;
503
-
504
- const synthesizer = new SpeechSynthesizer(speechConfig, null);
505
- const wordBoundryList: any[] = [];
506
-
507
- synthesizer.wordBoundary = (s: any, e: any) => {
508
- wordBoundryList.push(e);
509
- };
510
-
511
- const prefetchPromise = new Promise((resolve) => {
512
- synthesizer.speakSsmlAsync(this.buildSSML(text),
513
- (result: any) => {
514
- synthesizer.close();
515
-
516
- if (!result?.audioData) {
517
- resolve(null);
518
-
519
- return;
520
- }
521
-
522
- const blob = new Blob([result.audioData], {type: 'audio/mpeg'});
523
- const url = URL.createObjectURL(blob);
524
- const prefetch = {
525
- key,
526
- nodeId: nextNode.id,
527
- text,
528
- url,
529
- wordBoundryList,
530
- };
531
-
532
- this.prefetchedAudio.set(key, prefetch);
533
- resolve(prefetch);
534
- },
535
- () => {
536
- synthesizer.close();
537
- 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
+ });
538
594
  });
539
- }).finally(() => {
595
+ } catch (error) {
596
+ this.closeResource(synthesizer);
597
+ synthesizer = undefined;
598
+ this.dispatchError(error);
599
+ return null;
600
+ }
601
+ })().finally(() => {
540
602
  this.prefetchPromises.delete(key);
541
603
  });
542
604
 
@@ -580,38 +642,53 @@ export class TextToSpeech {
580
642
 
581
643
  await this.createInterval();
582
644
 
583
- const audio = new Audio(prefetch.url);
584
- this.activePrefetchedAudioUrl = prefetch.url;
585
- this.player = audio;
645
+ try {
646
+ const audio = new Audio(prefetch.url);
647
+ this.activePrefetchedAudioUrl = prefetch.url;
648
+ this.player = audio;
586
649
 
587
- audio.addEventListener('play', () => {
588
- document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
589
- }, {once: true});
650
+ audio.addEventListener('play', () => {
651
+ document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
652
+ }, {once: true});
590
653
 
591
- audio.addEventListener('ended', async () => {
592
- this.stopPlayer();
654
+ audio.addEventListener('ended', async () => {
655
+ this.stopPlayer();
593
656
 
594
- if (this.clickedNode.hasAttribute('co-tts.next')) {
595
- 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'));
596
659
 
597
- if (nextNode && await this.playPrefetchedNode(nextNode)) {
598
- return;
599
- }
660
+ if (nextNode && await this.playPrefetchedNode(nextNode)) {
661
+ return;
662
+ }
600
663
 
601
- if (nextNode && nextNode.attributes.getNamedItem('co-tts.text')) {
602
- this.handleWithoutClick(nextNode, nextNode.attributes.getNamedItem('co-tts.text'));
603
- } else if (nextNode) {
604
- 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', {}));
605
671
  }
606
- } else {
607
- document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
608
- }
609
- }, {once: true});
610
-
611
- this.prefetchNextNode(node);
612
- 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();
613
689
 
614
- return true;
690
+ return false;
691
+ }
615
692
  }
616
693
 
617
694
  async clearInterval() {
@@ -620,7 +697,7 @@ export class TextToSpeech {
620
697
 
621
698
  async createInterval() {
622
699
  this.interval = setInterval(() => {
623
- if (this.player !== undefined && this.highlightDiv) {
700
+ if (this.player !== undefined && (this.highlightDiv || this.playbackSegments.length > 0)) {
624
701
  const currentTime = this.player.currentTime;
625
702
  let wordBoundary;
626
703
  for (const e of this.wordBoundryList) {
@@ -674,6 +751,8 @@ export class TextToSpeech {
674
751
  `;
675
752
  }
676
753
  }
754
+ } else if (this.playbackSegments.length > 0) {
755
+ this.resetPlaybackSegments();
677
756
  } else {
678
757
  this.highlightDiv.innerHTML = this.originalHighlightDivInnerHTML;
679
758
  }
@@ -732,4 +811,36 @@ export class TextToSpeech {
732
811
 
733
812
  return p.innerHTML;
734
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
+ }
735
846
  }