Skip to content

Instantly share code, notes, and snippets.

@Yanrishatum
Created April 24, 2022 17:21
Show Gist options
  • Save Yanrishatum/b914ba87fe35364c8a554cecc637a2cd to your computer and use it in GitHub Desktop.
Save Yanrishatum/b914ba87fe35364c8a554cecc637a2cd to your computer and use it in GitHub Desktop.
Syosetu: TTS auto-reader
// ==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