// 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. // // StreamAudioMP3 is the streaming variant: it returns an io.ReadCloser that // yields MP3-encoded audio incrementally as pocket-tts generates it, without // buffering the full output. // // 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) // StreamAudioMP3 synthesises text and returns an io.ReadCloser that streams // MP3-encoded audio incrementally via a live ffmpeg transcode pipe. // The caller must always close the returned ReadCloser. StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) // StreamAudioWAV synthesises text and returns an io.ReadCloser that streams // raw WAV audio directly from pocket-tts without any transcoding. // The stream begins with a WAV header followed by 16-bit PCM frames at 16 kHz. // The caller must always close the returned ReadCloser. StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, 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" } resp, err := c.postTTS(ctx, text, voice) if err != nil { return nil, err } defer resp.Body.Close() 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 } // StreamAudioMP3 posts to POST /tts and returns an io.ReadCloser that delivers // MP3 bytes as pocket-tts generates WAV frames. ffmpeg runs as a subprocess // with stdin connected to the live WAV stream and stdout piped to the caller. // The caller must always close the returned ReadCloser. func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) { if text == "" { return nil, fmt.Errorf("pockettts: empty text") } if voice == "" { voice = "alba" } resp, err := c.postTTS(ctx, text, voice) if err != nil { return nil, err } // Start ffmpeg: read WAV from stdin (the live HTTP body), write MP3 to stdout. cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0", // WAV from stdin "-f", "mp3", // output format "-q:a", "2", // VBR ~190 kbps "pipe:1", // MP3 to stdout ) cmd.Stdin = resp.Body pr, pw := io.Pipe() cmd.Stdout = pw var stderrBuf bytes.Buffer cmd.Stderr = &stderrBuf if err := cmd.Start(); err != nil { resp.Body.Close() return nil, fmt.Errorf("pockettts: start ffmpeg: %w", err) } // Close the write end of the pipe when ffmpeg exits, propagating any error. go func() { waitErr := cmd.Wait() resp.Body.Close() if waitErr != nil { pw.CloseWithError(fmt.Errorf("ffmpeg: %w (stderr: %s)", waitErr, stderrBuf.String())) } else { pw.Close() } }() return pr, nil } // StreamAudioWAV posts to POST /tts and returns an io.ReadCloser that delivers // raw WAV bytes directly from pocket-tts — no ffmpeg transcoding required. // The first bytes will be a WAV header (RIFF/fmt chunk) followed by PCM frames. // The caller must always close the returned ReadCloser. func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) { if text == "" { return nil, fmt.Errorf("pockettts: empty text") } if voice == "" { voice = "alba" } resp, err := c.postTTS(ctx, text, voice) if err != nil { return nil, err } return resp.Body, 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 } // postTTS sends a multipart POST /tts request and returns the raw response. // The caller is responsible for closing resp.Body. func (c *httpClient) postTTS(ctx context.Context, text, voice string) (*http.Response, error) { 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) } 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) } if resp.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() return nil, fmt.Errorf("pockettts: server returned %d", resp.StatusCode) } return resp, 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 }