PageSourceSearch

https://tobiasduerschmid.github.io/js/tts-player.js

js tobiasduerschmid.github.io collected 2026-10-03 10:05:39 UTC 20,956 bytes, 568 lines download raw bytes

1/**
2 * TTSPlayer — browser Text-to-Speech engine using Web Speech API.
3 *
4 * Automatically selects the best-sounding voice available
5 * (Enhanced/Premium on macOS, Google Neural on Chrome, etc.)
6 * and surfaces quality labels so users can pick wisely.
7 *
8 * Public API:
9 *   TTSPlayer.speak(input, { rate, onUpdate, onEnd })
10 *     input may be a plain string OR { text, markers } as produced by
11 *     TTSPlayer.extract(el). Markers (heading positions) enable heading
12 *     navigation; without them seekToHeading is a no-op.
13 *   TTSPlayer.pause() / resume() / stop()
14 *   TTSPlayer.setRate(n)            — restarts from last word boundary
15 *   TTSPlayer.setVoice(voice)       — SpeechSynthesisVoice object
16 *   TTSPlayer.seekRelative(seconds) — back/forward by ~seconds (rate-aware)
17 *   TTSPlayer.seekToHeading(dir)    — 'next' | 'prev', uses extracted markers
18 *   TTSPlayer.getMarkers()          — current extraction's markers (copy)
19 *   TTSPlayer.getVoices()           — sorted list: { voice, label, lang, score }
20 *   TTSPlayer.onVoicesReady(fn)     — call fn once voice list is populated
21 *   TTSPlayer.extract(el)           — { text, markers }, DOM-aware
22 *   TTSPlayer.extractText(el)       — plain text (alias for extract(el).text)
23 *   TTSPlayer.rateLabel()
24 */
25(function (window) {
26  'use strict';
27
28  var synth = window.speechSynthesis;
29  var _text = '';
30  var _offset = 0;
31  var _rate = 1.0;
32  var _voice = null;
33  var _speaking = false;
34  var _paused = false;
35  var _startTimer = null;    // pending setTimeout handle
36  var _epoch = 0;            // bumped on every _startFrom; stale handlers compare against this
37  var _onUpdate = null;
38  var _onEnd = null;
39  var _voicesReadyCbs = [];
40  var _voicesReady = false;
41
42  // Chrome's TTS engine silently fails when an utterance is too long
43  // (~32K chars) or runs longer than ~15s. Splitting the input into short
44  // pieces keeps each utterance under both limits. 180 chars stays under
45  // ~15s of audio at rate 1.0 (≈12.5 chars/sec) with safety margin.
46  var MAX_CHUNK_LENGTH = 180;
47  var _chunks = [];
48  var _chunkIdx = 0;
49  var _chunkBase = 0;        // global offset within _text where _chunks[0] begins
50  var _markers = [];         // [{ offset, type, level, label }]
51  // Estimated chars-per-second at rate=1.0 (English ~150 wpm × 5 chars/word ÷ 60s).
52  // Scales with _rate for seekRelative.
53  var BASE_CHARS_PER_SEC = 12.5;
54
55  /* ── Voice quality scoring ────────────────────────────────── */
56
57  function _scoreVoice(v) {
58    var n = v.name;
59    var score = 0;
60    // High-quality neural / enhanced voices
61    if (/premium/i.test(n))  score += 40;
62    if (/enhanced/i.test(n)) score += 30;
63    if (/neural/i.test(n))   score += 25;
64    if (/^google\s/i.test(n)) score += 20;  // Chrome's Google voices
65    // Locale preference
66    if (/^en-US/i.test(v.lang)) score += 10;
67    else if (/^en/i.test(v.lang)) score += 5;
68    // Local is usually better than network (and always available offline)
69    if (v.localService) score += 3;
70    return score;
71  }
72
73  // Label shown in the dropdown
74  function _friendlyName(v) {
75    var n = v.name;
76    // Skip URI-style internal names (e.g. "com.apple.voice.compact.en-US.Zoe")
77    if (/^com\.\w+\./.test(n)) return null;
78    n = n.replace(/^Microsoft\s+/, '');   // "Microsoft Zira …" → "Zira …"
79    n = n.replace(/\s+Desktop\b/, '');    // " Desktop" suffix
80    n = n.replace(/\s+-\s+.*$/, '');      // " - English (United States)"
81    n = n.replace(/\s*\(Standard\)\s*/i, ''); // "(Standard)" adds no info
82    return n.trim() || null;
83  }
84
85  // Quality badge appended to the label
86  function _badge(v) {
87    var n = v.name;
88    if (/premium/i.test(n))  return ' ★★★';
89    if (/enhanced/i.test(n)) return ' ★★';
90    if (/neural/i.test(n))   return ' ★★';
91    if (/^google\s/i.test(n)) return ' ★';
92    return '';
93  }
94
95  /* ── Voice list helpers ───────────────────────────────────── */
96
97  function _getEnglishVoices() {
98    if (!synth) return [];
99    return synth.getVoices().filter(function (v) { return /^en/i.test(v.lang); });
100  }
101
102  function _bestVoice(items) {
103    if (!items.length) return null;
104    return items.slice().sort(function (a, b) { return b.score - a.score; })[0];
105  }
106
107  /* ── Voice loading ────────────────────────────────────────── */
108
109  function _notifyVoicesReady() {
110    if (_voicesReady) return;
111    _voicesReady = true;
112    _voicesReadyCbs.forEach(function (fn) { fn(); });
113    _voicesReadyCbs = [];
114  }
115
116  if (synth) {
117    if (synth.getVoices().length > 0) {
118      _notifyVoicesReady();
119    } else {
120      synth.addEventListener('voiceschanged', function onVC() {
121        synth.removeEventListener('voiceschanged', onVC);
122        _notifyVoicesReady();
123      });
124      setTimeout(_notifyVoicesReady, 1000); // fallback
125    }
126  }
127
128  /* ── Playback ─────────────────────────────────────────────── */
129
130  // Split text into TTS-engine-sized pieces, preferring boundaries that
131  // already sound like natural pauses. Order of preference per chunk:
132  //   1. Sentence end (. ! ?)            — already a strong pause
133  //   2. Clause end   (; : ,)            — already a light pause
134  //   3. Word boundary (space)           — last resort, sounds clipped
135  // Falling back to a word boundary is what produces the awkward
136  // mid-sentence pauses; structural punctuation injected by _extract keeps
137  // most chunks splitting at tier 1 even on long pages.
138  // Chunks concatenate back to the input, so chunkStart math is exact.
139  function _splitIntoChunks(text) {
140    var chunks = [];
141    var remaining = text;
142    var minSplit = Math.floor(MAX_CHUNK_LENGTH / 3);
143    while (remaining.length > MAX_CHUNK_LENGTH) {
144      var win = remaining.slice(0, MAX_CHUNK_LENGTH);
145      var splitAt;
146
147      var lastSentence = Math.max(
148        win.lastIndexOf('. '),
149        win.lastIndexOf('! '),
150        win.lastIndexOf('? ')
151      );
152
153      if (lastSentence >= minSplit) {
154        splitAt = lastSentence + 2;
155      } else {
156        var lastClause = Math.max(
157          win.lastIndexOf('; '),
158          win.lastIndexOf(': '),
159          win.lastIndexOf(', ')
160        );
161        if (lastClause >= minSplit) {
162          splitAt = lastClause + 2;
163        } else {
164          var lastSpace = win.lastIndexOf(' ');
165          splitAt = lastSpace > 0 ? lastSpace + 1 : MAX_CHUNK_LENGTH;
166        }
167      }
168
169      chunks.push(remaining.slice(0, splitAt));
170      remaining = remaining.slice(splitAt);
171    }
172    if (remaining.length > 0) chunks.push(remaining);
173    return chunks;
174  }
175
176  function _startFrom(offset) {
177    if (!synth) return;
178
179    var remaining = _text.slice(offset);
180    if (!remaining.trim()) {
181      _speaking = false;
182      _paused = false;
183      if (_onUpdate) _onUpdate();
184      if (_onEnd) _onEnd();
185      return;
186    }
187
188    _chunks = _splitIntoChunks(remaining);
189    _chunkBase = offset;
190    _chunkIdx = 0;
191    // Bump the generation counter so any in-flight utterance handlers
192    // from the previous run treat themselves as stale and exit early.
193    _epoch++;
194
195    // The cancel+setTimeout dance works around a Chrome race where speak()
196    // called immediately after cancel() drops the new utterance. We only need
197    // it when something is actually playing — for a fresh start, going through
198    // setTimeout instead loses Chrome's transient user activation and the
199    // first speak() silently aborts.
200    if (_startTimer) { clearTimeout(_startTimer); _startTimer = null; }
201    var queueEpoch = _epoch;
202    if (synth.speaking || synth.pending) {
203      synth.cancel();
204      _startTimer = setTimeout(function () {
205        _startTimer = null;
206        _queueAllChunks(queueEpoch);
207      }, 50);
208    } else {
209      _queueAllChunks(queueEpoch);
210    }
211  }
212
213  // Queue every chunk on the SpeechSynthesis engine in one pass. Chrome
214  // hands utterances off to its TTS engine sequentially with very small
215  // gaps between them — far smaller than the latency of round-tripping
216  // through JS via onend → speak(), which is what made sentence-end
217  // chunk boundaries sound like long mid-sentence pauses.
218  function _queueAllChunks(epoch) {
219    if (!synth || epoch !== _epoch) return;
220    var chunkStart = _chunkBase;
221    for (var i = 0; i < _chunks.length; i++) {
222      _queueChunk(i, chunkStart, epoch);
223      chunkStart += _chunks[i].length;
224    }
225  }
226
227  function _queueChunk(idx, chunkStart, epoch) {
228    if (!synth || epoch !== _epoch) return;
229
230    var chunkText = _chunks[idx];
231
232    // Re-resolve voice by name at speak-time so the reference is never stale
233    // (Chrome rebuilds its voice list internally; stored object refs can go dead)
234    var activeVoice = null;
235    if (_voice) {
236      var fresh = synth.getVoices().filter(function (v) { return v.name === _voice.name; });
237      activeVoice = fresh[0] || null;
238    }
239
240    var utt = new SpeechSynthesisUtterance(chunkText);
241    utt.rate = _rate;
242    if (activeVoice) {
243      utt.voice = activeVoice;
244      utt.lang  = activeVoice.lang;
245    } else {
246      utt.lang = 'en-US';
247    }
248    var isLast = (idx === _chunks.length - 1);
249
250    utt.onboundary = function (e) {
251      if (epoch !== _epoch) return;
252      if (e.name === 'word') _offset = chunkStart + e.charIndex;
253    };
254    utt.onstart = function () {
255      if (epoch !== _epoch) return;
256      _chunkIdx = idx;
257      _offset = chunkStart;
258      _speaking = true;
259      _paused = false;
260      if (_onUpdate) _onUpdate();
261    };
262    utt.onend = function () {
263      // Stale onend (e.g. cancel during seek/setRate) — ignore so we don't
264      // fire the user's _onEnd callback for a session that's been replaced.
265      if (epoch !== _epoch) return;
266      if (!isLast) {
267        // Advance the recorded offset to the end of this chunk so a seek
268        // operation called in the gap before the next chunk's onstart
269        // fires (or in the absence of reliable onboundary events) reflects
270        // actual playback progress. Without this, "next heading" always
271        // jumps from offset 0.
272        _offset = chunkStart + chunkText.length;
273        return;
274      }
275      _speaking = false;
276      _paused = false;
277      _offset = 0;
278      if (_onUpdate) _onUpdate();
279      if (_onEnd) _onEnd();
280    };
281    utt.onerror = function (e) {
282      if (epoch !== _epoch) return;
283      if (e.error === 'interrupted' || e.error === 'canceled') return;
284      _speaking = false;
285      _paused = false;
286      if (_onUpdate) _onUpdate();
287    };
288
289    synth.speak(utt);
290  }
291
292  /* ── Text extraction ──────────────────────────────────────── */
293
294  // Tags that imply a strong pause (≈ sentence end) when leaving the element.
295  var STRONG_PAUSE_TAGS = {
296    H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1,
297    P: 1, BLOCKQUOTE: 1, PRE: 1, HR: 1,
298    SECTION: 1, ARTICLE: 1, HEADER: 1, FOOTER: 1, MAIN: 1, ASIDE: 1,
299    TABLE: 1, FIGURE: 1, FIGCAPTION: 1,
300    UL: 1, OL: 1, DL: 1
301  };
302  // Tags that imply a light pause (≈ comma) when leaving the element.
303  var LIGHT_PAUSE_TAGS = {
304    LI: 1, TR: 1, TD: 1, TH: 1, DT: 1, DD: 1
305  };
306
307  var DEFAULT_SKIP_SELECTORS = [
308    'script', 'noscript', 'style', 'nav', 'button', 'input', 'select', 'textarea',
309    '.cap', '.highlight-toggle-container', '.navbar', '#sidebar',
310    '#tts-bar', '.tts-bar', '.se-gym-container', '.alert'
311  ];
312
313  // Walk a (cleaned) DOM subtree and produce both the spoken text and a list
314  // of markers (currently just headings). Block-level elements get a strong
315  // ". " on exit; list items / table cells get a light ", "; <pre> blocks
316  // turn each non-empty code line into its own short sentence so each line
317  // gets an audible pause. Punctuation is normalized at the end so we never
318  // emit things like "Title.. ," or "Word, .".
319  function _extract(el, skipSel) {
320    if (!el) return { text: '', markers: [] };
321
322    var clone = el.cloneNode(true);
323    var selectors = skipSel || DEFAULT_SKIP_SELECTORS;
324    for (var s = 0; s < selectors.length; s++) {
325      var matches;
326      try { matches = clone.querySelectorAll(selectors[s]); } catch (e) { continue; }
327      for (var i = matches.length - 1; i >= 0; i--) {
328        var n = matches[i];
329        if (n.parentNode) n.parentNode.removeChild(n);
330      }
331    }
332
333    var parts = [];
334    var rawMarkers = [];
335
336    function walk(node) {
337      if (node.nodeType === 3) {  // TEXT_NODE
338        if (node.nodeValue) parts.push(node.nodeValue);
339        return;
340      }
341      if (node.nodeType !== 1) return;  // not ELEMENT_NODE
342      var tag = node.tagName;
343
344      if (tag === 'BR') { parts.push(', '); return; }
345
346      if (tag === 'PRE') {
347        // Each non-blank code line becomes its own short sentence so the
348        // engine pauses at line breaks instead of running lines together.
349        var raw = (node.textContent || '');
350        var lines = raw.split(/\r?\n/);
351        var pieces = [];
352        for (var l = 0; l < lines.length; l++) {
353          var line = lines[l].replace(/\s+/g, ' ').trim();
354          if (line) pieces.push(line);
355        }
356        if (pieces.length) parts.push(pieces.join('. ') + '. ');
357        return;
358      }
359
360      var isHeading = /^H[1-6]$/.test(tag);
361      var headingLabel = isHeading ? (node.textContent || '').replace(/\s+/g, ' ').trim() : null;
362
363      for (var c = 0; c < node.childNodes.length; c++) walk(node.childNodes[c]);
364
365      if (headingLabel) {
366        rawMarkers.push({ type: 'heading', level: parseInt(tag.charAt(1), 10), label: headingLabel });
367      }
368
369      if (STRONG_PAUSE_TAGS[tag]) parts.push('. ');
370      else if (LIGHT_PAUSE_TAGS[tag]) parts.push(', ');
371    }
372
373    walk(clone);
374
375    // Strip < > so Chrome's TTS doesn't interpret code like <iostream> or <T>
376    // as SSML tags and halt mid-sentence. Then collapse whitespace.
377    var text = parts.join('').replace(/[<>]/g, ' ').replace(/\s+/g, ' ');
378
379    // Punctuation cleanup. Repeated injections plus original punctuation can
380    // produce strings like ". , ", " : .", or ".. " — normalize them.
381    text = text
382      .replace(/\s+([.,!?;:])/g, '$1')                 // no space before punct
383      .replace(/([.!?])(?:\s*[,;:])+/g, '$1')          // strong absorbs following light
384      .replace(/(?:[,;:]\s*)+([.!?])/g, '$1')          // strong absorbs preceding light
385      .replace(/([.!?])(?:\s*\1)+/g, '$1')             // collapse ".." or "?? " etc.
386      .replace(/([,;:])(?:\s*\1)+/g, '$1')             // collapse ",,"
387      .replace(/([.,!?;:])(?=\S)/g, '$1 ')             // ensure space after punct
388      .replace(/\s+/g, ' ')
389      .trim();
390
391    // Locate each heading marker in the normalized text. Search forward from
392    // the previous marker so duplicate heading text in body prose doesn't
393    // pull a marker backward in document order.
394    var markers = [];
395    var searchFrom = 0;
396    for (var k = 0; k < rawMarkers.length; k++) {
397      var raw = rawMarkers[k];
398      if (!raw.label) continue;
399      var idx = text.indexOf(raw.label, searchFrom);
400      if (idx >= 0) {
401        markers.push({ offset: idx, type: raw.type, level: raw.level, label: raw.label });
402        searchFrom = idx + raw.label.length;
403      }
404    }
405
406    return { text: text, markers: markers };
407  }
408
409  function _extractText(el, skipSel) {
410    return _extract(el, skipSel).text;
411  }
412
413  /* ── Public API ───────────────────────────────────────────── */
414
415  window.TTSPlayer = {
416    isSupported: function () {
417      return !!(synth && window.SpeechSynthesisUtterance);
418    },
419    isSpeaking: function () { return _speaking; },
420    isPaused:   function () { return _paused;   },
421    getRate:    function () { return _rate;     },
422    getVoice:   function () { return _voice;    },
423
424    speak: function (input, opts) {
425      opts = opts || {};
426      var text;
427      var markers;
428      if (typeof input === 'string') {
429        text = input;
430        markers = [];
431      } else if (input && typeof input.text === 'string') {
432        text = input.text;
433        markers = Array.isArray(input.markers) ? input.markers.slice() : [];
434      } else {
435        return;
436      }
437      _text = text;
438      _markers = markers;
439      _offset = 0;
440      _onEnd    = opts.onEnd    || null;
441      _onUpdate = opts.onUpdate || null;
442      if (opts.rate !== undefined) _rate = opts.rate;
443      _startFrom(0);
444    },
445
446    pause: function () {
447      if (synth && _speaking && !_paused) {
448        synth.pause();
449        _paused = true;
450        if (_onUpdate) _onUpdate();
451      }
452    },
453
454    resume: function () {
455      if (synth && _paused) {
456        synth.resume();
457        _paused = false;
458        if (_onUpdate) _onUpdate();
459      }
460    },
461
462    stop: function () {
463      if (_startTimer) { clearTimeout(_startTimer); _startTimer = null; }
464      // Bump the epoch so all currently queued utterances treat their
465      // event handlers as stale and exit early.
466      _epoch++;
467      _text     = '';
468      _chunks   = [];
469      _chunkIdx = 0;
470      _markers  = [];
471      if (synth) synth.cancel();
472      _speaking = false;
473      _paused   = false;
474      _offset   = 0;
475      if (_onUpdate) _onUpdate();
476      _onUpdate = null;
477      _onEnd    = null;
478    },
479
480    setRate: function (r) {
481      r = Math.max(0.5, Math.min(2.5, r));
482      _rate = Math.round(r * 10) / 10;
483      if (_speaking || _paused) _startFrom(_offset);
484    },
485
486    setVoice: function (voice) {
487      _voice = voice || null;
488      if (_speaking || _paused) _startFrom(_offset);
489    },
490
491    /**
492     * Skip back/forward by approximately deltaSeconds. Conversion uses an
493     * empirical chars-per-second baseline scaled by the current rate, so a
494     * 10s skip at 2x covers roughly twice the text of a 10s skip at 1x.
495     * Always restarts playback at the new position (matching audio-player
496     * UX where skip controls work whether currently playing or paused);
497     * no-op when stop() has cleared the text.
498     */
499    seekRelative: function (deltaSeconds) {
500      if (!_text) return;
501      var charsPerSec = BASE_CHARS_PER_SEC * _rate;
502      var deltaChars = Math.round(charsPerSec * deltaSeconds);
503      var newOffset = Math.max(0, Math.min(_text.length, _offset + deltaChars));
504      _offset = newOffset;
505      _startFrom(newOffset);
506    },
507
508    /**
509     * Jump to the nearest heading marker. direction is 'next' or 'prev'.
510     * 'next' past the last heading is a no-op so the user doesn't get
511     * silently teleported to the end. 'prev' before the first heading
512     * rewinds to the start of the document.
513     */
514    seekToHeading: function (direction) {
515      if (!_text || !_markers.length) return;
516      // Tolerance avoids re-selecting the heading we're currently inside
517      // when the user mashes the button.
518      var FUZZ = 5;
519      var newOffset = null;
520      if (direction === 'next') {
521        for (var i = 0; i < _markers.length; i++) {
522          if (_markers[i].type !== 'heading') continue;
523          if (_markers[i].offset > _offset + FUZZ) { newOffset = _markers[i].offset; break; }
524        }
525        if (newOffset === null) return;
526      } else {
527        for (var j = _markers.length - 1; j >= 0; j--) {
528          if (_markers[j].type !== 'heading') continue;
529          if (_markers[j].offset < _offset - FUZZ) { newOffset = _markers[j].offset; break; }
530        }
531        if (newOffset === null) newOffset = 0;
532      }
533      _offset = newOffset;
534      _startFrom(newOffset);
535    },
536
537    /** Returns a copy of the markers (heading positions) for the active text. */
538    getMarkers: function () { return _markers.slice(); },
539
540    /** Current playback offset as a fraction in [0, 1]. 0 if no text. */
541    getProgress: function () {
542      if (!_text || !_text.length) return 0;
543      return Math.max(0, Math.min(1, _offset / _text.length));
544    },
545
546    /** Returns English voices sorted best-first, with quality scores and labels. */
547    getVoices: function () {
548      return _getEnglishVoices().map(function (v) {
549        var label = _friendlyName(v);
550        if (!label) return null;
551        return { voice: v, label: label + _badge(v), lang: v.lang, score: _scoreVoice(v) };
552      }).filter(Boolean).sort(function (a, b) { return b.score - a.score; });
553    },
554
555    /** Returns the single best-quality voice available. */
556    bestVoice: function () {
557      return _bestVoice(this.getVoices());
558    },
559
560    onVoicesReady: function (fn) {
561      if (_voicesReady) { fn(); } else { _voicesReadyCbs.push(fn); }
562    },
563
564    extract:     _extract,
565    extractText: _extractText,
566    rateLabel:   function () { return _rate.toFixed(1) + 'x'; }
567  };
568})(window);

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.