// 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 }