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.