diff --git a/deploy/piper/Dockerfile b/deploy/piper/Dockerfile index aa25267..8f30412 100644 --- a/deploy/piper/Dockerfile +++ b/deploy/piper/Dockerfile @@ -30,7 +30,7 @@ EXPOSE 5000 # the honest check: it proves the model loaded, not just that a port is open. HEALTHCHECK --interval=60s --timeout=20s --start-period=180s --retries=3 \ CMD python -c "import os,urllib.request,json; \ -urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:'+os.environ['PIPER_PORT']+'/', \ +urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:'+os.environ['PIPER_PORT']+'/synthesize', \ data=json.dumps({'text':'ok','voice':os.environ['PIPER_VOICE']}).encode(), \ headers={'Content-Type':'application/json'}), timeout=15).read(1)" diff --git a/docker-compose.yml b/docker-compose.yml index 6d498ff..5ac8743 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,9 @@ services: # separate containers; the handler maps language → instance from config. TTS_ENDPOINT: http://piper-en:5000 TTS_ENDPOINT_ZH: http://piper-zh:5000 + # The sidecars run piper-tts 1.6.0, which serves synthesis on + # /synthesize; millenia's older server keeps the default "/". + TTS_PATH: /synthesize # The companion's bedtime nag and night mode read the local clock. TZ: ${TZ:-Europe/Lisbon} volumes: diff --git a/internal/config/config.go b/internal/config/config.go index 392f482..3e87b5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,11 @@ type Config struct { TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech TTSVoiceEN string // Piper voice id for English (e.g. en_US-amy-medium) TTSVoiceZH string // Piper voice id for Chinese (e.g. zh_CN-huayan-medium) + // TTSPath is the path Piper serves synthesis on. Piper moved it from "/" to + // "/synthesize" in 1.6.0 with an unchanged request body, so this is a + // version knob, not a feature: millenia's older server keeps the default, + // the containerised sidecars set "/synthesize". + TTSPath string TTSCacheDir string // on-disk store for synthesized clips (content-addressed) TTSTimeout time.Duration TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg @@ -56,6 +61,7 @@ func Load() *Config { TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""), TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"), TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"), + TTSPath: env("TTS_PATH", "/"), TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"), TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second), TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"), diff --git a/internal/tts/handler.go b/internal/tts/handler.go index c8863c1..bd1b806 100644 --- a/internal/tts/handler.go +++ b/internal/tts/handler.go @@ -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 } diff --git a/internal/tts/handler_test.go b/internal/tts/handler_test.go index 00ee1ff..5019b9b 100644 --- a/internal/tts/handler_test.go +++ b/internal/tts/handler_test.go @@ -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})