@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/dist/co-azure-tts.es.js +267 -0
- package/dist/co-azure-tts.umd.js +10 -8
- package/package.json +1 -1
- package/src/TextToSpeech.ts +333 -0
package/package.json
CHANGED
package/src/TextToSpeech.ts
CHANGED
|
@@ -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;
|