Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 37s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
The aura-2-en model enforces a hard 2 000-character limit per request. Chapters routinely exceed this, producing 413 errors. GenerateAudio now splits the stripped text into ≤1 800-char chunks at paragraph → sentence → space → hard-cut boundaries, calls the API once per chunk, and concatenates the MP3 frames. Callers (runner, streaming handler) are unchanged. StreamAudioMP3/WAV inherit the fix automatically since they delegate to GenerateAudio.
316 lines
9.7 KiB
Go
316 lines
9.7 KiB
Go
// Package cfai provides a client for Cloudflare Workers AI Text-to-Speech models.
|
|
//
|
|
// The Cloudflare Workers AI REST API is used to run TTS models:
|
|
//
|
|
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
|
|
// Authorization: Bearer {apiToken}
|
|
// Content-Type: application/json
|
|
// { "text": "...", "speaker": "luna" }
|
|
//
|
|
// → 200 audio/mpeg — raw MP3 bytes
|
|
//
|
|
// Currently supported model: @cf/deepgram/aura-2-en (40 English speakers).
|
|
// Voice IDs are prefixed with "cfai:" to distinguish them from Kokoro/pocket-tts
|
|
// voices (e.g. "cfai:luna", "cfai:orion").
|
|
//
|
|
// The API is batch-only (no streaming), so GenerateAudio waits for the full
|
|
// response. There is no 100-second Cloudflare proxy timeout because we are
|
|
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
|
|
// homelab tunnel.
|
|
//
|
|
// The aura-2-en model enforces a hard 2 000-character limit per request.
|
|
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
|
|
// and concatenates the resulting MP3 frames.
|
|
package cfai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// DefaultModel is the Cloudflare Workers AI TTS model used by default.
|
|
DefaultModel = "@cf/deepgram/aura-2-en"
|
|
|
|
// voicePrefix is the prefix used to namespace CF AI voice IDs.
|
|
voicePrefix = "cfai:"
|
|
)
|
|
|
|
// aura2Speakers is the exhaustive list of speakers supported by aura-2-en.
|
|
var aura2Speakers = []string{
|
|
"amalthea", "andromeda", "apollo", "arcas", "aries", "asteria",
|
|
"athena", "atlas", "aurora", "callista", "cora", "cordelia",
|
|
"delia", "draco", "electra", "harmonia", "helena", "hera",
|
|
"hermes", "hyperion", "iris", "janus", "juno", "jupiter",
|
|
"luna", "mars", "minerva", "neptune", "odysseus", "ophelia",
|
|
"orion", "orpheus", "pandora", "phoebe", "pluto", "saturn",
|
|
"thalia", "theia", "vesta", "zeus",
|
|
}
|
|
|
|
// femaleSpeakers is the set of aura-2-en speaker names that are female voices.
|
|
var femaleSpeakers = map[string]struct{}{
|
|
"amalthea": {}, "andromeda": {}, "aries": {}, "asteria": {},
|
|
"athena": {}, "aurora": {}, "callista": {}, "cora": {},
|
|
"cordelia": {}, "delia": {}, "electra": {}, "harmonia": {},
|
|
"helena": {}, "hera": {}, "iris": {}, "juno": {},
|
|
"luna": {}, "minerva": {}, "ophelia": {}, "pandora": {},
|
|
"phoebe": {}, "thalia": {}, "theia": {}, "vesta": {},
|
|
}
|
|
|
|
// IsCFAIVoice reports whether voice is served by the Cloudflare AI client.
|
|
// CF AI voices use the "cfai:" prefix, e.g. "cfai:luna".
|
|
func IsCFAIVoice(voice string) bool {
|
|
return strings.HasPrefix(voice, voicePrefix)
|
|
}
|
|
|
|
// SpeakerName strips the "cfai:" prefix and returns the bare speaker name.
|
|
// If voice is not a CF AI voice the original string is returned unchanged.
|
|
func SpeakerName(voice string) string {
|
|
return strings.TrimPrefix(voice, voicePrefix)
|
|
}
|
|
|
|
// VoiceID returns the full voice ID (with prefix) for a bare speaker name.
|
|
func VoiceID(speaker string) string {
|
|
return voicePrefix + speaker
|
|
}
|
|
|
|
// VoiceSampleKey returns the MinIO object key for a CF AI voice sample MP3.
|
|
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)
|
|
}
|
|
|
|
// IsFemale reports whether the given CF AI voice ID (with or without prefix)
|
|
// is a female speaker.
|
|
func IsFemale(voice string) bool {
|
|
speaker := SpeakerName(voice)
|
|
_, ok := femaleSpeakers[speaker]
|
|
return ok
|
|
}
|
|
|
|
// Speakers returns all available bare speaker names for aura-2-en.
|
|
func Speakers() []string {
|
|
out := make([]string, len(aura2Speakers))
|
|
copy(out, aura2Speakers)
|
|
return out
|
|
}
|
|
|
|
// Client is the interface for interacting with Cloudflare Workers AI TTS.
|
|
type Client interface {
|
|
// GenerateAudio synthesises text using the given voice (e.g. "cfai:luna")
|
|
// and returns raw MP3 bytes.
|
|
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
|
|
|
// StreamAudioMP3 is not natively supported by the CF AI batch API.
|
|
// It buffers the full response and returns an io.ReadCloser over the bytes,
|
|
// so callers can use it like a stream without special-casing.
|
|
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
|
|
|
// StreamAudioWAV is not natively supported; the CF AI model returns MP3.
|
|
// This method returns the same MP3 bytes wrapped as an io.ReadCloser.
|
|
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
|
|
|
// ListVoices returns all available voice IDs (with the "cfai:" prefix).
|
|
ListVoices(ctx context.Context) ([]string, error)
|
|
}
|
|
|
|
// httpClient is the concrete CF AI HTTP client.
|
|
type httpClient struct {
|
|
accountID string
|
|
apiToken string
|
|
model string
|
|
http *http.Client
|
|
}
|
|
|
|
// New returns a Client for the given Cloudflare account and API token.
|
|
// model defaults to DefaultModel when empty.
|
|
func New(accountID, apiToken, model string) Client {
|
|
if model == "" {
|
|
model = DefaultModel
|
|
}
|
|
return &httpClient{
|
|
accountID: accountID,
|
|
apiToken: apiToken,
|
|
model: model,
|
|
http: &http.Client{Timeout: 5 * time.Minute},
|
|
}
|
|
}
|
|
|
|
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
|
|
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
|
|
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
|
|
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
|
if text == "" {
|
|
return nil, fmt.Errorf("cfai: empty text")
|
|
}
|
|
speaker := SpeakerName(voice)
|
|
if speaker == "" {
|
|
speaker = "luna"
|
|
}
|
|
|
|
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
|
|
var combined []byte
|
|
for _, chunk := range chunks {
|
|
part, err := c.generateChunk(ctx, chunk, speaker)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
combined = append(combined, part...)
|
|
}
|
|
return combined, nil
|
|
}
|
|
|
|
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
|
|
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
|
|
body, err := json.Marshal(map[string]any{
|
|
"text": text,
|
|
"speaker": speaker,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cfai: marshal request: %w", err)
|
|
}
|
|
|
|
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
|
|
c.accountID, c.model)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cfai: build request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cfai: request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("cfai: server returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
mp3, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cfai: read response: %w", err)
|
|
}
|
|
return mp3, nil
|
|
}
|
|
|
|
// splitText splits src into chunks of at most maxChars characters each.
|
|
// It tries to break at paragraph boundaries first, then at sentence-ending
|
|
// punctuation (. ! ?), and falls back to the nearest space.
|
|
func splitText(src string, maxChars int) []string {
|
|
if len(src) <= maxChars {
|
|
return []string{src}
|
|
}
|
|
|
|
var chunks []string
|
|
remaining := src
|
|
|
|
for len(remaining) > 0 {
|
|
if len(remaining) <= maxChars {
|
|
chunks = append(chunks, strings.TrimSpace(remaining))
|
|
break
|
|
}
|
|
|
|
// Search window: the first maxChars bytes of remaining.
|
|
// Use byte length here because the API limit is in bytes/chars for ASCII;
|
|
// for safety we operate on rune-aware slices.
|
|
window := remaining
|
|
if len(window) > maxChars {
|
|
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
|
|
window = runeSlice(remaining, maxChars)
|
|
}
|
|
|
|
cut := -1
|
|
|
|
// 1. Prefer paragraph break (\n\n or \n).
|
|
if i := strings.LastIndex(window, "\n\n"); i > 0 {
|
|
cut = i + 2
|
|
} else if i := strings.LastIndex(window, "\n"); i > 0 {
|
|
cut = i + 1
|
|
}
|
|
|
|
// 2. Fall back to sentence-ending punctuation followed by a space.
|
|
if cut < 0 {
|
|
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
|
|
if i := strings.LastIndex(window, punct); i > 0 {
|
|
candidate := i + len(punct)
|
|
if cut < 0 || candidate > cut {
|
|
cut = candidate
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Last resort: nearest space.
|
|
if cut < 0 {
|
|
if i := strings.LastIndex(window, " "); i > 0 {
|
|
cut = i + 1
|
|
}
|
|
}
|
|
|
|
// 4. Hard cut at maxChars runes if no boundary found.
|
|
if cut < 0 {
|
|
cut = len(window)
|
|
}
|
|
|
|
chunk := strings.TrimSpace(remaining[:cut])
|
|
if chunk != "" {
|
|
chunks = append(chunks, chunk)
|
|
}
|
|
remaining = remaining[cut:]
|
|
}
|
|
|
|
return chunks
|
|
}
|
|
|
|
// runeSlice returns the first n runes of s as a string.
|
|
func runeSlice(s string, n int) string {
|
|
count := 0
|
|
for i := range s {
|
|
if count == n {
|
|
return s[:i]
|
|
}
|
|
count++
|
|
}
|
|
return s
|
|
}
|
|
|
|
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
|
|
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
|
mp3, err := c.GenerateAudio(ctx, text, voice)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return io.NopCloser(bytes.NewReader(mp3)), nil
|
|
}
|
|
|
|
// StreamAudioWAV generates audio (MP3) and wraps it as an io.ReadCloser.
|
|
// Note: the CF AI aura-2-en model returns MP3 regardless of the method name.
|
|
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
|
return c.StreamAudioMP3(ctx, text, voice)
|
|
}
|
|
|
|
// ListVoices returns all available CF AI voice IDs (with the "cfai:" prefix).
|
|
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
|
ids := make([]string, len(aura2Speakers))
|
|
for i, s := range aura2Speakers {
|
|
ids[i] = VoiceID(s)
|
|
}
|
|
return ids, nil
|
|
}
|