Make Piper's synthesis path configurable (TTS_PATH)

piper-tts 1.6.0 moved synthesis from POST / to POST /synthesize, with an
identical request body; the VPS sidecars run 1.6.0 and returned 405 to
every read-aloud request, while millenia's older server still expects /.
Rather than pinning both deployments to one Piper release, the path is
configuration -- default "/" keeps millenia working untouched, and the
compose stack sets /synthesize. The container healthcheck moves with it,
since it was probing the old route too.
This commit is contained in:
prosolis
2026-07-26 23:15:01 -07:00
parent df6bc4989c
commit 2363ef2d37
5 changed files with 76 additions and 2 deletions
+22 -1
View File
@@ -56,6 +56,25 @@ var formats = map[string]audioFormat{
ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
}
// synthPath normalises TTS_PATH into a leading-slash path with no trailing
// slash, so it concatenates cleanly onto a route's endpoint.
//
// Piper moved synthesis from `POST /` to `POST /synthesize` in 1.6.0, and the
// request body is identical either side of that change. Rather than pinning
// every deployment to one Piper release, the path is configuration: millenia
// keeps the default `/` its installed server expects, and the containerised
// 1.6.0 sidecars on the VPS set `/synthesize`.
func synthPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || p == "/" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return strings.TrimRight(p, "/")
}
// route is the Piper instance and voice id serving one language. Each Piper
// HTTP server loads exactly one model, so distinct languages mean distinct
// endpoints (e.g. English on :5005, Chinese on :5006).
@@ -67,6 +86,7 @@ type route struct {
// Handler proxies synthesis to Piper and caches the result on disk.
type Handler struct {
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
synthURI string // path Piper serves synthesis on (see TTSPath)
cacheDir string
format audioFormat
client *http.Client
@@ -105,6 +125,7 @@ func New(cfg *config.Config) (*Handler, bool) {
return &Handler{
routes: routes,
synthURI: synthPath(cfg.TTSPath),
cacheDir: cfg.TTSCacheDir,
format: format,
client: &http.Client{Timeout: cfg.TTSTimeout},
@@ -207,7 +228,7 @@ func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte
"voice": rt.voice,
"length_scale": lengthScale,
})
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+"/", bytes.NewReader(body))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+h.synthURI, bytes.NewReader(body))
if err != nil {
return nil, err
}
+44
View File
@@ -36,12 +36,56 @@ func newHandler(t *testing.T, endpoint string) *Handler {
t.Helper()
return &Handler{
routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}},
synthURI: synthPath("/"),
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
}
// Piper 1.6.0 serves synthesis on /synthesize and 405s on /. The path is
// configuration (TTS_PATH) so one Petal build talks to either server version;
// this asserts the configured path is the one actually requested.
func TestSynthUsesConfiguredPath(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.URL.Path != "/synthesize" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write([]byte("RIFF....fake-wav"))
}))
t.Cleanup(srv.Close)
h := newHandler(t, srv.URL)
h.synthURI = synthPath("/synthesize")
if rr := post(t, h, "hello there", "en-US"); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (piper saw path %q)", rr.Code, gotPath)
}
if gotPath != "/synthesize" {
t.Errorf("piper path = %q, want /synthesize", gotPath)
}
}
func TestSynthPathNormalisation(t *testing.T) {
cases := map[string]string{
"": "/",
"/": "/",
"synthesize": "/synthesize",
"/synthesize": "/synthesize",
"/synthesize/": "/synthesize",
" /v1/tts ": "/v1/tts",
}
for in, want := range cases {
if got := synthPath(in); got != want {
t.Errorf("synthPath(%q) = %q, want %q", in, got, want)
}
}
}
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})