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.
|
||||
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.
|
||||
# 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
|
||||
|
||||
@@ -28,6 +28,7 @@ type WebConfig struct {
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
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
|
||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||
@@ -53,6 +54,29 @@ type PushConfig struct {
|
||||
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).
|
||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||
// 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)
|
||||
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
|
||||
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
@@ -166,6 +167,10 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
PostingEnabled: s.postingEnabled,
|
||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||
TTS: template.JS("null"),
|
||||
}
|
||||
if s.tts != nil {
|
||||
d.TTS = s.tts.clientConfig()
|
||||
}
|
||||
if s.auth != nil {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
|
||||
@@ -58,6 +58,7 @@ type Server struct {
|
||||
srv *http.Server
|
||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||
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
|
||||
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()
|
||||
|
||||
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/unsubscribe", s.handlePushUnsubscribe)
|
||||
}
|
||||
if s.tts != nil {
|
||||
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
||||
}
|
||||
}
|
||||
|
||||
s.srv = &http.Server{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -246,6 +246,10 @@
|
||||
<button type="button" data-paper="sepia">Sepia</button>
|
||||
</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>
|
||||
<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_PREFS = {{.UserPrefs}};
|
||||
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
||||
window.PETE_TTS = {{.TTS}};
|
||||
</script>
|
||||
<script src="/static/js/prefs.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