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:
@@ -30,7 +30,7 @@ EXPOSE 5000
|
|||||||
# the honest check: it proves the model loaded, not just that a port is open.
|
# 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 \
|
HEALTHCHECK --interval=60s --timeout=20s --start-period=180s --retries=3 \
|
||||||
CMD python -c "import os,urllib.request,json; \
|
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(), \
|
data=json.dumps({'text':'ok','voice':os.environ['PIPER_VOICE']}).encode(), \
|
||||||
headers={'Content-Type':'application/json'}), timeout=15).read(1)"
|
headers={'Content-Type':'application/json'}), timeout=15).read(1)"
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ services:
|
|||||||
# separate containers; the handler maps language → instance from config.
|
# separate containers; the handler maps language → instance from config.
|
||||||
TTS_ENDPOINT: http://piper-en:5000
|
TTS_ENDPOINT: http://piper-en:5000
|
||||||
TTS_ENDPOINT_ZH: http://piper-zh: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.
|
# The companion's bedtime nag and night mode read the local clock.
|
||||||
TZ: ${TZ:-Europe/Lisbon}
|
TZ: ${TZ:-Europe/Lisbon}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ type Config struct {
|
|||||||
TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech
|
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)
|
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)
|
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)
|
TTSCacheDir string // on-disk store for synthesized clips (content-addressed)
|
||||||
TTSTimeout time.Duration
|
TTSTimeout time.Duration
|
||||||
TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
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", ""),
|
TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""),
|
||||||
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
|
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
|
||||||
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
|
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
|
||||||
|
TTSPath: env("TTS_PATH", "/"),
|
||||||
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
|
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
|
||||||
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
|
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
|
||||||
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
|
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
|
||||||
|
|||||||
+22
-1
@@ -56,6 +56,25 @@ var formats = map[string]audioFormat{
|
|||||||
ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
|
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
|
// route is the Piper instance and voice id serving one language. Each Piper
|
||||||
// HTTP server loads exactly one model, so distinct languages mean distinct
|
// HTTP server loads exactly one model, so distinct languages mean distinct
|
||||||
// endpoints (e.g. English on :5005, Chinese on :5006).
|
// 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.
|
// Handler proxies synthesis to Piper and caches the result on disk.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
|
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
|
||||||
|
synthURI string // path Piper serves synthesis on (see TTSPath)
|
||||||
cacheDir string
|
cacheDir string
|
||||||
format audioFormat
|
format audioFormat
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -105,6 +125,7 @@ func New(cfg *config.Config) (*Handler, bool) {
|
|||||||
|
|
||||||
return &Handler{
|
return &Handler{
|
||||||
routes: routes,
|
routes: routes,
|
||||||
|
synthURI: synthPath(cfg.TTSPath),
|
||||||
cacheDir: cfg.TTSCacheDir,
|
cacheDir: cfg.TTSCacheDir,
|
||||||
format: format,
|
format: format,
|
||||||
client: &http.Client{Timeout: cfg.TTSTimeout},
|
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,
|
"voice": rt.voice,
|
||||||
"length_scale": lengthScale,
|
"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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,56 @@ func newHandler(t *testing.T, endpoint string) *Handler {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
return &Handler{
|
return &Handler{
|
||||||
routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}},
|
routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}},
|
||||||
|
synthURI: synthPath("/"),
|
||||||
cacheDir: t.TempDir(),
|
cacheDir: t.TempDir(),
|
||||||
format: formats["wav"],
|
format: formats["wav"],
|
||||||
client: http.DefaultClient,
|
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 {
|
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
|
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
|
||||||
|
|||||||
Reference in New Issue
Block a user