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:
@@ -77,6 +77,25 @@ interval_minutes = 360
|
|||||||
# item doesn't ping everyone.
|
# item doesn't ping everyone.
|
||||||
min_stories = 3
|
min_stories = 3
|
||||||
|
|
||||||
|
# Server-side neural read-aloud (Piper, https://github.com/rhasspy/piper).
|
||||||
|
# When enabled, the reader's "Listen" button streams real Piper voices instead
|
||||||
|
# of the browser's robotic Web Speech voice. Signed-in only, so it needs
|
||||||
|
# [web.auth] on too. Install the piper binary and one or more voice models
|
||||||
|
# (<id>.onnx + <id>.onnx.json) into voices_dir first.
|
||||||
|
[web.tts]
|
||||||
|
enabled = false
|
||||||
|
piper_bin = "/opt/piper/piper" # path to the piper executable
|
||||||
|
voices_dir = "/opt/piper/voices" # dir holding <id>.onnx (+ .onnx.json) models
|
||||||
|
default = "en_US-amy-medium" # voice id selected until the reader picks another
|
||||||
|
# List the voices to offer, in menu order. Omit the whole [[web.tts.voices]]
|
||||||
|
# list to auto-discover every *.onnx in voices_dir (labelled by filename).
|
||||||
|
[[web.tts.voices]]
|
||||||
|
id = "en_US-amy-medium"
|
||||||
|
label = "Amy (US, female)"
|
||||||
|
[[web.tts.voices]]
|
||||||
|
id = "en_US-ryan-high"
|
||||||
|
label = "Ryan (US, male, HQ)"
|
||||||
|
|
||||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type WebConfig struct {
|
|||||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||||
|
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||||
@@ -53,6 +54,29 @@ type PushConfig struct {
|
|||||||
MinStories int `toml:"min_stories"`
|
MinStories int `toml:"min_stories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
|
||||||
|
// signed-in users get the reader's "Listen" button backed by real Piper voices
|
||||||
|
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
|
||||||
|
// perk, so this does nothing unless auth is also enabled.
|
||||||
|
type TTSConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
PiperBin string `toml:"piper_bin"` // path to the piper executable
|
||||||
|
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
|
||||||
|
// Voices lists the voices to offer, in menu order. Each id is a model
|
||||||
|
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
|
||||||
|
// Leave empty to auto-discover every *.onnx in voices_dir.
|
||||||
|
Voices []VoiceConfig `toml:"voices"`
|
||||||
|
// Default is the voice id selected until the reader picks another. Empty
|
||||||
|
// falls back to the first available voice.
|
||||||
|
Default string `toml:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceConfig is one selectable Piper voice.
|
||||||
|
type VoiceConfig struct {
|
||||||
|
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
|
||||||
|
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||||
|
}
|
||||||
|
|
||||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ type pageData struct {
|
|||||||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
||||||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||||||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||||||
|
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelPage struct {
|
type channelPage struct {
|
||||||
@@ -166,6 +167,10 @@ func (s *Server) base(r *http.Request) pageData {
|
|||||||
PostingEnabled: s.postingEnabled,
|
PostingEnabled: s.postingEnabled,
|
||||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||||
|
TTS: template.JS("null"),
|
||||||
|
}
|
||||||
|
if s.tts != nil {
|
||||||
|
d.TTS = s.tts.clientConfig()
|
||||||
}
|
}
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
if u := s.auth.userFromRequest(r); u != nil {
|
if u := s.auth.userFromRequest(r); u != nil {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type Server struct {
|
|||||||
srv *http.Server
|
srv *http.Server
|
||||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||||
|
tts *ttsService // nil when server-side read-aloud is disabled
|
||||||
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
||||||
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||||
|
|
||||||
@@ -115,6 +116,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
|
||||||
|
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
|
||||||
|
if s.auth != nil {
|
||||||
|
tts, err := newTTS(cfg.TTS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.tts = tts
|
||||||
|
} else if cfg.TTS.Enabled {
|
||||||
|
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
@@ -167,6 +180,9 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||||
}
|
}
|
||||||
|
if s.tts != nil {
|
||||||
|
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.srv = &http.Server{
|
s.srv = &http.Server{
|
||||||
|
|||||||
@@ -283,6 +283,23 @@ html[data-phase="night"] {
|
|||||||
color: #1c1305;
|
color: #1c1305;
|
||||||
border-color: transparent;
|
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 {
|
.pete-reader-scroll {
|
||||||
flex: 1 1 auto;
|
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 shareBtn = overlay.querySelector("[data-reader-share]");
|
||||||
var typeBtn = overlay.querySelector("[data-reader-type]");
|
var typeBtn = overlay.querySelector("[data-reader-type]");
|
||||||
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
|
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 relatedEl = overlay.querySelector("[data-reader-related]");
|
||||||
var relatedCache = {}; // id -> results array
|
var relatedCache = {}; // id -> results array
|
||||||
|
|
||||||
@@ -34,10 +36,12 @@
|
|||||||
var SIGNED_IN = !!(window.PETE_USER);
|
var SIGNED_IN = !!(window.PETE_USER);
|
||||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
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
|
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
|
||||||
// available to everyone (native sheet where present, clipboard copy otherwise).
|
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
|
||||||
var TTS_OK = SIGNED_IN && typeof window.speechSynthesis !== "undefined" &&
|
// is available to everyone (native sheet where present, clipboard otherwise).
|
||||||
typeof window.SpeechSynthesisUtterance !== "undefined";
|
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 items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||||
var index = 0;
|
var index = 0;
|
||||||
@@ -484,12 +488,34 @@
|
|||||||
window.open(url, "_blank", "noopener");
|
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 speaking = false;
|
||||||
var speakGen = 0; // bumped on every stop/start; stale utterance callbacks that
|
var speakGen = 0;
|
||||||
// fire after speechSynthesis.cancel() check it and no-op
|
var speakParas = []; // <p> elements currently highlighted
|
||||||
var speakParas = []; // <p> elements being read, for highlight
|
var speakItems = []; // [{el, text}] for the whole article
|
||||||
var speakQueue = []; // remaining {el, text} to utter
|
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() {
|
function speakingText() {
|
||||||
if (!articleEl) return [];
|
if (!articleEl) return [];
|
||||||
var out = [];
|
var out = [];
|
||||||
@@ -505,30 +531,95 @@
|
|||||||
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
|
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
|
||||||
speakParas = [];
|
speakParas = [];
|
||||||
}
|
}
|
||||||
function stopSpeak() {
|
function highlight(item) {
|
||||||
if (!TTS_OK) return;
|
|
||||||
speakGen++;
|
|
||||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
|
||||||
speaking = false;
|
|
||||||
speakQueue = [];
|
|
||||||
clearSpeakHighlight();
|
clearSpeakHighlight();
|
||||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
if (item && item.el) {
|
||||||
}
|
|
||||||
function speakNext() {
|
|
||||||
if (!speaking) return;
|
|
||||||
var item = speakQueue.shift();
|
|
||||||
if (!item) { stopSpeak(); return; }
|
|
||||||
clearSpeakHighlight();
|
|
||||||
if (item.el) {
|
|
||||||
item.el.classList.add("is-speaking");
|
item.el.classList.add("is-speaking");
|
||||||
speakParas.push(item.el);
|
speakParas.push(item.el);
|
||||||
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
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 gen = speakGen;
|
||||||
var u = new SpeechSynthesisUtterance(item.text);
|
var item = speakItems[i];
|
||||||
u.onend = function () { if (speaking && gen === speakGen) speakNext(); };
|
if (!item) { stopSpeak(); return; }
|
||||||
u.onerror = function () { if (speaking && gen === speakGen) speakNext(); };
|
speakIdx = i;
|
||||||
try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); }
|
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() {
|
function startSpeak() {
|
||||||
if (!TTS_OK) return;
|
if (!TTS_OK) return;
|
||||||
@@ -536,16 +627,43 @@
|
|||||||
var parts = speakingText();
|
var parts = speakingText();
|
||||||
if (!parts.length) { toast("Nothing to read yet"); return; }
|
if (!parts.length) { toast("Nothing to read yet"); return; }
|
||||||
speakGen++;
|
speakGen++;
|
||||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
teardownAudio();
|
||||||
speaking = true;
|
speaking = true;
|
||||||
speakQueue = parts.slice();
|
speakItems = parts;
|
||||||
|
speakIdx = 0;
|
||||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
||||||
speakNext();
|
playIdx(0);
|
||||||
}
|
}
|
||||||
function toggleSpeak() {
|
function toggleSpeak() {
|
||||||
if (!TTS_OK) return;
|
if (!TTS_OK) return;
|
||||||
if (speaking) stopSpeak(); else startSpeak();
|
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 ---------------------------------------------------------
|
// ---- open / close ---------------------------------------------------------
|
||||||
function openReader() {
|
function openReader() {
|
||||||
@@ -587,9 +705,9 @@
|
|||||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
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 (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";
|
else listenBtn.style.display = "none";
|
||||||
}
|
}
|
||||||
// Share: available to everyone.
|
// Share: available to everyone.
|
||||||
|
|||||||
@@ -246,6 +246,10 @@
|
|||||||
<button type="button" data-paper="sepia">Sepia</button>
|
<button type="button" data-paper="sepia">Sepia</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pete-reader-typerow pete-reader-voicerow" data-reader-voicerow hidden>
|
||||||
|
<span class="pete-reader-typelabel">Voice</span>
|
||||||
|
<select class="pete-reader-voice" data-reader-voice aria-label="Read-aloud voice"></select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
||||||
@@ -305,6 +309,7 @@
|
|||||||
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
||||||
window.PETE_PREFS = {{.UserPrefs}};
|
window.PETE_PREFS = {{.UserPrefs}};
|
||||||
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
||||||
|
window.PETE_TTS = {{.TTS}};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/prefs.js" defer></script>
|
<script src="/static/js/prefs.js" defer></script>
|
||||||
<script src="/static/js/weather.js" defer></script>
|
<script src="/static/js/weather.js" defer></script>
|
||||||
|
|||||||
199
internal/web/tts.go
Normal file
199
internal/web/tts.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
|
||||||
|
ttsTimeout = 60 * time.Second
|
||||||
|
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
|
||||||
|
)
|
||||||
|
|
||||||
|
// ttsVoice is one selectable voice, as sent to the client.
|
||||||
|
type ttsVoice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
|
||||||
|
// synthesize read-aloud audio server-side. It holds the validated voice
|
||||||
|
// registry and a concurrency semaphore; there is no long-lived process, each
|
||||||
|
// request spawns a short-lived piper that loads its model, synthesizes one
|
||||||
|
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
|
||||||
|
type ttsService struct {
|
||||||
|
piperBin string
|
||||||
|
voices []ttsVoice // menu order, for the client selector
|
||||||
|
models map[string]string // voice id -> absolute .onnx path
|
||||||
|
def string // default voice id
|
||||||
|
sem chan struct{} // buffered to ttsMaxConcurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTTS validates the Piper install and builds the voice registry. It returns
|
||||||
|
// (nil, nil) when TTS is disabled. A configured voice whose model file is
|
||||||
|
// missing is skipped with a warning rather than failing startup; if that leaves
|
||||||
|
// no usable voices, TTS is disabled.
|
||||||
|
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
bin := cfg.PiperBin
|
||||||
|
if bin == "" {
|
||||||
|
bin = "piper"
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath(bin); err != nil {
|
||||||
|
slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
|
||||||
|
return nil, nil
|
||||||
|
} else {
|
||||||
|
bin = p
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := cfg.VoicesDir
|
||||||
|
svc := &ttsService{piperBin: bin, models: make(map[string]string)}
|
||||||
|
|
||||||
|
want := cfg.Voices
|
||||||
|
if len(want) == 0 {
|
||||||
|
// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSuffix(name, ".onnx")
|
||||||
|
want = append(want, config.VoiceConfig{ID: id, Label: id})
|
||||||
|
}
|
||||||
|
sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range want {
|
||||||
|
if v.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
model := filepath.Join(dir, v.ID+".onnx")
|
||||||
|
if _, err := os.Stat(model); err != nil {
|
||||||
|
slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := v.Label
|
||||||
|
if label == "" {
|
||||||
|
label = v.ID
|
||||||
|
}
|
||||||
|
svc.models[v.ID] = model
|
||||||
|
svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
|
||||||
|
}
|
||||||
|
if len(svc.voices) == 0 {
|
||||||
|
slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.def = cfg.Default
|
||||||
|
if _, ok := svc.models[svc.def]; !ok {
|
||||||
|
svc.def = svc.voices[0].ID
|
||||||
|
}
|
||||||
|
svc.sem = make(chan struct{}, ttsMaxConcurrent)
|
||||||
|
slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
|
||||||
|
// can build its voice selector and know TTS is available.
|
||||||
|
func (t *ttsService) clientConfig() template.JS {
|
||||||
|
payload := struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
Voices []ttsVoice `json:"voices"`
|
||||||
|
}{Enabled: true, Default: t.def, Voices: t.voices}
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return template.JS("null")
|
||||||
|
}
|
||||||
|
return jsForScript(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTTS synthesizes one chunk of text to WAV audio with the requested
|
||||||
|
// voice. It is registered only under the authenticated route group, so callers
|
||||||
|
// are already signed in (read-aloud is a signed-in perk). The request body is
|
||||||
|
// JSON {voice, text}; the response is audio/wav.
|
||||||
|
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.tts == nil {
|
||||||
|
http.Error(w, "tts disabled", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.auth == nil || s.auth.userFromRequest(r) == nil {
|
||||||
|
http.Error(w, "sign-in required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Voice string `json:"voice"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(req.Text)
|
||||||
|
if text == "" {
|
||||||
|
http.Error(w, "empty text", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(text) > ttsMaxTextLen {
|
||||||
|
text = text[:ttsMaxTextLen]
|
||||||
|
}
|
||||||
|
model, ok := s.tts.models[req.Voice]
|
||||||
|
if !ok {
|
||||||
|
model = s.tts.models[s.tts.def] // unknown/blank voice -> default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound concurrent piper processes; give up if the client leaves or we wait
|
||||||
|
// too long for a slot rather than queueing unboundedly.
|
||||||
|
slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
|
||||||
|
defer cancelSlot()
|
||||||
|
select {
|
||||||
|
case s.tts.sem <- struct{}{}:
|
||||||
|
defer func() { <-s.tts.sem }()
|
||||||
|
case <-slotCtx.Done():
|
||||||
|
http.Error(w, "tts busy", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
|
||||||
|
// so there is no shell and nothing user-controlled reaches the arg list
|
||||||
|
// besides the model path, which is looked up from a fixed allowlist above.
|
||||||
|
cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
|
||||||
|
cmd.Stdin = strings.NewReader(text)
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &errb
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
|
||||||
|
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "audio/wav")
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
|
||||||
|
_, _ = w.Write(out.Bytes())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user