Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Failing after 55s
Release / Test backend (push) Successful in 56s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 1m6s
Release / Docker / caddy (push) Successful in 41s
CI / Backend (pull_request) Successful in 58s
CI / UI (pull_request) Successful in 55s
Release / Docker / runner (push) Failing after 55s
Release / Docker / backend (push) Failing after 1m23s
Release / Gitea Release (push) Has been skipped
Add GET /api/audio-stream/{slug}/{n}?voice= that streams MP3 audio to the
client as TTS generates it, while simultaneously uploading to MinIO. On
subsequent requests the endpoint redirects to the presigned MinIO URL,
skipping generation entirely.
- PocketTTS: StreamAudioMP3 pipes live WAV response body through ffmpeg
(streaming transcode — no full-buffer wait)
- Kokoro: StreamAudioMP3 uses stream:true mode, returning MP3 frames
directly without the two-step download-link flow
- AudioStore: PutAudioStream added for multipart MinIO upload from reader
- WriteTimeout bumped 60s → 15min to accommodate full-chapter streams
- X-Accel-Buffering: no header disables Caddy/nginx response buffering
210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
// Package kokoro provides a client for the Kokoro-FastAPI TTS service.
|
|
//
|
|
// The Kokoro API is an OpenAI-compatible audio speech API that returns a
|
|
// download link (X-Download-Path header) instead of streaming audio directly.
|
|
// GenerateAudio handles the two-step flow: POST /v1/audio/speech → GET /v1/download/{file}.
|
|
package kokoro
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client is the interface for interacting with the Kokoro TTS service.
|
|
type Client interface {
|
|
// GenerateAudio synthesises text using voice and returns raw MP3 bytes.
|
|
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
|
|
|
// StreamAudioMP3 synthesises text and returns an io.ReadCloser that streams
|
|
// MP3-encoded audio incrementally. Uses the kokoro-fastapi streaming mode
|
|
// (stream:true), which delivers MP3 frames as they are generated without
|
|
// waiting for the full output. The caller must always close the ReadCloser.
|
|
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
|
|
|
// ListVoices returns the available voice IDs. Falls back to an empty slice
|
|
// on error — callers should treat an empty list as "service unavailable".
|
|
ListVoices(ctx context.Context) ([]string, error)
|
|
}
|
|
|
|
// httpClient is the concrete Kokoro HTTP client.
|
|
type httpClient struct {
|
|
baseURL string
|
|
http *http.Client
|
|
}
|
|
|
|
// New returns a Kokoro Client targeting baseURL (e.g. "https://kokoro.example.com").
|
|
func New(baseURL string) Client {
|
|
return &httpClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
http: &http.Client{Timeout: 10 * time.Minute},
|
|
}
|
|
}
|
|
|
|
// GenerateAudio calls POST /v1/audio/speech (return_download_link=true) and then
|
|
// downloads the resulting MP3 from GET /v1/download/{filename}.
|
|
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
|
if text == "" {
|
|
return nil, fmt.Errorf("kokoro: empty text")
|
|
}
|
|
if voice == "" {
|
|
voice = "af_bella"
|
|
}
|
|
|
|
// ── Step 1: request generation ────────────────────────────────────────────
|
|
reqBody, err := json.Marshal(map[string]any{
|
|
"model": "kokoro",
|
|
"input": text,
|
|
"voice": voice,
|
|
"response_format": "mp3",
|
|
"speed": 1.0,
|
|
"stream": false,
|
|
"return_download_link": true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: build speech request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: speech request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("kokoro: speech returned %d", resp.StatusCode)
|
|
}
|
|
|
|
dlPath := resp.Header.Get("X-Download-Path")
|
|
if dlPath == "" {
|
|
return nil, fmt.Errorf("kokoro: no X-Download-Path header in response")
|
|
}
|
|
filename := dlPath
|
|
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
|
|
filename = dlPath[idx+1:]
|
|
}
|
|
if filename == "" {
|
|
return nil, fmt.Errorf("kokoro: empty filename in X-Download-Path: %q", dlPath)
|
|
}
|
|
|
|
// ── Step 2: download the generated file ───────────────────────────────────
|
|
dlURL := c.baseURL + "/v1/download/" + filename
|
|
dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, dlURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: build download request: %w", err)
|
|
}
|
|
|
|
dlResp, err := c.http.Do(dlReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: download request: %w", err)
|
|
}
|
|
defer dlResp.Body.Close()
|
|
|
|
if dlResp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("kokoro: download returned %d", dlResp.StatusCode)
|
|
}
|
|
|
|
data, err := io.ReadAll(dlResp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: read download body: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// StreamAudioMP3 calls POST /v1/audio/speech with stream:true and returns an
|
|
// io.ReadCloser that delivers MP3 frames as kokoro generates them.
|
|
// kokoro-fastapi emits raw MP3 bytes when stream mode is enabled — no download
|
|
// redirect; the response body IS the audio stream.
|
|
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
|
if text == "" {
|
|
return nil, fmt.Errorf("kokoro: empty text")
|
|
}
|
|
if voice == "" {
|
|
voice = "af_bella"
|
|
}
|
|
|
|
reqBody, err := json.Marshal(map[string]any{
|
|
"model": "kokoro",
|
|
"input": text,
|
|
"voice": voice,
|
|
"response_format": "mp3",
|
|
"speed": 1.0,
|
|
"stream": true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: marshal stream request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: build stream request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: stream request: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("kokoro: stream returned %d", resp.StatusCode)
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
|
|
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
|
c.baseURL+"/v1/audio/voices", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: build voices request: %w", err)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro: voices request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil, fmt.Errorf("kokoro: voices returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Voices []string `json:"voices"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("kokoro: decode voices response: %w", err)
|
|
}
|
|
return result.Voices, nil
|
|
}
|
|
|
|
// VoiceSampleKey returns the MinIO object key for a voice sample MP3.
|
|
// Key: _voice-samples/{voice}.mp3 (sanitised).
|
|
func VoiceSampleKey(voice string) string {
|
|
safe := strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
|
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
|
return r
|
|
}
|
|
return '_'
|
|
}, voice)
|
|
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
|
|
}
|