1(function (global) { 2 const WORD_RE = /\S+/g; 3 const DEFAULT_SPEECH_RATE = 0.92; 4 const DEFAULT_SPEECH_START_DELAY_MS = 280; 5 const DEFAULT_SENTENCE_PAUSE_MS = 1000; 6 const SPEECH_BLOCK_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,blockquote'; 7 8 function getSpeechStartDelayMs() { 9 const custom = parseInt(document.documentElement.dataset.nsT3asSpeechStartDelay || '', 10); 10 return Number.isFinite(custom) && custom >= 0 ? custom : DEFAULT_SPEECH_START_DELAY_MS; 11 } 12 13 function getSentencePauseMs() { 14 const custom = parseInt(document.documentElement.dataset.nsT3asSpeechSentencePause || '', 10); 15 return Number.isFinite(custom) && custom >= 0 ? custom : DEFAULT_SENTENCE_PAUSE_MS; 16 } 17 18 function normalizeSpeechText(text) { 19 return (text || '').replace(/\s+/g, ' ').trim(); 20 } 21 22 function parseWords(text) { 23 const words = []; 24 const normalized = normalizeSpeechText(text); 25 if (!normalized) { 26 return words; 27 } 28 let match; 29 while ((match = WORD_RE.exec(normalized)) !== null) { 30 words.push({ 31 start: match.index, 32 end: match.index + match[0].length, 33 index: words.length, 34 text: match[0], 35 }); 36 } 37 return words; 38 } 39 40 function collectTextNodes(root) { 41 const nodes = []; 42 if (!root) { 43 return nodes; 44 } 45 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { 46 acceptNode(node) { 47 const parent = node.parentElement; 48 if ( 49 !parent || 50 parent.closest( 51 '.ns-t3as-rating-row, .ns-t3as-source-links, .ns-t3as-speak-word, .ns-t3as-stream-wait, .ns-t3as-loader' 52 ) 53 ) { 54 return NodeFilter.FILTER_REJECT; 55 } 56 if (!node.textContent || !/\S/.test(node.textContent)) { 57 return NodeFilter.FILTER_REJECT; 58 } 59 return NodeFilter.FILTER_ACCEPT; 60 }, 61 }); 62 while (walker.nextNode()) { 63 nodes.push(walker.currentNode); 64 } 65 return nodes; 66 } 67 68 let activeSpeech = null; 69 const retainedUtterances = new Set(); 70 let chromeKeepAliveTimer = null; 71 72 function isChromeBrowser() { 73 if (typeof navigator === 'undefined') { 74 return false; 75 } 76 return /Chrome/i.test(navigator.userAgent) && !/Edg|OPR/i.test(navigator.userAgent); 77 } 78 79 function isAndroidBrowser() { 80 return typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent); 81 } 82 83 /** Network/Google voices in Chrome do not fire onboundary â highlight must use timers. */ 84 function voiceSupportsWordBoundaries(voice) { 85 if (!voice) { 86 return false; 87 } 88 if (voice.localService) { 89 return true; 90 } 91 if (/Google/i.test(voice.name || '')) { 92 return false; 93 } 94 return false; 95 } 96 97 function isSpeechDebugEnabled() { 98 if (typeof document === 'undefined') { 99 return false; 100 } 101 const root = document.documentElement; 102 if (root.dataset.nsT3asSpeechDebug === '1') { 103 return true; 104 } 105 try { 106 const params = new URLSearchParams(window.location.search); 107 return params.has('speechDebug') || params.get('speechDebug') === '1'; 108 } catch (e) { 109 return false; 110 } 111 } 112 113 function getSpeechDebugPanel(listenBtn) { 114 if (!listenBtn) { 115 return null; 116 } 117 return listenBtn.closest('.ai-overview__voice')?.querySelector('.ns-t3as-speech-debug') || null; 118 } 119 120 function setSpeechDebugField(panel, key, value) { 121 if (!panel) { 122 return; 123 } 124 const el = panel.querySelector(`[data-speech-debug="${key}"]`); 125 if (el) { 126 el.textContent = value == null || value === '' ? 'â' : String(value); 127 } 128 } 129 130 function updateSpeechDebugPanel(listenBtn, patch) { 131 if (!isSpeechDebugEnabled()) { 132 return; 133 } 134 const panel = getSpeechDebugPanel(listenBtn); 135 if (!panel) { 136 return; 137 } 138 panel.hidden = false; 139 if (patch && typeof patch === 'object') { 140 Object.entries(patch).forEach(([key, value]) => { 141 if (key !== 'console') { 142 setSpeechDebugField(panel, key, value); 143 } 144 }); 145 if (patch.console) { 146 console.info('[ns_t3as speech debug]', patch.console); 147 } 148 } 149 } 150 151 function hideSpeechDebugPanel(listenBtn) { 152 if (!isSpeechDebugEnabled()) { 153 return; 154 } 155 const panel = getSpeechDebugPanel(listenBtn); 156 if (panel) { 157 panel.hidden = true; 158 } 159 } 160 161 function retainUtterance(utterance) { 162 if (utterance) { 163 retainedUtterances.add(utterance); 164 } 165 } 166 167 function releaseUtterance(utterance) { 168 if (utterance) { 169 retainedUtterances.delete(utterance); 170 } 171 } 172 173 function startChromeKeepAlive(onPausePulse) {
174 if (!isChromeBrowser() || isAndroidBrowser() || typeof window === 'undefined') { 175 return; 176 } 177 stopChromeKeepAlive(); 178 const synth = window.speechSynthesis; 179 if (!synth) { 180 return; 181 } 182 chromeKeepAliveTimer = window.setInterval(() => { 183 if (!activeSpeech || activeSpeech.finished) { 184 stopChromeKeepAlive(); 185 return; 186 } 187 if (!synth.speaking && !synth.pending) { 188 stopChromeKeepAlive(); 189 return; 190 } 191 const pulseStart = performance.now(); 192 try { 193 synth.pause(); 194 synth.resume(); 195 } catch (e) { 196 /* ignore */ 197 } 198 onPausePulse?.(performance.now() - pulseStart); 199 }, 10000); 200 } 201 202 function stopChromeKeepAlive() { 203 if (chromeKeepAliveTimer) { 204 window.clearInterval(chromeKeepAliveTimer); 205 chromeKeepAliveTimer = null; 206 } 207 } 208 209 /** ~165 WPM at rate 1.0 â 13.5 spoken characters per second (incl. short pauses). */ 210 const BASE_CHARS_PER_SECOND = 13.5; 211 212 function getSpeechRateMultiplier(rate) { 213 const rateRaw = rate || DEFAULT_SPEECH_RATE; 214 return Number.isFinite(rateRaw) ? Math.min(1.15, Math.max(0.8, rateRaw)) : DEFAULT_SPEECH_RATE; 215 } 216 217 function getSpeechRateAdjust() { 218 try { 219 const stored = parseFloat(sessionStorage.getItem('nsT3asSpeechRateAdjust') || '1'); 220 return Number.isFinite(stored) && stored > 0.5 && stored < 2 ? stored : 1; 221 } catch (e) { 222 return 1; 223 } 224 } 225 226 function storeSpeechRateAdjust(ratio) { 227 if (!Number.isFinite(ratio) || ratio <= 0.5 || ratio >= 2) { 228 return; 229 } 230 try { 231 const prev = getSpeechRateAdjust(); 232 const blended = prev * 0.65 + ratio * 0.35; 233 sessionStorage.setItem('nsT3asSpeechRateAdjust', String(Math.round(blended * 1000) / 1000)); 234 } catch (e) { 235 /* ignore */ 236 } 237 } 238 239 function getCharsPerSecond(rate) { 240 const custom = parseFloat(document.documentElement.dataset.nsT3asSpeechCharsPerSec || ''); 241 if (Number.isFinite(custom) && custom > 0) { 242 return custom * getSpeechRateMultiplier(rate); 243 } 244 return BASE_CHARS_PER_SECOND * getSpeechRateMultiplier(rate) * getSpeechRateAdjust(); 245 } 246 247 function calibrateSpeechTimingFromRun(startMs, estimatedMs) { 248 if (!startMs || !estimatedMs || estimatedMs < 1000) { 249 return; 250 }
251 const actualMs = performance.now() - startMs; 252 if (actualMs < 1000) { 253 return; 254 } 255 storeSpeechRateAdjust(estimatedMs / actualMs); 256 } 257 258 function buildWordTimeline(words, startWordIndex, spanCount, rate, segmentEndWordIndex) { 259 const timeline = []; 260 let totalWeight = 0; 261 const from = Math.max(0, startWordIndex); 262 const segmentEnd = 263 segmentEndWordIndex == null ? spanCount - 1 : Math.min(segmentEndWordIndex, spanCount - 1); 264 const to = Math.min(words.length, spanCount, segmentEnd + 1); 265 for (let i = from; i < to; i += 1) { 266 const word = words[i]; 267 const weight = Math.max(1, word.text.length) + 0.28; 268 timeline.push({ index: i, weight, atWeight: totalWeight }); 269 totalWeight += weight; 270 } 271 const charsPerSecond = getCharsPerSecond(rate); 272 const estimatedMs = totalWeight > 0 ? (totalWeight / charsPerSecond) * 1000 : 0; 273 return { timeline, totalWeight, estimatedMs, charsPerSecond }; 274 } 275 276 function wordIndexAtProgress(timeline, totalWeight, progress) { 277 if (!timeline.length || totalWeight <= 0) { 278 return -1; 279 } 280 const target = Math.max(0, Math.min(1, progress)) * totalWeight; 281 for (let i = timeline.length - 1; i >= 0; i -= 1) { 282 if (target >= timeline[i].atWeight) { 283 return timeline[i].index; 284 } 285 } 286 return timeline[0].index; 287 } 288 289 function createProgressHighlighter(options) { 290 const { 291 words, 292 spanCount, 293 startWordIndex, 294 segmentEndWordIndex, 295 rate, 296 onAdvance, 297 } = options; 298 const { timeline, totalWeight, estimatedMs } = buildWordTimeline( 299 words, 300 startWordIndex, 301 spanCount, 302 rate, 303 segmentEndWordIndex 304 ); 305 let startMs = 0; 306 let compensatedMs = 0; 307 let tickTimer = null; 308 let lastEmitted = startWordIndex > 0 ? startWordIndex - 1 : -1; 309 310 const clearTick = () => { 311 if (tickTimer) { 312 clearInterval(tickTimer); 313 tickTimer = null; 314 } 315 }; 316 317 const emitUpTo = (idx) => { 318 if (idx < 0 || idx >= spanCount || idx <= lastEmitted) { 319 return; 320 } 321 lastEmitted = idx; 322 onAdvance(idx); 323 }; 324 325 let driverEnabled = true; 326 327 const tick = () => { 328 if (!driverEnabled || !activeSpeech || activeSpeech.finished) { 329 if (!driverEnabled) { 330 return; 331 } 332 clearTick(); 333 return; 334 } 335 const elapsedMs = performance.now() - startMs + compensatedMs; 336 if (elapsedMs < 0) { 337 return; 338 } 339 const progress = estimatedMs > 0 ? elapsedMs / estimatedMs : 0; 340 const idx = wordIndexAtProgress(timeline, totalWeight, progress); 341 if (idx >= 0) { 342 emitUpTo(idx); 343 } 344 }; 345 346 return { 347 start(alreadyElapsedMs = 0) { 348 clearTick(); 349 driverEnabled = true; 350 startMs = performance.now(); 351 const startDelay = startWordIndex > 0 ? 0 : -getSpeechStartDelayMs(); 352 compensatedMs = startDelay + (Number.isFinite(alreadyElapsedMs) ? alreadyElapsedMs : 0); 353 lastEmitted = startWordIndex > 0 ? startWordIndex - 1 : -1; 354 tickTimer = setInterval(tick, 45); 355 }, 356 stopDriver() { 357 driverEnabled = false; 358 clearTick(); 359 }, 360 compensatePause(durationMs) { 361 if (Number.isFinite(durationMs) && durationMs > 0) { 362 compensatedMs -= durationMs; 363 } 364 }, 365 pause() { 366 clearTick(); 367 }, 368 resume() { 369 if (tickTimer || !driverEnabled) { 370 return; 371 } 372 startMs = performance.now(); 373 tick(); 374 tickTimer = setInterval(tick, 45); 375 }, 376 clear() { 377 driverEnabled = false; 378 clearTick(); 379 }, 380 estimatedMs, 381 }; 382 } 383 384 function updateListenControlUi() {} 385 386 function resetListenButtonEl(btn) { 387 if (!btn) { 388 return; 389 } 390 const listenLabel = btn.dataset.labelListen || 'Listen'; 391 btn.classList.remove('is-speaking', 'is-paused'); 392 btn.setAttribute('aria-label', listenLabel); 393 btn.setAttribute('aria-pressed', 'false'); 394 btn.dataset.tooltip = listenLabel; 395 updateListenControlUi(btn); 396 } 397 398 function registerActiveSpeech(listenBtn, resultsEl, utterance, normalizedText, onFinish) { 399 retainUtterance(utterance); 400 activeSpeech = { 401 listenBtn, 402 resultsEl, 403 utterance, 404 normalizedText: normalizedText || '', 405 onFinish: typeof onFinish === 'function' ? onFinish : null, 406 resumeCharIndex: 0, 407 resumeWordIndex: 0, 408 userPaused: false, 409 finished: false, 410 segments: null, 411 currentSegmentIndex: 0, 412 segmentPauseMs: getSentencePauseMs(), 413 segmentPauseTimer: null, 414 }; 415 wireUtteranceEndHandlers(utterance); 416 } 417 418 function clearSegmentPauseTimer() { 419 if (activeSpeech?.segmentPauseTimer) { 420 clearTimeout(activeSpeech.segmentPauseTimer); 421 activeSpeech.segmentPauseTimer = null; 422 } 423 } 424
425 function findSegmentIndexForWord(segments, wordIndex) { 426 if (!segments?.length) { 427 return 0; 428 } 429 for (let i = 0; i < segments.length; i += 1) { 430 const segment = segments[i]; 431 if (wordIndex >= segment.startWordIndex && wordIndex <= segment.endWordIndex) { 432 return i; 433 } 434 if (wordIndex < segment.startWordIndex) { 435 return i; 436 } 437 } 438 return segments.length - 1; 439 } 440 441 function finishOverviewSpeechSession() { 442 const onFinish = activeSpeech?.onFinish; 443 stopOverviewSpeech(); 444 onFinish?.(); 445 } 446 447 function speakCurrentSegment() { 448 const session = activeSpeech; 449 if (!session || session.finished || session.userPaused) { 450 return; 451 } 452 const segment = session.segments?.[session.currentSegmentIndex]; 453 if (!segment) { 454 finishOverviewSpeechSession(); 455 return; 456 } 457 458 clearSegmentPauseTimer(); 459 const utterance = createOverviewUtterance(segment.text); 460 attachUtteranceHighlight(utterance, session.resultsEl, session.normalizedText, { 461 charOffset: segment.startChar, 462 startWordIndex: segment.startWordIndex, 463 speechTextOverride: segment.text, 464 segmentEndWordIndex: segment.endWordIndex, 465 preserveWrap: true, 466 }); 467 session.utterance = utterance; 468 retainUtterance(utterance); 469 wireUtteranceEndHandlers(utterance); 470 startOverviewSpeech(utterance); 471 } 472 473 function scheduleNextSegment() { 474 const session = activeSpeech; 475 if (!session || session.finished || session.userPaused) { 476 return; 477 } 478 479 session.resultsEl?._nsT3asSpeechHighlightSession?.pause?.(); 480 session.resultsEl?.querySelectorAll('.ns-t3as-speak-word.is-active').forEach((el) => { 481 el.classList.remove('is-active'); 482 }); 483 484 const synth = typeof window !== 'undefined' ? window.speechSynthesis : null; 485 if (synth) { 486 try { 487 synth.cancel(); 488 } catch (e) { 489 /* ignore */ 490 } 491 } 492 releaseUtterance(session.utterance); 493 session.utterance = null; 494 495 session.currentSegmentIndex += 1; 496 if (session.currentSegmentIndex >= session.segments.length) { 497 finishOverviewSpeechSession(); 498 return; 499 } 500 501 clearSegmentPauseTimer(); 502 session.segmentPauseTimer = window.setTimeout(() => { 503 session.segmentPauseTimer = null; 504 if (!session.finished && !session.userPaused) { 505 speakCurrentSegment(); 506 } 507 }, session.segmentPauseMs || getSentencePauseMs()); 508 } 509 510 function beginOverviewSpeechSession(listenBtn, resultsEl, listenText, onFinish) { 511 const spans = wrapSpeechWords(resultsEl); 512 const normalizedText = buildSpeechTextFromSpans(spans) || normalizeSpeechText(listenText); 513 const words = parseWords(normalizedText); 514 const segments = buildBlockSegments(spans, words, normalizedText); 515 if (!segments.length || !normalizedText) { 516 return null; 517 } 518 519 activeSpeech = { 520 listenBtn, 521 resultsEl, 522 utterance: null, 523 normalizedText, 524 onFinish: typeof onFinish === 'function' ? onFinish : null, 525 resumeCharIndex: 0, 526 resumeWordIndex: 0, 527 userPaused: false, 528 finished: false, 529 segments, 530 currentSegmentIndex: 0, 531 segmentPauseMs: getSentencePauseMs(), 532 segmentPauseTimer: null, 533 }; 534 speakCurrentSegment(); 535 return normalizedText; 536 } 537 538 function clearActiveSpeech() { 539 activeSpeech = null; 540 } 541 542 function updateResumePosition(absoluteCharIndex, wordIndex) { 543 if (!activeSpeech) { 544 return; 545 } 546 if (absoluteCharIndex != null && absoluteCharIndex >= 0) { 547 activeSpeech.resumeCharIndex = absoluteCharIndex; 548 } 549 if (wordIndex != null && wordIndex >= 0) { 550 activeSpeech.resumeWordIndex = wordIndex; 551 } 552 } 553 554 function wireUtteranceEndHandlers(utterance) { 555 if (!utterance) { 556 return; 557 } 558 const handleEnd = () => { 559 if (!activeSpeech || activeSpeech.utterance !== utterance || activeSpeech.finished) { 560 return; 561 } 562 if (activeSpeech.userPaused) { 563 return; 564 } 565 if (activeSpeech.syncDriver === 'progress') { 566 calibrateSpeechTimingFromRun(activeSpeech.speechStartMs, activeSpeech.progressEstimatedMs); 567 } 568 if (activeSpeech.segments?.length) { 569 scheduleNextSegment(); 570 return; 571 } 572 finishOverviewSpeechSession(); 573 }; 574 utterance.onend = handleEnd; 575 utterance.onerror = handleEnd; 576 } 577 578 function isActiveForButton(listenBtn) { 579 return Boolean(activeSpeech && activeSpeech.listenBtn === listenBtn && !activeSpeech.finished); 580 } 581
582 function isPausedForButton(listenBtn) { 583 return Boolean(isActiveForButton(listenBtn) && activeSpeech.userPaused); 584 } 585 586 function isPlayingForButton(listenBtn) { 587 return Boolean(isActiveForButton(listenBtn) && !activeSpeech.userPaused); 588 } 589 590 function capturePausePositionFromDom() { 591 if (!activeSpeech?.resultsEl || !activeSpeech.normalizedText) { 592 return; 593 } 594 const active = activeSpeech.resultsEl.querySelector('.ns-t3as-speak-word.is-active'); 595 if (!active) { 596 return; 597 } 598 const wordIndex = parseInt(active.dataset.speechIndex || '', 10); 599 if (!Number.isFinite(wordIndex) || wordIndex < 0) { 600 return; 601 } 602 const words = parseWords(activeSpeech.normalizedText); 603 const word = words[wordIndex]; 604 if (word) { 605 updateResumePosition(word.start, wordIndex); 606 return; 607 } 608 updateResumePosition(null, wordIndex); 609 } 610 611 /** 612 * Chrome/Safari often drop speak() when called in the same tick as cancel(). 613 */ 614 function scheduleSpeakAfterCancel(synth, utterance) { 615 const speak = () => { 616 if (!activeSpeech || activeSpeech.finished || activeSpeech.utterance !== utterance) { 617 return; 618 } 619 activeSpeech.userPaused = false; 620 if (synth.getVoices().length) { 621 synth.speak(utterance); 622 return; 623 } 624 synth.addEventListener( 625 'voiceschanged', 626 () => { 627 if (!activeSpeech || activeSpeech.finished || activeSpeech.utterance !== utterance) { 628 return; 629 } 630 synth.speak(utterance); 631 }, 632 { once: true } 633 ); 634 }; 635 try { 636 synth.cancel(); 637 } catch (e) { 638 /* ignore */ 639 } 640 window.setTimeout(speak, 80); 641 } 642 643 function pauseOverviewSpeech() { 644 if (!activeSpeech) { 645 return false; 646 } 647 capturePausePositionFromDom(); 648 activeSpeech.userPaused = true; 649 clearSegmentPauseTimer(); 650 stopChromeKeepAlive(); 651 const synth = typeof window !== 'undefined' ? window.speechSynthesis : null; 652 if (synth) { 653 try { 654 synth.cancel(); 655 } catch (e) { 656 /* ignore */ 657 } 658 } 659 activeSpeech.resultsEl?._nsT3asSpeechHighlightSession?.pause?.(); 660 return true; 661 } 662 663 function resumeFromSavedPosition() { 664 const session = activeSpeech; 665 if (!session?.normalizedText) { 666 return false; 667 } 668 const startWordIndex = session.resumeWordIndex || 0; 669 const words = parseWords(session.normalizedText); 670 const startWord = words[startWordIndex]; 671 if (!startWord) { 672 return false; 673 } 674 675 const synth = typeof window !== 'undefined' ? window.speechSynthesis : null; 676 if (!synth) { 677 return false; 678 } 679 680 if (session.segments?.length) { 681 const segmentIndex = findSegmentIndexForWord(session.segments, startWordIndex); 682 const segment = session.segments[segmentIndex]; 683 if (!segment) { 684 return false; 685 } 686 session.currentSegmentIndex = segmentIndex; 687 const remaining = session.normalizedText.slice(startWord.start).trimStart(); 688 if (!remaining) { 689 return false; 690 } 691 const charOffset = startWord.start; 692 const utterance = createOverviewUtterance(remaining); 693 attachUtteranceHighlight(utterance, session.resultsEl, session.normalizedText, { 694 charOffset, 695 startWordIndex, 696 speechTextOverride: remaining, 697 segmentEndWordIndex: segment.endWordIndex, 698 preserveWrap: true, 699 }); 700 session.utterance = utterance; 701 retainUtterance(utterance); 702 wireUtteranceEndHandlers(utterance); 703 scheduleSpeakAfterCancel(synth, utterance); 704 return true; 705 } 706 707 const startChar = session.resumeCharIndex || 0; 708 let remaining = session.normalizedText.slice(startChar); 709 const leadingTrim = remaining.length - remaining.trimStart().length; 710 remaining = remaining.trimStart(); 711 if (!remaining) { 712 return false; 713 } 714 const charOffset = startChar + leadingTrim; 715 716 const utterance = createOverviewUtterance(remaining); 717 attachUtteranceHighlight(utterance, session.resultsEl, session.normalizedText, { 718 charOffset, 719 startWordIndex, 720 speechTextOverride: remaining, 721 preserveWrap: true, 722 }); 723 session.utterance = utterance; 724 retainUtterance(utterance); 725 wireUtteranceEndHandlers(utterance); 726 scheduleSpeakAfterCancel(synth, utterance); 727 return true; 728 } 729 730 function resumeOverviewSpeech() { 731 if (!activeSpeech?.userPaused) { 732 return false;
733 } 734 activeSpeech.userPaused = false; 735 if (activeSpeech.segments?.length && !activeSpeech.utterance) { 736 speakCurrentSegment(); 737 return true; 738 } 739 return resumeFromSavedPosition(); 740 } 741 742 function stopOverviewSpeech() { 743 const synth = typeof window !== 'undefined' ? window.speechSynthesis : null; 744 const debugListenBtn = activeSpeech?.listenBtn; 745 clearSegmentPauseTimer(); 746 stopChromeKeepAlive(); 747 if (activeSpeech) { 748 activeSpeech.finished = true; 749 releaseUtterance(activeSpeech.utterance); 750 } 751 if (synth) { 752 synth.cancel(); 753 } 754 if (activeSpeech?.resultsEl) { 755 clearSpeechHighlights(activeSpeech.resultsEl); 756 delete activeSpeech.resultsEl._nsT3asSpeechHighlightSession; 757 } 758 if (typeof document !== 'undefined') { 759 document 760 .querySelectorAll('.ns-t3as-listen-btn.is-speaking, .ns-t3as-listen-btn.is-paused') 761 .forEach(resetListenButtonEl); 762 } 763 hideSpeechDebugPanel(debugListenBtn); 764 clearActiveSpeech(); 765 } 766 767 function clearSpeechHighlights(resultsEl) { 768 if (!resultsEl) { 769 return; 770 } 771 if (typeof resultsEl._nsT3asSpeechFallbackClear === 'function') { 772 resultsEl._nsT3asSpeechFallbackClear(); 773 delete resultsEl._nsT3asSpeechFallbackClear; 774 } 775 delete resultsEl._nsT3asSpeechHighlightSession; 776 resultsEl.querySelectorAll('.ns-t3as-speak-word.is-active').forEach((el) => { 777 el.classList.remove('is-active'); 778 }); 779 if (resultsEl.dataset.speechWordsWrapped === '1') { 780 resultsEl.querySelectorAll('.ns-t3as-speak-word').forEach((span) => { 781 span.replaceWith(document.createTextNode(span.textContent)); 782 }); 783 resultsEl.normalize(); 784 delete resultsEl.dataset.speechWordsWrapped; 785 } 786 } 787 788 function getSpeechContentRoot(resultsEl) { 789 return resultsEl?.querySelector?.('.ns-t3as-search-content') || resultsEl; 790 } 791 792 function getExistingSpeechSpans(resultsEl) { 793 if (!resultsEl || resultsEl.dataset.speechWordsWrapped !== '1') { 794 return []; 795 } 796 return Array.from(resultsEl.querySelectorAll('.ns-t3as-speak-word')); 797 } 798 799 function resolveSpeechBlockIndex(textNode, contentRoot, blockIndexByEl, blockCounterRef) { 800 const block = textNode.parentElement?.closest?.(SPEECH_BLOCK_SELECTOR); 801 const keyEl = block || contentRoot; 802 if (!blockIndexByEl.has(keyEl)) { 803 blockIndexByEl.set(keyEl, blockCounterRef.value); 804 blockCounterRef.value += 1; 805 } 806 return blockIndexByEl.get(keyEl); 807 } 808 809 function buildBlockSegments(spans, words, normalizedText) { 810 if (!spans.length || !words.length) { 811 return []; 812 } 813 const segments = []; 814 let blockStart = 0; 815 let currentBlock = spans[0]?.dataset?.speechBlock ?? '0'; 816 817 const pushSegment = (startIdx, endIdx) => { 818 if (startIdx > endIdx || startIdx >= words.length) { 819 return; 820 } 821 const startWord = words[startIdx]; 822 const endWord = words[Math.min(endIdx, words.length - 1)]; 823 if (!startWord || !endWord) { 824 return; 825 } 826 const text = normalizedText.slice(startWord.start, endWord.end).trim(); 827 if (!text) { 828 return; 829 } 830 segments.push({ 831 startWordIndex: startIdx, 832 endWordIndex: Math.min(endIdx, words.length - 1), 833 startChar: startWord.start, 834 endChar: endWord.end, 835 text, 836 }); 837 }; 838 839 for (let i = 1; i < spans.length; i += 1) { 840 const block = spans[i].dataset.speechBlock ?? '0'; 841 if (block !== currentBlock) { 842 pushSegment(blockStart, i - 1); 843 blockStart = i; 844 currentBlock = block; 845 } 846 } 847 pushSegment(blockStart, spans.length - 1); 848 return segments; 849 } 850 851 function wrapSpeechWords(resultsEl) { 852 if (!resultsEl) { 853 return []; 854 } 855 clearSpeechHighlights(resultsEl); 856 const contentRoot = getSpeechContentRoot(resultsEl); 857 const textNodes = collectTextNodes(contentRoot); 858 const spans = []; 859 let wordCounter = 0; 860 const blockIndexByEl = new WeakMap(); 861 const blockCounterRef = { value: 0 }; 862 863 textNodes.forEach((textNode) => { 864 const blockIndex = resolveSpeechBlockIndex(textNode, contentRoot, blockIndexByEl, blockCounterRef); 865 const parts = textNode.textContent.split(/(\s+)/); 866 const fragment = document.createDocumentFragment();
867 parts.forEach((part) => { 868 if (!part) { 869 return; 870 } 871 if (/^\s+$/.test(part)) { 872 fragment.appendChild(document.createTextNode(part)); 873 return; 874 } 875 const span = document.createElement('span'); 876 span.className = 'ns-t3as-speak-word'; 877 span.dataset.speechIndex = String(wordCounter); 878 span.dataset.speechBlock = String(blockIndex); 879 span.textContent = part; 880 fragment.appendChild(span); 881 spans.push(span); 882 wordCounter += 1; 883 }); 884 textNode.parentNode.replaceChild(fragment, textNode); 885 }); 886 887 resultsEl.dataset.speechWordsWrapped = '1'; 888 return spans; 889 } 890 891 function buildSpeechTextFromSpans(spans) { 892 if (!spans || !spans.length) { 893 return ''; 894 } 895 return normalizeSpeechText(Array.from(spans, (span) => span.textContent).join(' ')); 896 } 897 898 function isSpeechActive() { 899 return Boolean(activeSpeech && !activeSpeech.finished); 900 } 901 902 function isResultsStreaming(resultsEl) { 903 return Boolean(resultsEl?.querySelector?.('.ns-t3as-is-streaming')); 904 } 905 906 function findWordIndexAtChar(words, charIndex) { 907 if (!words.length || charIndex == null) { 908 return -1; 909 } 910 for (let i = 0; i < words.length; i += 1) { 911 if (charIndex >= words[i].start && charIndex < words[i].end) { 912 return words[i].index; 913 } 914 } 915 for (let i = words.length - 1; i >= 0; i -= 1) { 916 if (words[i].start <= charIndex) { 917 return words[i].index; 918 } 919 } 920 return -1; 921 } 922 923 /** 924 * Chrome often fires several boundary events inside one DOM token (e.g. "(CMS)", "TYPO3"). 925 * Map charIndex to a word, then only advance when the mapped word
925is ahead of the highlight. 926 */ 927 function resolveBoundaryWordIndex(words, absoluteCharIndex, lastHighlighted, spanCount) { 928 const mapped = findWordIndexAtChar(words, absoluteCharIndex); 929 if (mapped >= 0) { 930 if (mapped <= lastHighlighted) { 931 return -1; 932 } 933 if (mapped < spanCount) { 934 return mapped; 935 } 936 } 937 const next = lastHighlighted + 1; 938 return next < spanCount ? next : -1; 939 } 940 941 function highlightWordIndex(resultsEl, wordIndex) { 942 if (!resultsEl || wordIndex < 0) { 943 return; 944 } 945 resultsEl.querySelectorAll('.ns-t3as-speak-word.is-active').forEach((el) => { 946 el.classList.remove('is-active'); 947 }); 948 const next = resultsEl.querySelector(`.ns-t3as-speak-word[data-speech-index="${wordIndex}"]`); 949 if (!next) { 950 return; 951 } 952 next.classList.add('is-active'); 953 next.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); 954 } 955 956 function attachUtteranceHighlight(utterance, resultsEl, listenText, options) { 957 const opts = options || {}; 958 const charOffset = opts.charOffset || 0; 959 const startWordIndex = opts.startWordIndex || 0; 960 const segmentEndWordIndex = 961 opts.segmentEndWordIndex == null ? null : Math.max(0, opts.segmentEndWordIndex); 962 const spans = 963 opts.preserveWrap && resultsEl?.dataset?.speechWordsWrapped === '1' 964 ? getExistingSpeechSpans(resultsEl) 965 : wrapSpeechWords(resultsEl); 966 const spanCount = spans.length; 967 const domSpeechText = buildSpeechTextFromSpans(spans); 968 const normalizedText = domSpeechText || normalizeSpeechText(listenText); 969 const speechText = opts.speechTextOverride || normalizedText; 970 if (utterance && speechText) { 971 utterance.text = speechText; 972 } 973 const words = parseWords(normalizedText); 974 const useCharIndex = spanCount > 0 && words.length > 0; 975 let useBoundarySnap = voiceSupportsWordBoundaries(utterance?.voice); 976 let syncModeLabel = useBoundarySnap ? 'boundary' : 'progress'; 977 let syncDriver = useBoundarySnap ? 'waiting' : 'progress'; 978 let lastHighlighted = -1; 979 let lastBoundaryCharIndex = -1; 980 let boundarySeen = false; 981 let boundaryFallbackTimer = null; 982 let progressHighlighter = null; 983 let utteranceStartMs = 0; 984 const speechRate = utterance?.rate || DEFAULT_SPEECH_RATE; 985 986 const segmentEnd = 987 segmentEndWordIndex == null ? spanCount - 1 : Math.min(segmentEndWordIndex, spanCount - 1); 988 989 const advanceTo = (idx) => { 990 if (idx < 0 || idx >= spanCount || idx > segmentEnd || idx === lastHighlighted) { 991 return; 992 } 993 lastHighlighted = idx; 994 highlightWordIndex(resultsEl, idx); 995 updateResumePosition(null, idx); 996 if (spanCount > 0) { 997 updateSpeechDebugPanel(activeSpeech?.listenBtn, { 998 active: `${idx + 1}/${spanCount}`, 999 }); 1000 } 1001 }; 1002 1003 const ensureProgressHighlighter = () => { 1004 if (progressHighlighter) { 1005 return progressHighlighter; 1006 } 1007 progressHighlighter = createProgressHighlighter({ 1008 words, 1009 spanCount, 1010 startWordIndex, 1011 segmentEndWordIndex: segmentEnd, 1012 rate: speechRate, 1013 onAdvance: advanceTo, 1014 }); 1015 return progressHighlighter; 1016 }; 1017 1018 const clearBoundaryFallback = () => { 1019 if (boundaryFallbackTimer) { 1020 clearTimeout(boundaryFallbackTimer); 1021 boundaryFallbackTimer = null; 1022 } 1023 }; 1024 1025 const startProgressDriver = (reason, alreadyElapsedMs = 0) => { 1026 syncDriver = 'progress'; 1027 syncModeLabel = reason || 'progress'; 1028 if (activeSpeech) { 1029 activeSpeech.syncDriver = syncDriver; 1030 if (!activeSpeech.speechStartMs) { 1031 activeSpeech.speechStartMs = utteranceStartMs || performance.now(); 1032 } 1033 } 1034 updateSpeechDebugPanel(activeSpeech?.listenBtn, { sync: syncModeLabel }); 1035 const progress = ensureProgressHighlighter(); 1036 if (activeSpeech) { 1037 activeSpeech.progressEstimatedMs = progress.estimatedMs; 1038 } 1039 progress.start(alreadyElapsedMs); 1040 }; 1041 1042 const clearAllSync = () => { 1043 clearBoundaryFallback(); 1044 progressHighlighter?.clear(); 1045 }; 1046 1047 utterance.onstart = () => { 1048 utteranceStartMs = performance.now(); 1049 boundarySeen = false;
1050 lastBoundaryCharIndex = -1; 1051 clearAllSync(); 1052 1053 if (!utterance.voice) { 1054 const voice = pickSpeechVoice(utterance.lang || getSpeechLang()); 1055 if (voice) { 1056 utterance.voice = voice; 1057 } 1058 } 1059 useBoundarySnap = voiceSupportsWordBoundaries(utterance?.voice); 1060 syncDriver = useBoundarySnap ? 'waiting' : 'progress'; 1061 syncModeLabel = useBoundarySnap ? 'boundary' : 'progress'; 1062 1063 if (startWordIndex > 0) { 1064 lastHighlighted = startWordIndex - 1; 1065 advanceTo(startWordIndex); 1066 updateResumePosition(charOffset, startWordIndex); 1067 } else { 1068 lastHighlighted = -1; 1069 resultsEl.querySelectorAll('.ns-t3as-speak-word.is-active').forEach((el) => { 1070 el.classList.remove('is-active'); 1071 }); 1072 } 1073 1074 const progress = ensureProgressHighlighter(); 1075 if (useBoundarySnap) { 1076 boundaryFallbackTimer = window.setTimeout(() => { 1077 boundaryFallbackTimer = null; 1078 if (syncDriver === 'waiting') { 1079 const elapsed = utteranceStartMs ? performance.now() - utteranceStartMs : 0; 1080 startProgressDriver('progress (fallback)', elapsed); 1081 } 1082 }, 700); 1083 } else { 1084 progress.start(0); 1085 } 1086 startChromeKeepAlive((pauseMs) => { 1087 if (syncDriver === 'progress') { 1088 progress.compensatePause(pauseMs); 1089 } 1090 }); 1091 1092 if (activeSpeech) { 1093 activeSpeech.syncDriver = syncDriver; 1094 activeSpeech.speechStartMs = utteranceStartMs; 1095 activeSpeech.progressEstimatedMs = progress.estimatedMs; 1096 } 1097 1098 const voice = utterance?.voice; 1099 const timeline = buildWordTimeline(words, startWordIndex, spanCount, speechRate, segmentEnd); 1100 const segmentNo = activeSpeech?.segments?.length 1101 ? `${(activeSpeech.currentSegmentIndex || 0) + 1}/${activeSpeech.segments.length}` 1102 : 'â'; 1103 updateSpeechDebugPanel(activeSpeech?.listenBtn, { 1104 voice: voice?.name || '(default)', 1105 local: voice ? (voice.localService ? 'yes' : 'no') : 'â', 1106 sync: syncModeLabel, 1107 words: `${spanCount} spans / ${words.length} tokens / seg ${segmentNo}`, 1108 active: startWordIndex > 0 ? `${startWordIndex + 1}/${spanCount}` : `â/${spanCount}`, 1109 console: { 1110 voice: voice?.name, 1111 lang: voice?.lang || utterance?.lang, 1112 localService: voice?.localService, 1113 sync: syncModeLabel, 1114 chrome: isChromeBrowser(), 1115 rate: speechRate, 1116 charsPerSec: timeline.charsPerSecond, 1117 estimatedMs: Math.round(timeline.estimatedMs), 1118 spanCount, 1119 tokenCount: words.length, 1120 firstSpan: spans[startWordIndex]?.textContent || spans[0]?.textContent || '', 1121 firstToken: words[startWordIndex]?.text || words[0]?.text || '', 1122 startDelayMs: getSpeechStartDelayMs(), 1123 segmentPauseMs: getSentencePauseMs(), 1124 segment: segmentNo, 1125 }, 1126 }); 1127 }; 1128 1129 utterance.onboundary = (event) => { 1130 if (!useBoundarySnap) { 1131 return; 1132 } 1133 if (event.charIndex == null) { 1134 return; 1135 } 1136 if (event.name === 'sentence') { 1137 return; 1138 } 1139 1140 boundarySeen = true; 1141 if (syncDriver === 'waiting') { 1142 clearBoundaryFallback();
1143 syncDriver = 'boundary'; 1144 syncModeLabel = 'boundary (active)'; 1145 progressHighlighter?.stopDriver(); 1146 if (activeSpeech) { 1147 activeSpeech.syncDriver = syncDriver; 1148 } 1149 updateSpeechDebugPanel(activeSpeech?.listenBtn, { sync: syncModeLabel }); 1150 } 1151 1152 const absoluteCharIndex = event.charIndex + charOffset; 1153 const chromeSentenceReset = 1154 lastBoundaryCharIndex >= 0 && event.charIndex < lastBoundaryCharIndex; 1155 lastBoundaryCharIndex = event.charIndex; 1156 1157 let idx = -1; 1158 if (chromeSentenceReset) { 1159 idx = findWordIndexAtChar(words, absoluteCharIndex); 1160 if (idx < 0 || idx <= lastHighlighted) { 1161 idx = lastHighlighted + 1; 1162 } 1163 } else if (useCharIndex) { 1164 idx = resolveBoundaryWordIndex(words, absoluteCharIndex, lastHighlighted, spanCount); 1165 if (idx < 0) { 1166 return; 1167 } 1168 } 1169 1170 if (idx < 0 || idx >= spanCount) { 1171 return; 1172 } 1173 1174 updateResumePosition(absoluteCharIndex, idx); 1175 advanceTo(idx); 1176 }; 1177 1178 resultsEl._nsT3asSpeechFallbackClear = clearAllSync; 1179 resultsEl._nsT3asSpeechHighlightSession = { 1180 pause: () => { 1181 clearBoundaryFallback(); 1182 progressHighlighter?.pause(); 1183 }, 1184 resume: () => { 1185 if (syncDriver === 'progress') { 1186 progressHighlighter?.resume(); 1187 } 1188 }, 1189 }; 1190 1191 return normalizedText; 1192 } 1193 1194 function getSpeechLang() { 1195 const root = document.documentElement; 1196 return (root.getAttribute('lang') || root.dataset.nsT3asSpeechLang || 'en-US').trim(); 1197 } 1198 1199 function voicesForLang(voices, lang) { 1200 const normalized = (lang || 'en-US').trim().toLowerCase(); 1201 const prefix = normalized.split('-')[0]; 1202 return voices.filter((voice) => { 1203 const vlang = (voice.lang || '').toLowerCase(); 1204 return vlang === normalized || vlang.startsWith(`${prefix}-`) || vlang === prefix; 1205 }); 1206 } 1207 1208 function scoreVoiceQuality(voice) { 1209 const name = voice.name || ''; 1210 let score = 0; 1211 if (voice.localService) { 1212 score += 40; 1213 } 1214 if (/Microsoft/i.test(name) && voice.localService) { 1215 score += 20; 1216 } 1217 if ( 1218 /Samantha|Karen|Daniel|Moira|Victoria|Alex|Fred|Kate|Jenny|Aria|Guy/i.test(name) && 1219 voice.localService 1220 ) { 1221 score += 18; 1222 } 1223 if (/Google/i.test(name)) { 1224 score += voice.localService ? 12 : 4; 1225 } 1226 if (voice.default) { 1227 score += 3; 1228 } 1229 if (/eSpeak|Festival|Pico|Android|Compact|Whisper|Bells/i.test(name)) { 1230 score -= 25; 1231 } 1232 return score; 1233 } 1234 1235 function pickSpeechVoice(lang) { 1236 if (typeof window === 'undefined' || !window.speechSynthesis) { 1237 return null; 1238 } 1239 const voices = window.speechSynthesis.getVoices(); 1240 if (!voices.length) { 1241 return null; 1242 } 1243 1244 const voicePref = (document.documentElement.dataset.nsT3asSpeechVoice || '').trim(); 1245 if (voicePref) { 1246 const preferred = voices.find((voice) => voice.name.includes(voicePref)); 1247 if (preferred) { 1248 return preferred; 1249 } 1250 } 1251 1252 const candidates = voicesForLang(voices, lang || getSpeechLang()); 1253 if (!candidates.length) { 1254 return voices.find((voice) => voice.default) || voices[0]; 1255 } 1256 1257 const localCandidates = candidates.filter((voice) => voice.localService); 1258 const pool = localCandidates.length ? localCandidates : candidates; 1259 return pool.slice().sort((a, b) => scoreVoiceQuality(b) - scoreVoiceQuality(a))[0]; 1260 } 1261 1262 function createOverviewUtterance(text) { 1263 const lang = getSpeechLang(); 1264 const utterance = new SpeechSynthesisUtterance(text); 1265 utterance.lang = lang; 1266 if (typeof window !== 'undefined' && window.speechSynthesis) { 1267 window.speechSynthesis.getVoices(); 1268 } 1269 const voice = pickSpeechVoice(lang); 1270 if (voice) { 1271 utterance.voice = voice; 1272 } 1273 const rateRaw = parseFloat(document.documentElement.dataset.nsT3asSpeechRate || String(DEFAULT_SPEECH_RATE)); 1274 utterance.rate = Number.isFinite(rateRaw) 1275 ? Math.min(1.15, Math.max(0.8, rateRaw)) 1276 : DEFAULT_SPEECH_RATE; 1277 utterance.pitch = 1; 1278 utterance.volume = 1; 1279 return utterance; 1280 } 1281 1282 function startOverviewSpeech(utterance) { 1283 const synth = window.speechSynthesis; 1284 if (!synth) { 1285 return; 1286 } 1287 if (synth.speaking || synth.pending) { 1288 scheduleSpeakAfterCancel(synth, utterance); 1289 return; 1290 } 1291 const speak = () => { 1292 if (!activeSpeech || activeSpeech.finished || activeSpeech.utterance !== utterance) { 1293 return; 1294 } 1295 if (!utterance.voice) { 1296 const voice = pickSpeechVoice(utterance.lang || getSpeechLang()); 1297 if (voice) { 1298 utterance.voice = voice; 1299 } 1300 } 1301 activeSpeech.userPaused = false;
1302 retainUtterance(utterance); 1303 synth.speak(utterance); 1304 }; 1305 synth.getVoices(); 1306 if (synth.getVoices().length) { 1307 speak(); 1308 return; 1309 } 1310 synth.addEventListener('voiceschanged', speak, { once: true }); 1311 } 1312 1313 function warmUpSpeechVoices() { 1314 if (typeof window === 'undefined' || !window.speechSynthesis) { 1315 return; 1316 } 1317 window.speechSynthesis.getVoices(); 1318 window.speechSynthesis.addEventListener( 1319 'voiceschanged', 1320 () => { 1321 window.speechSynthesis.getVoices(); 1322 }, 1323 { once: false } 1324 ); 1325 } 1326 1327 warmUpSpeechVoices(); 1328 1329 global.NsT3asOverviewSpeechHighlight = { 1330 attachUtteranceHighlight, 1331 beginOverviewSpeechSession, 1332 buildBlockSegments, 1333 buildSpeechTextFromSpans, 1334 clearSpeechHighlights, 1335 createOverviewUtterance, 1336 highlightWordIndex, 1337 isActiveForButton, 1338 isPausedForButton, 1339 isPlayingForButton, 1340 isResultsStreaming, 1341 isSpeechActive, 1342 isSpeechDebugEnabled, 1343 normalizeSpeechText, 1344 pauseOverviewSpeech, 1345 pickSpeechVoice, 1346 registerActiveSpeech, 1347 resetListenButtonEl, 1348 updateListenControlUi, 1349 updateSpeechDebugPanel, 1350 resumeFromSavedPosition, 1351 resumeOverviewSpeech, 1352 startOverviewSpeech, 1353 stopOverviewSpeech, 1354 wrapSpeechWords, 1355 }; 1356})(typeof window !== 'undefined' ? window : globalThis);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.