All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
- backend/: Go API server and runner binaries with PocketBase + MinIO storage - ui-v2/: SvelteKit frontend rewrite - docker-compose-new.yml: compose file for the v2 stack - .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images - scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs - .env.example: document DOCKER_BUILDKIT=1 for Colima users
161 lines
4.9 KiB
Go
161 lines
4.9 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)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|