1igradeplus = window.igradeplus || {}; 2igradeplus.speech = igradeplus.speech || {}; 3 4/* 5 * Reads an element of the page aloud through the browser's SpeechSynthesis API. 6 * 7 * The markup is written by SpeechControl.java, never by hand: 8 * 9 * <div class="blog-speech" data-speech-target="<id of the element to read>" hidden> 10 * <button data-speech-action="play"> (also "pause" and "stop") 11 * <button data-speech-rate="1"> (one per speed) 12 * </div> 13 * 14 * Every control on the page is wired up on load, so a page adds one by rendering the markup and 15 * loading this file. A control stays hidden unless the browser can actually speak, which is why no 16 * page has to test for support on the server. 17 * 18 * The text is read as a queue of sentences rather than one long utterance. Browsers cut off or stall 19 * on very long utterances, and the speed of an utterance that has already started cannot be changed - 20 * so a speed change re-speaks the current sentence at the new rate and carries on from there. 21 * 22 * The control publishes its state on the root element as data-speech-state (idle, playing or paused) 23 * and marks the chosen speed with aria-pressed, which is all the stylesheet needs to know. 24 */ 25 26igradeplus.speech.MAX_SENTENCE = 220; 27igradeplus.speech.KEEP_ALIVE_MS = 10000; 28igradeplus.speech.RESTART_DELAY_MS = 60; 29 30 31igradeplus.speech.isSupported = function () 32{ 33 return typeof window.speechSynthesis !== 'undefined' && typeof window.SpeechSynthesisUtterance !== 'undefined'; 34}; 35 36igradeplus.speech.init = function () 37{ 38 if (igradeplus.speech.isSupported() === false) 39 { 40 return; 41 } 42 43 var roots = document.querySelectorAll('[data-speech-target]'); 44 45 for (var i = 0; i < roots.length; i++) 46 { 47 igradeplus.speech.attach(roots[i]); 48 } 49}; 50 51igradeplus.speech.attach = function (root) 52{ 53 var target = document.getElementById(root.getAttribute('data-speech-target')); 54 55 if (target === null) 56 { 57 return; 58 } 59 60 new igradeplus.speech.Reader(root, target).attach(); 61}; 62 63/* 64 * Breaks text into sentence-sized pieces. A sentence longer than MAX_SENTENCE is broken again at the 65 * last space that fits, so that one runaway sentence cannot stall the whole reading. 66 */ 67igradeplus.speech.split = function (text) 68{ 69 var cleaned = (text || '').replace(/\s+/g, ' ').trim(); 70 var sentences = []; 71 72 if (cleaned === '') 73 { 74 return sentences; 75 } 76 77 var parts = cleaned.match(/[^.!?]+[.!?]*/g) || [cleaned]; 78 79 for (var i = 0; i < parts.length; i++) 80 { 81 var part = parts[i].trim(); 82 83 while (part.length > igradeplus.speech.MAX_SENTENCE) 84 { 85 var cut = part.lastIndexOf(' ', igradeplus.speech.MAX_SENTENCE); 86 87 if (cut <= 0) 88 { 89 cut = igradeplus.speech.MAX_SENTENCE; 90 } 91 92 sentences.push(part.substring(0, cut).trim()); 93 part = part.substring(cut).trim(); 94 } 95 96 if (part !== '') 97 { 98 sentences.push(part); 99 } 100 } 101 102 return sentences; 103}; 104 105 106igradeplus.speech.Reader = function (root, target) 107{ 108 this.root = root; 109 this.target = target; 110 this.sentences = null; 111 this.index = 0; 112 this.rate = 1; 113 this.state = 'idle'; 114 this.token = 0; 115 this.restart = false;
116 this.keepAlive = null; 117 this.actions = {}; 118 this.rates = []; 119}; 120 121igradeplus.speech.Reader.prototype.attach = function () 122{ 123 var reader = this; 124 var buttons = this.root.querySelectorAll('[data-speech-action]'); 125 var i; 126 127 for (i = 0; i < buttons.length; i++) 128 { 129 this.actions[buttons[i].getAttribute('data-speech-action')] = buttons[i]; 130 131 buttons[i].addEventListener('click', function (event) 132 { 133 reader.onAction(event.currentTarget.getAttribute('data-speech-action')); 134 }); 135 } 136 137 this.rates = this.root.querySelectorAll('[data-speech-rate]'); 138 139 for (i = 0; i < this.rates.length; i++) 140 { 141 this.rates[i].addEventListener('click', function (event) 142 { 143 reader.setRate(parseFloat(event.currentTarget.getAttribute('data-speech-rate'))); 144 }); 145 } 146 147 // Speech outlives the page it started on, so leaving must silence it. 148 window.addEventListener('pagehide', function () 149 { 150 reader.stop(); 151 }); 152 153 this.markRate(); 154 this.setState('idle'); 155 this.root.removeAttribute('hidden'); 156}; 157 158igradeplus.speech.Reader.prototype.onAction = function (action) 159{ 160 if (action === 'play') 161 { 162 this.play(); 163 } 164 else if (action === 'pause') 165 { 166 this.pause(); 167 } 168 else if (action === 'stop') 169 { 170 this.stop(); 171 } 172}; 173 174igradeplus.speech.Reader.prototype.play = function () 175{ 176 if (this.sentences === null) 177 { 178 this.sentences = igradeplus.speech.split(this.target.innerText); 179 } 180 181 if (this.sentences.length === 0) 182 { 183 return; 184 } 185 186 // Resuming only works while the browser still holds the paused sentence. A speed change drops it, 187 // and that sentence is spoken again instead. 188 if (this.state === 'paused' && this.restart === false) 189 { 190 this.setState('playing'); 191 window.speechSynthesis.resume(); 192 return; 193 } 194 195 this.restart = false;
196 this.setState('playing'); 197 this.speakCurrent(); 198}; 199 200igradeplus.speech.Reader.prototype.pause = function () 201{ 202 if (this.state !== 'playing') 203 { 204 return; 205 } 206 207 this.setState('paused'); 208 window.speechSynthesis.pause(); 209}; 210 211igradeplus.speech.Reader.prototype.stop = function () 212{ 213 this.token++; 214 this.index = 0; 215 this.restart = false; 216 this.setState('idle'); 217 window.speechSynthesis.cancel(); 218}; 219 220igradeplus.speech.Reader.prototype.setRate = function (rate) 221{ 222 var reader = this; 223 224 this.rate = rate; 225 this.markRate(); 226 227 if (this.state === 'idle') 228 { 229 return; 230 } 231 232 // The rate of a sentence that is already being spoken cannot be changed, so the current sentence 233 // is dropped and spoken again from its start at the new speed. 234 this.token++; 235 window.speechSynthesis.cancel(); 236 237 if (this.state === 'paused') 238 { 239 this.restart = true; 240 return; 241 } 242 243 // Chrome ignores a speak() issued in the same tick as a cancel(). 244 window.setTimeout(function () 245 { 246 if (reader.state === 'playing') 247 { 248 reader.speakCurrent(); 249 } 250 }, igradeplus.speech.RESTART_DELAY_MS); 251}; 252 253igradeplus.speech.Reader.prototype.speakCurrent = function () 254{ 255 var reader = this; 256 257 if (this.index >= this.sentences.length) 258 { 259 this.stop(); 260 return; 261 } 262 263 // Anything cancelled mid-sentence still reports back; the token tells a live sentence from a dropped one. 264 var token = ++this.token; 265 var utterance = new window.SpeechSynthesisUtterance(this.sentences[this.index]); 266 267 utterance.rate = this.rate; 268 utterance.lang = document.documentElement.lang || 'en-US'; 269 270 utterance.onend = function () 271 { 272 if (token !== reader.token || reader.state !== 'playing') 273 { 274 return; 275 } 276 277 reader.index++; 278 reader.speakCurrent(); 279 }; 280 281 utterance.onerror = function () 282 { 283 if (token !== reader.token) 284 { 285 return; 286 } 287 288 reader.stop(); 289 }; 290 291 window.speechSynthesis.speak(utterance); 292}; 293 294igradeplus.speech.Reader.prototype.setState = function (state) 295{ 296 this.state = state; 297 this.root.setAttribute('data-speech-state', state); 298 299 this.setDisabled('play', state === 'playing'); 300 this.setDisabled('pause', state !== 'playing'); 301 this.setDisabled('stop', state === 'idle'); 302 303 if (state === 'playing') 304 { 305 this.startKeepAlive(); 306 } 307 else 308 { 309 this.stopKeepAlive(); 310 } 311}; 312 313igradeplus.speech.Reader.prototype.setDisabled = function (action, disabled) 314{ 315 var button = this.actions[action]; 316 317 if (button) 318 { 319 button.disabled = disabled; 320 } 321}; 322 323igradeplus.speech.Reader.prototype.markRate = function () 324{ 325 for (var i = 0; i < this.rates.length; i++) 326 { 327 var selected = parseFloat(this.rates[i].getAttribute('data-speech-rate')) === this.rate; 328 this.rates[i].setAttribute('aria-pressed', selected ? 'true' : 'false'); 329 } 330}; 331 332/* 333 * Chrome stops speaking after about fifteen seconds unless it is nudged, which a long sentence can reach. 334 * Resuming while already playing is harmless everywhere else. 335 */ 336igradeplus.speech.Reader.prototype.startKeepAlive = function () 337{ 338 var reader = this; 339 340 this.stopKeepAlive(); 341 342 this.keepAlive = window.setInterval(function () 343 { 344 if (reader.state === 'playing') 345 { 346 window.speechSynthesis.resume(); 347 } 348 }, igradeplus.speech.KEEP_ALIVE_MS); 349}; 350 351igradeplus.speech.Reader.prototype.stopKeepAlive = function () 352{ 353 if (this.keepAlive !== null) 354 { 355 window.clearInterval(this.keepAlive); 356 this.keepAlive = null; 357 } 358}; 359 360 361if (document.readyState === 'loading') 362{ 363 document.addEventListener('DOMContentLoaded', igradeplus.speech.init); 364} 365else 366{ 367 igradeplus.speech.init(); 368}
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.