PageSourceSearch

https://www.viernheim.de/typo3conf/ext/ns_t3as/Resources/Public/Ja…/overview-speech-highlight.js?1786116632

js viernheim.de collected 2026-10-02 10:43:29 UTC 40,577 bytes, 1,356 lines download raw bytes

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.