Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Successful in 28s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 49s
Release / Docker / caddy (push) Successful in 59s
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Successful in 46s
Release / Docker / ui (push) Successful in 1m31s
Release / Docker / backend (push) Successful in 3m27s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Failing after 32s
- Add StreamAudioWAV() to pocket-tts and Kokoro clients; pocket-tts streams
raw WAV directly (no ffmpeg), Kokoro requests response_format:wav with stream:true
- GET /api/audio-stream supports ?format=wav for lower-latency first-byte delivery;
WAV cached separately in MinIO as {slug}/{n}/{voice}.wav
- Add GET /api/admin/audio/jobs with optional ?slug filter
- Add POST /api/admin/audio/bulk {slug, voice, from, to, skip_existing, force}
where skip_existing=true (default) resumes interrupted bulk jobs
- Add POST /api/admin/audio/cancel-bulk {slug} to cancel all pending/running tasks
- Add CancelAudioTasksBySlug to taskqueue.Producer + asynqqueue implementation
- Add AudioObjectKeyExt to bookstore.AudioStore for format-aware MinIO keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
255 lines
8.1 KiB
Go
255 lines
8.1 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.
|
|
//
|
|
// 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
|
|
}
|