Created
April 24, 2022 17:21
-
-
Save Yanrishatum/b914ba87fe35364c8a554cecc637a2cd to your computer and use it in GitHub Desktop.
Syosetu: TTS auto-reader
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Syosetu TTS capabilities | |
// @namespace http://tampermonkey.net/ | |
// @version 0.1 | |
// @description I'm too lazy to read! Do it for me! | |
// @author Yanrishatum | |
// @match https://ncode.syosetu.com/n*/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=syosetu.com | |
// @grant unsafeWindow | |
// @run-at document-idle | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
var speechUtteranceChunker = function (utt, settings, callback) { | |
settings = settings || {}; | |
var newUtt; | |
var txt = (settings && settings.offset !== undefined ? utt.text.substring(settings.offset) : utt.text); | |
if (utt.voice && utt.voice.voiceURI === 'native') { // Not part of the spec | |
if (callback !== undefined) utt.addEventListener("end", () => callback(), { once: true }); | |
speechSynthesis.speak(utt); | |
} else { | |
var chunkLength = settings.chunkLength || 160; | |
var halfLength = Math.floor(chunkLength / 2); | |
var pattRegex = new RegExp(`^[\\s\\S]{${halfLength},${chunkLength}}\\p{Po}|^[\\s\\S]{1,${chunkLength}}$|^[\\s\\S]{1,${chunkLength}}\\s`, 'u'); | |
//var pattRegex = new RegExp('^[\\s\\S]{' + Math.floor(chunkLength / 2) + ',' + chunkLength + '}\\p{Po}|^[\\s\\S]{1,' + chunkLength + '}$|^[\\s\\S]{1,' + chunkLength + '}\\s', 'u'); | |
var chunked = []; | |
var chunkArr; | |
let firstUtt = null; | |
let lastUtt = null; | |
// TODO: Properly handle charIndex offsets | |
function cloneEvent(ev) { | |
return new SpeechSynthesisEvent(ev.type, { utterance: ev.utterance, name: ev.name, elapsedTime: ev.elapsedTime, charIndex: ev.charIndex }); | |
} | |
while(true) { | |
var chunkArr = txt.match(pattRegex); | |
if (!chunkArr || chunkArr[0] === undefined) { // || chunkArr[0].length <= 2 | |
if (lastUtt === null) { | |
if (callback !== undefined) callback(); | |
return; | |
} else break; | |
} | |
var chunk = chunkArr[0]; | |
const newUtt = new SpeechSynthesisUtterance(chunk); | |
for (const key in utt) { | |
if (key !== "text" && typeof(utt[key]) !== "function" && utt[key] !== null && utt[key] !== -1) { | |
newUtt[key] = utt[key]; | |
} | |
} | |
if (settings.modifier) settings.modifier(newUtt); | |
if (!firstUtt) firstUtt = lastUtt = newUtt; | |
else lastUtt = newUtt; | |
chunked.push(newUtt); | |
txt = txt.substr(chunk.length); | |
if (txt.length == 0) break; | |
} | |
//IMPORTANT!! Do not remove: Logging the object out fixes some onend firing issues. | |
// TODO: Investigate why. Likely GC shenanigans because Chrome doesn't keep references properly. | |
firstUtt.addEventListener('start', (ev) => { | |
//console.log(ev.utterance); | |
utt.dispatchEvent(cloneEvent(ev)); | |
speechUtteranceChunker.queued.push(ev.utterance); | |
}); | |
lastUtt.addEventListener('end', (ev) => { | |
utt.dispatchEvent(cloneEvent(ev)); | |
speechUtteranceChunker.queued.splice(speechUtteranceChunker.queued.indexOf(ev.utterance), 1); | |
if (callback != null) callback(); | |
}); | |
} | |
//requestAnimationFrame(() => { | |
for (const u of chunked) speechSynthesis.speak(u); | |
//}); | |
}; | |
speechUtteranceChunker.queued = []; | |
class Speaker { | |
constructor() { | |
this.voices = speechSynthesis.getVoices().filter((v) => v.lang == "ja-JP" && v.localService); // TODO: Support non-locals, but it's a HUGE PAIN | |
if (this.voices.length == 0) this.voices = speechSynthesis.getVoices().filter((v) => v.lang == "ja-JP"); | |
this.voice /* : SpeechSynthesisVoice */ = | |
this.voices.find((v) => v.name.includes("Sayaka")) || | |
this.voices[0]; | |
this.lines = []; | |
this.queued = []; | |
this.caret = 0; | |
this.play = true; | |
this.rate = 0.8; | |
this.pitch = 1.0; | |
this.volume = 1.0; | |
this.autoNextPage = true; | |
this.readHeader = false; | |
this.readAuthorNotes = false; | |
this.onNext = null; | |
this.load(); | |
} | |
load() { | |
const stat = localStorage["syosetu_tts"]; | |
if (stat) { | |
const data = JSON.parse(stat); | |
this.rate = data.rate; | |
this.pitch = data.pitch; | |
this.volume = data.volume; | |
this.autoNextPage = data.autoNextPage; | |
if (data.readHeader !== undefined) this.readHeader = data.readHeader; | |
if (data.readAuthorNotes !== undefined) this.readAuthorNotes = data.readAuthorNotes; | |
this.voice = speechSynthesis.getVoices().find((v) => v.name == data.voice) || this.voice; | |
} | |
} | |
save() { | |
localStorage["syosetu_tts"] = JSON.stringify({ | |
rate: this.rate, | |
pitch: this.pitch, | |
volume: this.volume, | |
autoNextPage: this.autoNextPage, | |
voice: this.voice.name, | |
readHeader: this.readHeader, | |
readAuthorNotes: this.readAuthorNotes, | |
}); | |
} | |
speakPage(offset = 0) { | |
this.lines = Array.from(document.querySelectorAll("#novel_honbun>*")); | |
if (this.readAuthorNotes) { | |
this.lines = Array.from(document.querySelectorAll("#novel_p>*")).concat(this.lines, Array.from(document.querySelectorAll("#novel_a>*"))); | |
} | |
if (this.readHeader) { | |
this.lines.unshift(document.querySelector(".novel_subtitle")); | |
} | |
this.lines = this.lines.filter((l) => l.textContent.trim().length != ""); | |
this.caret = offset; | |
this.play = true; | |
let prequeue = 2; | |
const self = this; | |
function queue() { | |
self.speakNext(); | |
if (--prequeue != 0) requestAnimationFrame(queue); | |
} | |
speechSynthesis.cancel(); // Just in case | |
requestAnimationFrame(queue); | |
} | |
speakNext() { | |
if (!this.play) { | |
this.log("STOP"); | |
return; | |
} | |
if (this.caret == this.lines.length && this.queued.length == 0) { | |
this.log("DONE"); | |
this.nextPage(); | |
speechSynthesis.cancel(); | |
} else if (this.caret < this.lines.length) { | |
console.log("NEXT", this.caret + "/" + this.lines.length); | |
this.doSpeak(this.lines[this.caret++]); | |
if (this.onNext) this.onNext(); | |
} | |
} | |
nextPage() { | |
if (this.autoNextPage) { | |
const u = new URL(Array.from(document.querySelectorAll(".novel_bn>a")).filter((el) => el.textContent.includes("次へ"))[0].href); | |
this.fuckChrome(u); | |
// u.hash = "tts_play"; | |
// location.href = u.toString(); | |
} | |
} | |
/** | |
* @param {HTMLElement} el | |
*/ | |
doSpeak(el) { | |
new LineSpeak(el, this).speak(); | |
} | |
log() { | |
// if (false) | |
console.log(...arguments); | |
} | |
fuckChrome(url) { | |
const xhr = new XMLHttpRequest(); | |
xhr.open("GET", url); | |
xhr.responseType = "document"; | |
xhr.onload = (e) => { | |
const doc = xhr.responseXML; | |
document.head.replaceChildren(...Array.from(doc.head.childNodes)); | |
document.querySelector("#container").replaceChildren(...Array.from(doc.querySelector("#container").childNodes)); | |
history.pushState(null, null, url); | |
this.speakPage(); | |
} | |
xhr.send(); | |
} | |
} | |
/** @typedef {{ offset: number, el: Node, end: number }} RangeInfo */ | |
class LineSpeak { | |
/** | |
* | |
* @param {HTMLElement} el | |
* @param {Speaker} speaker | |
*/ | |
constructor (el, speaker) { | |
this.el = el; | |
this.speaker = speaker; | |
const utterance = this.utterance = new SpeechSynthesisUtterance(el.textContent); | |
utterance.voice = speaker.voice; | |
utterance.rate = speaker.rate; | |
utterance.pitch = speaker.pitch; | |
utterance.volume = speaker.volume; | |
/** @type {RangeInfo[]} */ | |
this.ranges = []; | |
this.buildRanges(el, 0); | |
this.onBoundaryBound = this.onBoundary.bind(this); | |
utterance.addEventListener("boundary", this.onBoundaryBound); | |
utterance.addEventListener("start", this.onStart.bind(this), { once: true }); | |
utterance.addEventListener("end", this.onEnd.bind(this), { once: true }); | |
} | |
speak() { | |
this.speaker.queued.push(this); | |
speechSynthesis.speak(this.utterance); | |
} | |
/** | |
* | |
* @param {Node} node | |
* @param {number} offset | |
*/ | |
buildRanges(node, offset) { | |
if (node.nodeType == Node.TEXT_NODE) { | |
const tlen = node.textContent.length; | |
this.ranges.push({ offset: offset, el: node, end: offset + tlen }); | |
return offset + tlen; | |
} else if (node.nodeType == Node.ELEMENT_NODE) { | |
for (const n of node.childNodes) offset = this.buildRanges(n, offset); | |
return offset; | |
} else return offset; | |
} | |
getRange(min, size) { | |
let i = 0; | |
const max = this.ranges.length; | |
/** @type {RangeInfo} */ | |
let rmin = null; | |
while (i < max) { | |
const r = this.ranges[i++]; | |
if (min >= r.offset && min < r.end) { | |
rmin = r; | |
break; | |
} | |
} | |
if (!rmin) rmin = this.ranges[0]; | |
const range = new Range(); | |
range.setStart(rmin.el, min - rmin.offset); | |
min += size; | |
/** @type {RangeInfo} */ | |
let rmax = null; | |
while (i < max) { | |
const r = this.ranges[i++]; | |
if (min >= r.offset && min < r.end) { | |
rmax = r; | |
break; | |
} | |
} | |
if (!rmax) rmax = rmin; | |
range.setEnd(rmax.el, min - rmax.offset); | |
return range; | |
} | |
/** | |
* @param {SpeechSynthesisEvent} ev | |
*/ | |
onBoundary(ev) { | |
const range = this.getRange(ev.charIndex, ev.charLength); | |
const sel = document.getSelection(); | |
sel.removeAllRanges(); | |
sel.addRange(range); | |
} | |
onStart() { | |
this.el.style.backgroundColor = "#8adfff"; | |
this.el.scrollIntoView({ behavior: "smooth", block: "center" }); | |
} | |
onEnd() { | |
this.el.style.backgroundColor = ""; | |
this.utterance.removeEventListener("boundary", this.onBoundaryBound); | |
this.speaker.queued.splice(this.speaker.queued.indexOf(this), 1); | |
this.speaker.speakNext(); | |
} | |
} | |
let state; | |
function init() { | |
state = new Speaker(); | |
{ | |
{ | |
const old = document.getElementById("speaker"); | |
if (old) old.remove(); | |
} | |
const root = document.createElement("div"); | |
root.id = "speaker"; | |
root.innerHTML = ` | |
<div><button id="play">Start</button> <button id="playpause">►</button> <span id="status"></span></div> | |
<div><label><input type="checkbox" data-bind="autoNextPage"> Auto-play next page</label></div> | |
<div><label><input type="checkbox" data-bind="readHeader"> Include header</label></div> | |
<div><label><input type="checkbox" data-bind="readAuthorNotes"> Include author notes</label></div> | |
<div><label><input type="range" data-bind="pitch" data-mul="100" min="50" max="200"> Pitch <span data-show="pitch"></span>%</label></div> | |
<div><label><input type="range" data-bind="rate" data-mul="100" min="50" max="200"> Rate <span data-show="rate"></span>%</label></div> | |
<div><label><input type="range" data-bind="volume" data-mul="100" min="0" max="200"> Volume <span data-show="volume"></span>%</label></div> | |
<div>TODO: Voice selection</div> | |
</div>`; | |
root.style.position = "fixed"; | |
root.style.bottom = "0"; | |
root.style.left = "0"; | |
root.style.width = "290px"; | |
root.style.backgroundColor = "#ffffffa6"; | |
root.style.padding = "4px"; | |
/** @type {HTMLInputElement} */ | |
let inp; | |
for (inp of root.querySelectorAll("input[data-bind]")) { | |
inp.addEventListener("change", (e) => { | |
/** @type {HTMLInputElement} */ | |
const inp = e.currentTarget; | |
let disp = ""; | |
if (inp.type == "checkbox") { | |
state[inp.dataset.bind] = inp.checked; | |
state.save(); | |
disp = inp.checked ? "ON" : "OFF"; | |
} else if (inp.type == "range" || inp.type == "number") { | |
const mul = parseFloat(inp.dataset.mul || "1"); | |
state[inp.dataset.bind] = inp.valueAsNumber / mul; | |
state.save(); | |
disp = inp.valueAsNumber; | |
} | |
const rnd = root.querySelector("*[data-show=" + inp.dataset.bind + "]"); | |
if (rnd) rnd.textContent = disp; | |
}); | |
let disp = ""; | |
if (inp.type == "checkbox") { | |
inp.checked = state[inp.dataset.bind]; | |
disp = inp.checked ? "ON" : "OFF"; | |
} else if (inp.type == "range" || inp.type == "number") { | |
const mul = parseFloat(inp.dataset.mul || "1"); | |
inp.value = state[inp.dataset.bind] * mul; | |
disp = inp.value; | |
} | |
const rnd = root.querySelector("*[data-show=" + inp.dataset.bind + "]"); | |
if (rnd) rnd.textContent = disp; | |
} | |
root.querySelector("#play").addEventListener("click", () => { | |
state.speakPage(); | |
}); | |
root.querySelector("#playpause").addEventListener("click", () => { | |
speechSynthesis.paused ? speechSynthesis.resume() : speechSynthesis.pause(); | |
}); | |
const status = root.querySelector("#status"); | |
state.onNext = () => { | |
status.textContent = (state.caret - state.queued.length + 1) + "/" + state.lines.length; | |
} | |
document.body.appendChild(root); | |
} | |
unsafeWindow.state = state; | |
if (location.hash == "#tts_play") { | |
// Welcome to bullshit town: Even if voices are initialized - service itself is not. | |
setTimeout(() => state.speakPage(), 2000); | |
} | |
} | |
if (speechSynthesis.getVoices().find((v) => v.lang == "ja-JP")) init(); | |
else speechSynthesis.addEventListener("voiceschanged", init); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment