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:
prosolis
2026-07-07 23:00:27 -07:00
parent fceeb12ad5
commit dbcb459908
9 changed files with 436 additions and 33 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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())
}