PageSourceSearch

https://igradeplus.com/js/speechcontrol.js

js igradeplus.com collected 2026-10-03 22:27:46 UTC 9,147 bytes, 368 lines download raw bytes

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.