@creativeorange/azure-text-to-speech 2.1.1 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@creativeorange/azure-text-to-speech",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "main": "dist/co-azure-tts.umd.js",
5
5
  "browser": "dist/co-azure-tts.es.js",
6
6
  "scripts": {
@@ -36,6 +36,10 @@ export class TextToSpeech {
36
36
  wordBoundaryOffset: number = 0;
37
37
  prevTextOffset: number = 0;
38
38
  url: string = '';
39
+ prefetchedAudio: Map<string, any> = new Map();
40
+ prefetchPromises: Map<string, Promise<any>> = new Map();
41
+ activePrefetchedAudioUrl: string = '';
42
+ playbackSegments: any[] = [];
39
43
 
40
44
  constructor(key: string, region: string, voice: string, rate: number = 0, pitch: number = 0, url: string = '') {
41
45
  this.key = key;
@@ -52,18 +56,21 @@ export class TextToSpeech {
52
56
 
53
57
  setVoice(voice: string) {
54
58
  this.voice = voice;
59
+ this.clearPrefetchedAudio();
55
60
 
56
61
  return this;
57
62
  }
58
63
 
59
64
  setRate(rate: number) {
60
65
  this.rate = rate;
66
+ this.clearPrefetchedAudio();
61
67
 
62
68
  return this;
63
69
  }
64
70
 
65
71
  setPitch(pitch: number) {
66
72
  this.pitch = pitch;
73
+ this.clearPrefetchedAudio();
67
74
 
68
75
  return this;
69
76
  }
@@ -234,9 +241,15 @@ export class TextToSpeech {
234
241
  this.originalHighlightDivInnerHTML = '';
235
242
  this.wordBoundryList = [];
236
243
  this.wordEncounters = [];
244
+ this.resetPlaybackSegments();
245
+ this.playbackSegments = [];
237
246
  if (this.player !== undefined) {
238
247
  this.player.pause();
239
248
  }
249
+ if (this.activePrefetchedAudioUrl !== '') {
250
+ URL.revokeObjectURL(this.activePrefetchedAudioUrl);
251
+ this.activePrefetchedAudioUrl = '';
252
+ }
240
253
  this.player = undefined;
241
254
  this.highlightDiv = undefined;
242
255
  this.prevTextOffset = 0;
@@ -257,11 +270,32 @@ export class TextToSpeech {
257
270
  this.wordBoundryList.push(e);
258
271
  };
259
272
 
273
+ const playbackChain = this.collectPlaybackChain(this.clickedNode);
274
+ const isChainedPlayback = playbackChain.length > 1;
275
+
276
+ if (isChainedPlayback) {
277
+ this.preparePlaybackChain(playbackChain);
278
+ } else {
279
+ this.playbackSegments = [];
280
+ }
281
+
260
282
  this.player.onAudioEnd = async () => {
283
+ const wasChainedPlayback = this.playbackSegments.length > 0;
261
284
  this.stopPlayer();
262
285
 
286
+ if (wasChainedPlayback) {
287
+ document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
288
+
289
+ return;
290
+ }
291
+
263
292
  if (this.clickedNode.hasAttribute('co-tts.next')) {
264
293
  const nextNode = document.getElementById(this.clickedNode.getAttribute('co-tts.next'));
294
+
295
+ if (nextNode && await this.playPrefetchedNode(nextNode)) {
296
+ return;
297
+ }
298
+
265
299
  if (nextNode && nextNode.attributes.getNamedItem('co-tts.text')) {
266
300
  this.handleWithoutClick(nextNode, nextNode.attributes.getNamedItem('co-tts.text'));
267
301
  } else if (nextNode) {
@@ -276,6 +310,10 @@ export class TextToSpeech {
276
310
  document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
277
311
  };
278
312
 
313
+ if (!isChainedPlayback) {
314
+ this.prefetchNextNode(this.clickedNode);
315
+ }
316
+
279
317
  this.synthesizer.speakSsmlAsync(this.buildSSML(this.textToRead),
280
318
  () => {
281
319
  this.synthesizer.close();
@@ -287,6 +325,295 @@ export class TextToSpeech {
287
325
  });
288
326
  }
289
327
 
328
+ collectPlaybackChain(node: any) {
329
+ const chain = [];
330
+ const seenNodeIds = new Set<string>();
331
+ let currentNode = node;
332
+ let offset = 0;
333
+
334
+ while (currentNode && !seenNodeIds.has(currentNode.id)) {
335
+ seenNodeIds.add(currentNode.id);
336
+
337
+ const attr = currentNode.attributes.getNamedItem('co-tts.text') ?? currentNode.attributes.getNamedItem('co-tts');
338
+ const text = this.getNodeText(currentNode, attr);
339
+
340
+ if (text === '') {
341
+ break;
342
+ }
343
+
344
+ const highlightDiv = this.getHighlightDivForNode(currentNode);
345
+
346
+ chain.push({
347
+ node: currentNode,
348
+ text,
349
+ start: offset,
350
+ end: offset + text.length,
351
+ highlightDiv,
352
+ originalHighlightDivInnerHTML: highlightDiv?.innerHTML ?? '',
353
+ });
354
+
355
+ offset += text.length + 2;
356
+
357
+ if (!currentNode.hasAttribute('co-tts.next')) {
358
+ break;
359
+ }
360
+
361
+ currentNode = document.getElementById(currentNode.getAttribute('co-tts.next'));
362
+ }
363
+
364
+ return chain;
365
+ }
366
+
367
+ preparePlaybackChain(chain: any[]) {
368
+ this.playbackSegments = chain;
369
+ this.textToRead = chain.map((segment) => segment.text).join('. ');
370
+ this.highlightDiv = undefined;
371
+ this.originalHighlightDivInnerHTML = '';
372
+ this.wordEncounters = [];
373
+ this.previousWordBoundary = undefined;
374
+ this.prevTextOffset = 0;
375
+ this.currentWord = '';
376
+ this.currentOffset = 0;
377
+ this.wordBoundaryOffset = 0;
378
+ }
379
+
380
+ getHighlightDivForNode(node: any) {
381
+ if (!node.hasAttribute('co-tts.highlight')) {
382
+ return undefined;
383
+ }
384
+
385
+ if (node.attributes.getNamedItem('co-tts.highlight')?.value !== '') {
386
+ return document.getElementById(node.attributes.getNamedItem('co-tts.highlight').value);
387
+ }
388
+
389
+ return node;
390
+ }
391
+
392
+ resetPlaybackSegments() {
393
+ this.playbackSegments.forEach((segment) => {
394
+ if (segment.highlightDiv) {
395
+ segment.highlightDiv.innerHTML = segment.originalHighlightDivInnerHTML;
396
+ }
397
+ });
398
+ }
399
+
400
+ updateChainedHighlight(wordBoundary: any) {
401
+ if (~['.', ',', '!', '?', '*', '(', ')', '&', '\\', '/', '^', '[', ']', '<', '>', ':'].indexOf(wordBoundary.text)) {
402
+ wordBoundary = this.previousWordBoundary ?? undefined;
403
+ }
404
+
405
+ if (!wordBoundary) {
406
+ this.resetPlaybackSegments();
407
+
408
+ return;
409
+ }
410
+
411
+ const segment = this.playbackSegments.find((candidate) => (
412
+ wordBoundary.textOffset >= candidate.start && wordBoundary.textOffset < candidate.end
413
+ ));
414
+
415
+ this.resetPlaybackSegments();
416
+
417
+ if (!segment?.highlightDiv) {
418
+ this.previousWordBoundary = wordBoundary;
419
+
420
+ return;
421
+ }
422
+
423
+ const relativeTextOffset = wordBoundary.textOffset - segment.start;
424
+ const currentOffset = this.getPosition(segment.originalHighlightDivInnerHTML, wordBoundary.text, relativeTextOffset);
425
+
426
+ if (currentOffset === Number.MAX_SAFE_INTEGER) {
427
+ this.previousWordBoundary = wordBoundary;
428
+
429
+ return;
430
+ }
431
+
432
+ const startOfString = segment.originalHighlightDivInnerHTML.substring(0, currentOffset);
433
+ const endOffset = currentOffset + wordBoundary.wordLength;
434
+ const endOfString = segment.originalHighlightDivInnerHTML.substring(endOffset);
435
+
436
+ segment.highlightDiv.innerHTML = `
437
+ ${startOfString}<mark class='co-tts-highlight'>${wordBoundary.text}</mark>${endOfString}
438
+ `;
439
+ this.previousWordBoundary = wordBoundary;
440
+ }
441
+
442
+ getNodeText(node: any, attr?: Attr | null) {
443
+ if (attr && attr.value !== '') {
444
+ return attr.value;
445
+ }
446
+
447
+ if (node.hasAttribute('co-tts.text') && node.getAttribute('co-tts.text') !== '') {
448
+ return node.getAttribute('co-tts.text') ?? '';
449
+ }
450
+
451
+ return node.innerText;
452
+ }
453
+
454
+ getPrefetchKey(node: any, text: string) {
455
+ return [
456
+ node.id,
457
+ text,
458
+ this.voice,
459
+ this.rate,
460
+ this.pitch,
461
+ this.url,
462
+ ].join('|');
463
+ }
464
+
465
+ clearPrefetchedAudio() {
466
+ this.prefetchedAudio.forEach((prefetch) => {
467
+ if (prefetch.url) {
468
+ URL.revokeObjectURL(prefetch.url);
469
+ }
470
+ });
471
+
472
+ this.prefetchedAudio.clear();
473
+ this.prefetchPromises.clear();
474
+ }
475
+
476
+ async prefetchNextNode(node: any) {
477
+ if (!node || !node.hasAttribute('co-tts.next')) {
478
+ return;
479
+ }
480
+
481
+ const nextNode = document.getElementById(node.getAttribute('co-tts.next'));
482
+
483
+ if (!nextNode) {
484
+ return;
485
+ }
486
+
487
+ const attr = nextNode.attributes.getNamedItem('co-tts.text') ?? nextNode.attributes.getNamedItem('co-tts');
488
+ const text = this.getNodeText(nextNode, attr);
489
+
490
+ if (text === '') {
491
+ return;
492
+ }
493
+
494
+ const key = this.getPrefetchKey(nextNode, text);
495
+
496
+ if (this.prefetchedAudio.has(key) || this.prefetchPromises.has(key)) {
497
+ return;
498
+ }
499
+
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);
538
+ });
539
+ }).finally(() => {
540
+ this.prefetchPromises.delete(key);
541
+ });
542
+
543
+ this.prefetchPromises.set(key, prefetchPromise);
544
+ }
545
+
546
+ async playPrefetchedNode(node: any) {
547
+ const attr = node.attributes.getNamedItem('co-tts.text') ?? node.attributes.getNamedItem('co-tts');
548
+ const text = this.getNodeText(node, attr);
549
+ const key = this.getPrefetchKey(node, text);
550
+ const prefetch = this.prefetchedAudio.get(key) ?? await this.prefetchPromises.get(key);
551
+
552
+ if (!prefetch) {
553
+ return false;
554
+ }
555
+
556
+ this.prefetchedAudio.delete(key);
557
+ this.clickedNode = node;
558
+ this.textToRead = prefetch.text;
559
+ this.wordBoundryList = prefetch.wordBoundryList;
560
+ this.wordEncounters = [];
561
+ this.previousWordBoundary = undefined;
562
+ this.prevTextOffset = 0;
563
+ this.currentWord = '';
564
+ this.currentOffset = 0;
565
+ this.wordBoundaryOffset = 0;
566
+
567
+ if (node.hasAttribute('co-tts.highlight')) {
568
+ if (node.attributes.getNamedItem('co-tts.highlight')?.value !== '') {
569
+ const newReferenceDiv = document.getElementById(node.attributes.getNamedItem('co-tts.highlight').value);
570
+
571
+ this.highlightDiv = newReferenceDiv;
572
+ if (newReferenceDiv !== null) {
573
+ this.originalHighlightDivInnerHTML = newReferenceDiv.innerHTML;
574
+ }
575
+ } else {
576
+ this.highlightDiv = node;
577
+ this.originalHighlightDivInnerHTML = node.innerHTML;
578
+ }
579
+ }
580
+
581
+ await this.createInterval();
582
+
583
+ const audio = new Audio(prefetch.url);
584
+ this.activePrefetchedAudioUrl = prefetch.url;
585
+ this.player = audio;
586
+
587
+ audio.addEventListener('play', () => {
588
+ document.dispatchEvent(new CustomEvent('COAzureTTSStartedPlaying', {}));
589
+ }, {once: true});
590
+
591
+ audio.addEventListener('ended', async () => {
592
+ this.stopPlayer();
593
+
594
+ if (this.clickedNode.hasAttribute('co-tts.next')) {
595
+ const nextNode = document.getElementById(this.clickedNode.getAttribute('co-tts.next'));
596
+
597
+ if (nextNode && await this.playPrefetchedNode(nextNode)) {
598
+ return;
599
+ }
600
+
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'));
605
+ }
606
+ } else {
607
+ document.dispatchEvent(new CustomEvent('COAzureTTSFinishedPlaying', {}));
608
+ }
609
+ }, {once: true});
610
+
611
+ this.prefetchNextNode(node);
612
+ await audio.play();
613
+
614
+ return true;
615
+ }
616
+
290
617
  async clearInterval() {
291
618
  clearInterval(this.interval);
292
619
  }
@@ -305,6 +632,12 @@ export class TextToSpeech {
305
632
  }
306
633
 
307
634
  if (wordBoundary !== undefined) {
635
+ if (this.playbackSegments.length > 0) {
636
+ this.updateChainedHighlight(wordBoundary);
637
+
638
+ return;
639
+ }
640
+
308
641
  if (~['.', ',', '!', '?', '*', '(', ')', '&', '\\', '/', '^', '[', ']', '<', '>', ':']
309
642
  .indexOf(wordBoundary.text)) {
310
643
  wordBoundary = this.previousWordBoundary ?? undefined;