Some checks failed
Release / Test backend (push) Successful in 36s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Failing after 45s
CI / Test backend (pull_request) Successful in 39s
CI / Check ui (pull_request) Failing after 11s
CI / Docker / ui (pull_request) Has been skipped
CI / Docker / caddy (pull_request) Failing after 11s
Release / Docker / backend (push) Failing after 48s
Release / Docker / runner (push) Failing after 58s
Release / Upload source maps (push) Failing after 39s
CI / Docker / backend (pull_request) Failing after 34s
CI / Docker / runner (pull_request) Failing after 30s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Has been skipped
- New backend/internal/pockettts package: POST /tts client for kyutai-labs/pocket-tts; streams WAV response and transcodes to MP3 via ffmpeg so the rest of the pipeline stays format-agnostic - config.PocketTTS struct + POCKET_TTS_URL env var - runner.Dependencies.PocketTTS field; runAudioTask routes by voice name: pockettts.IsPocketTTSVoice (alba, marius, javert, …) → pocket-tts, everything else → kokoro-fastapi - Dockerfile: runner stage switched from distroless/static to alpine:3.21 with ffmpeg + ca-certificates installed - homelab compose: runner gets POCKET_TTS_URL=http://pocket-tts:8000 - Doppler prd + prd_homelab: KOKORO_URL=https://tts.libnovel.cc, POCKET_TTS_URL=https://pocket-tts.libnovel.cc
160 lines
5.2 KiB
Go
160 lines
5.2 KiB
Go
// Package pockettts provides a client for the kyutai-labs/pocket-tts TTS service.
|
|
//
|
|
// pocket-tts exposes a non-OpenAI API:
|
|
//
|
|
// POST /tts (multipart form: text, voice_url) → streaming WAV
|
|
// GET /health → {"status":"healthy"}
|
|
//
|
|
// GenerateAudio streams the WAV response and transcodes it to MP3 using ffmpeg,
|
|
// so callers receive MP3 bytes — the same format as the kokoro client — and the
|
|
// rest of the pipeline does not need to care which TTS engine was used.
|
|
//
|
|
// Predefined voices (pass the bare name as the voice parameter):
|
|
//
|
|
// alba, marius, javert, jean, fantine, cosette, eponine, azelma,
|
|
// anna, vera, charles, paul, george, mary, jane, michael, eve,
|
|
// bill_boerst, peter_yearsley, stuart_bell
|
|
package pockettts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// PredefinedVoices is the set of voice names built into pocket-tts.
|
|
// The runner uses this to decide which TTS engine to route a task to.
|
|
var PredefinedVoices = map[string]struct{}{
|
|
"alba": {}, "marius": {}, "javert": {}, "jean": {},
|
|
"fantine": {}, "cosette": {}, "eponine": {}, "azelma": {},
|
|
"anna": {}, "vera": {}, "charles": {}, "paul": {},
|
|
"george": {}, "mary": {}, "jane": {}, "michael": {},
|
|
"eve": {}, "bill_boerst": {}, "peter_yearsley": {}, "stuart_bell": {},
|
|
}
|
|
|
|
// IsPocketTTSVoice reports whether voice is served by pocket-tts.
|
|
func IsPocketTTSVoice(voice string) bool {
|
|
_, ok := PredefinedVoices[voice]
|
|
return ok
|
|
}
|
|
|
|
// Client is the interface for interacting with the pocket-tts service.
|
|
type Client interface {
|
|
// GenerateAudio synthesises text using the given voice and returns MP3 bytes.
|
|
// Voice must be one of the predefined pocket-tts voice names.
|
|
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
|
|
|
// ListVoices returns the available predefined voice names.
|
|
ListVoices(ctx context.Context) ([]string, error)
|
|
}
|
|
|
|
// httpClient is the concrete pocket-tts HTTP client.
|
|
type httpClient struct {
|
|
baseURL string
|
|
http *http.Client
|
|
}
|
|
|
|
// New returns a Client targeting baseURL (e.g. "https://pocket-tts.libnovel.cc").
|
|
func New(baseURL string) Client {
|
|
return &httpClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
http: &http.Client{Timeout: 10 * time.Minute},
|
|
}
|
|
}
|
|
|
|
// GenerateAudio posts to POST /tts and transcodes the WAV response to MP3
|
|
// using the system ffmpeg binary. Requires ffmpeg to be on PATH (available in
|
|
// the runner Docker image via Alpine's ffmpeg package).
|
|
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
|
if text == "" {
|
|
return nil, fmt.Errorf("pockettts: empty text")
|
|
}
|
|
if voice == "" {
|
|
voice = "alba"
|
|
}
|
|
|
|
// ── Build multipart form ──────────────────────────────────────────────────
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
|
|
if err := mw.WriteField("text", text); err != nil {
|
|
return nil, fmt.Errorf("pockettts: write text field: %w", err)
|
|
}
|
|
// pocket-tts accepts a predefined voice name as voice_url.
|
|
if err := mw.WriteField("voice_url", voice); err != nil {
|
|
return nil, fmt.Errorf("pockettts: write voice_url field: %w", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return nil, fmt.Errorf("pockettts: close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.baseURL+"/tts", &body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pockettts: build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pockettts: request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil, fmt.Errorf("pockettts: server returned %d", resp.StatusCode)
|
|
}
|
|
|
|
wavData, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pockettts: read response body: %w", err)
|
|
}
|
|
|
|
// ── Transcode WAV → MP3 via ffmpeg ────────────────────────────────────────
|
|
mp3Data, err := wavToMP3(ctx, wavData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pockettts: transcode to mp3: %w", err)
|
|
}
|
|
return mp3Data, nil
|
|
}
|
|
|
|
// ListVoices returns the statically known predefined voice names.
|
|
// pocket-tts has no REST endpoint for listing voices.
|
|
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
|
voices := make([]string, 0, len(PredefinedVoices))
|
|
for v := range PredefinedVoices {
|
|
voices = append(voices, v)
|
|
}
|
|
return voices, nil
|
|
}
|
|
|
|
// wavToMP3 converts raw WAV bytes to MP3 using ffmpeg.
|
|
// ffmpeg reads from stdin (pipe:0) and writes to stdout (pipe:1).
|
|
func wavToMP3(ctx context.Context, wav []byte) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx,
|
|
"ffmpeg",
|
|
"-hide_banner", "-loglevel", "error",
|
|
"-i", "pipe:0", // read WAV from stdin
|
|
"-f", "mp3", // output format
|
|
"-q:a", "2", // VBR quality ~190 kbps
|
|
"pipe:1", // write MP3 to stdout
|
|
)
|
|
cmd.Stdin = bytes.NewReader(wav)
|
|
|
|
var out, stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg: %w (stderr: %s)", err, stderr.String())
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|