Add server-side Piper read-aloud with voice picker
Reader read-aloud now streams neural WAV audio from a new POST /api/tts endpoint that shells out to Piper, instead of the browser's Web Speech voice. Each paragraph is synthesized on demand with the next one prefetched during playback, keeping the existing highlight/scroll sync. Voices are configured under [web.tts] (piper binary + voices_dir + a labelled voice list) and exposed to the client as window.PETE_TTS; the reader gets a Voice selector in the Aa menu, persisted per-device. Still a signed-in-only perk and gated on auth.
This commit is contained in:
@@ -283,6 +283,23 @@ html[data-phase="night"] {
|
||||
color: #1c1305;
|
||||
border-color: transparent;
|
||||
}
|
||||
.pete-reader-voicerow[hidden] { display: none; }
|
||||
.pete-reader-voice {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid rgba(20, 14, 6, 0.18);
|
||||
background: rgba(20, 14, 6, 0.06);
|
||||
color: inherit;
|
||||
max-width: 11rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-voice {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.pete-reader-scroll {
|
||||
flex: 1 1 auto;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,8 @@
|
||||
var shareBtn = overlay.querySelector("[data-reader-share]");
|
||||
var typeBtn = overlay.querySelector("[data-reader-type]");
|
||||
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
|
||||
var voiceRow = overlay.querySelector("[data-reader-voicerow]");
|
||||
var voiceSel = overlay.querySelector("[data-reader-voice]");
|
||||
var relatedEl = overlay.querySelector("[data-reader-related]");
|
||||
var relatedCache = {}; // id -> results array
|
||||
|
||||
@@ -34,10 +36,12 @@
|
||||
var SIGNED_IN = !!(window.PETE_USER);
|
||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
||||
|
||||
// Read-aloud is a signed-in-only perk and needs Web Speech support. Share is
|
||||
// available to everyone (native sheet where present, clipboard copy otherwise).
|
||||
var TTS_OK = SIGNED_IN && typeof window.speechSynthesis !== "undefined" &&
|
||||
typeof window.SpeechSynthesisUtterance !== "undefined";
|
||||
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
|
||||
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
|
||||
// is available to everyone (native sheet where present, clipboard otherwise).
|
||||
var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
|
||||
window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
|
||||
var TTS_OK = SIGNED_IN && !!TTS_CFG;
|
||||
|
||||
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||
var index = 0;
|
||||
@@ -484,12 +488,34 @@
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
// ---- read aloud (signed-in only) ------------------------------------------
|
||||
// ---- read aloud (signed-in only, server-side Piper) -----------------------
|
||||
// Each paragraph is synthesized on demand by POSTing its text to /api/tts and
|
||||
// playing the returned WAV. We fetch the next paragraph while the current one
|
||||
// plays, so the model-load + synthesis latency is hidden behind playback.
|
||||
// speakGen is bumped on every stop/start/voice-change; any async callback that
|
||||
// fires afterwards checks it against its captured gen and no-ops if stale.
|
||||
var VOICE_KEY = "pete.reader.voice.v1";
|
||||
var speaking = false;
|
||||
var speakGen = 0; // bumped on every stop/start; stale utterance callbacks that
|
||||
// fire after speechSynthesis.cancel() check it and no-op
|
||||
var speakParas = []; // <p> elements being read, for highlight
|
||||
var speakQueue = []; // remaining {el, text} to utter
|
||||
var speakGen = 0;
|
||||
var speakParas = []; // <p> elements currently highlighted
|
||||
var speakItems = []; // [{el, text}] for the whole article
|
||||
var speakIdx = 0; // index into speakItems currently playing
|
||||
var speakReq = null; // in-flight fetch for the current paragraph (has .ctrl)
|
||||
var prefetch = null; // { idx, gen, promise, abort } for the next paragraph
|
||||
var audioEl = null; // shared <audio> element
|
||||
var currentURL = null; // object URL currently loaded into audioEl
|
||||
|
||||
// Device-local voice choice, like the type prefs (see loadType).
|
||||
var ttsVoice = "";
|
||||
(function loadVoice() {
|
||||
if (!TTS_CFG) return;
|
||||
var saved = "";
|
||||
try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
|
||||
var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
|
||||
ttsVoice = ok ? saved : TTS_CFG.default;
|
||||
})();
|
||||
function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }
|
||||
|
||||
function speakingText() {
|
||||
if (!articleEl) return [];
|
||||
var out = [];
|
||||
@@ -505,30 +531,95 @@
|
||||
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
|
||||
speakParas = [];
|
||||
}
|
||||
function stopSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
speakGen++;
|
||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
||||
speaking = false;
|
||||
speakQueue = [];
|
||||
function highlight(item) {
|
||||
clearSpeakHighlight();
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
||||
}
|
||||
function speakNext() {
|
||||
if (!speaking) return;
|
||||
var item = speakQueue.shift();
|
||||
if (!item) { stopSpeak(); return; }
|
||||
clearSpeakHighlight();
|
||||
if (item.el) {
|
||||
if (item && item.el) {
|
||||
item.el.classList.add("is-speaking");
|
||||
speakParas.push(item.el);
|
||||
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
|
||||
// where promise resolves to an object URL for the audio blob.
|
||||
function ttsFetch(text) {
|
||||
var ctrl = new AbortController();
|
||||
var promise = fetch("/api/tts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice: ttsVoice, text: text }),
|
||||
signal: ctrl.signal,
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("tts " + r.status);
|
||||
return r.blob();
|
||||
}).then(function (b) { return URL.createObjectURL(b); });
|
||||
return { ctrl: ctrl, promise: promise };
|
||||
}
|
||||
function abortPrefetch() {
|
||||
if (!prefetch) return;
|
||||
try { prefetch.ctrl.abort(); } catch (e) {}
|
||||
// If it already resolved to a URL, reclaim it.
|
||||
prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
|
||||
prefetch = null;
|
||||
}
|
||||
function startPrefetch(i, gen) {
|
||||
var item = speakItems[i];
|
||||
if (!item) { prefetch = null; return; }
|
||||
var req = ttsFetch(item.text);
|
||||
prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
|
||||
}
|
||||
function playURL(url, gen) {
|
||||
if (!audioEl) audioEl = new Audio();
|
||||
if (currentURL) URL.revokeObjectURL(currentURL);
|
||||
currentURL = url;
|
||||
audioEl.src = url;
|
||||
audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||
audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||
var pr = audioEl.play();
|
||||
if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
|
||||
}
|
||||
function playIdx(i) {
|
||||
if (!speaking) return;
|
||||
var gen = speakGen;
|
||||
var u = new SpeechSynthesisUtterance(item.text);
|
||||
u.onend = function () { if (speaking && gen === speakGen) speakNext(); };
|
||||
u.onerror = function () { if (speaking && gen === speakGen) speakNext(); };
|
||||
try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); }
|
||||
var item = speakItems[i];
|
||||
if (!item) { stopSpeak(); return; }
|
||||
speakIdx = i;
|
||||
highlight(item);
|
||||
var urlP;
|
||||
if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
|
||||
urlP = prefetch.promise;
|
||||
prefetch = null; // hand off; do not abort/revoke this one
|
||||
} else {
|
||||
abortPrefetch();
|
||||
var req = ttsFetch(item.text);
|
||||
speakReq = req;
|
||||
urlP = req.promise;
|
||||
}
|
||||
urlP.then(function (url) {
|
||||
if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
|
||||
speakReq = null;
|
||||
startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
|
||||
playURL(url, gen);
|
||||
}).catch(function () {
|
||||
if (!speaking || gen !== speakGen) return;
|
||||
speakReq = null;
|
||||
playIdx(i + 1); // skip a paragraph that failed rather than stalling
|
||||
});
|
||||
}
|
||||
function teardownAudio() {
|
||||
if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
|
||||
abortPrefetch();
|
||||
if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
|
||||
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
|
||||
}
|
||||
function stopSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
speakGen++;
|
||||
speaking = false;
|
||||
teardownAudio();
|
||||
speakItems = [];
|
||||
clearSpeakHighlight();
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
||||
}
|
||||
function startSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
@@ -536,16 +627,43 @@
|
||||
var parts = speakingText();
|
||||
if (!parts.length) { toast("Nothing to read yet"); return; }
|
||||
speakGen++;
|
||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
||||
teardownAudio();
|
||||
speaking = true;
|
||||
speakQueue = parts.slice();
|
||||
speakItems = parts;
|
||||
speakIdx = 0;
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
||||
speakNext();
|
||||
playIdx(0);
|
||||
}
|
||||
function toggleSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
if (speaking) stopSpeak(); else startSpeak();
|
||||
}
|
||||
// Switching voice restarts the current paragraph with the new voice so the
|
||||
// change is audible immediately, keeping our place in the article.
|
||||
function changeVoice(id) {
|
||||
if (!TTS_CFG) return;
|
||||
ttsVoice = id;
|
||||
saveVoice();
|
||||
if (voiceSel) voiceSel.value = ttsVoice;
|
||||
if (speaking) {
|
||||
speakGen++;
|
||||
teardownAudio();
|
||||
playIdx(speakIdx);
|
||||
}
|
||||
}
|
||||
function buildVoiceMenu() {
|
||||
if (!voiceSel || !TTS_CFG) return;
|
||||
voiceSel.innerHTML = "";
|
||||
TTS_CFG.voices.forEach(function (v) {
|
||||
var o = document.createElement("option");
|
||||
o.value = v.id;
|
||||
o.textContent = v.label;
|
||||
voiceSel.appendChild(o);
|
||||
});
|
||||
voiceSel.value = ttsVoice;
|
||||
voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
|
||||
if (voiceRow) voiceRow.hidden = false;
|
||||
}
|
||||
|
||||
// ---- open / close ---------------------------------------------------------
|
||||
function openReader() {
|
||||
@@ -587,9 +705,9 @@
|
||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
||||
});
|
||||
|
||||
// Read-aloud: signed-in only, and only where Web Speech exists.
|
||||
// Read-aloud: signed-in only, and only when server-side TTS is configured.
|
||||
if (listenBtn) {
|
||||
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); }
|
||||
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
|
||||
else listenBtn.style.display = "none";
|
||||
}
|
||||
// Share: available to everyone.
|
||||
|
||||
Reference in New Issue
Block a user