From ba8fdaa9e0719fe394896bd1a4f94ee9fff5b631 Mon Sep 17 00:00:00 2001 From: a692570 Date: Wed, 2 Sep 2026 09:10:30 -0700 Subject: [PATCH 1/2] Add Telnyx TTS provider --- README.md | 2 +- README.zh-CN.md | 2 +- config.toml.example | 21 ++- docs/configuration.md | 8 +- docs/configuration.zh-CN.md | 8 +- docs/providers.md | 25 ++- docs/providers.zh-CN.md | 25 ++- internal/config/config.go | 19 +++ internal/tts/client.go | 7 +- internal/tts/telnyx.go | 321 ++++++++++++++++++++++++++++++++++++ internal/tts/telnyx_test.go | 171 +++++++++++++++++++ 11 files changed, 595 insertions(+), 14 deletions(-) create mode 100644 internal/tts/telnyx.go create mode 100644 internal/tts/telnyx_test.go diff --git a/README.md b/README.md index 2e5962b..f9f8166 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ StreamCore starts one layer below prompt-and-tool frameworks: the media path. Yo Details and code: [Bring your own agent](./docs/bring-your-own-agent.md) · [Agent runtime](./docs/agent-runtime.md). -Providers: Deepgram, AssemblyAI, OpenAI, Cartesia, ElevenLabs, MiniMax, Speechify, Ollama, VibeVoice (local), xAI Grok Voice (speech-to-speech), pgvector/Supabase for retrieval. See [Providers](./docs/providers.md). +Providers: Deepgram, AssemblyAI, OpenAI, Cartesia, ElevenLabs, MiniMax, Speechify, Telnyx, Ollama, VibeVoice (local), xAI Grok Voice (speech-to-speech), pgvector/Supabase for retrieval. See [Providers](./docs/providers.md). OpenAI STT supports `whisper-1`, `gpt-4o-transcribe`, and `gpt-4o-mini-transcribe` through the independent `openai.stt_model` setting. diff --git a/README.zh-CN.md b/README.zh-CN.md index f7a5600..44ecf3c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -113,7 +113,7 @@ StreamCore 位于「提示词 + 工具」类框架的下一层:媒体链路。 详情与代码:[接入你自己的智能体](./docs/bring-your-own-agent.zh-CN.md) · [智能体运行时](./docs/agent-runtime.zh-CN.md)。 -服务商:Deepgram、AssemblyAI、OpenAI、Cartesia、ElevenLabs、MiniMax、Speechify、Ollama、VibeVoice(本地)、xAI Grok Voice(语音到语音),检索支持 pgvector / Supabase。见[服务商](./docs/providers.zh-CN.md)。 +服务商:Deepgram、AssemblyAI、OpenAI、Cartesia、ElevenLabs、MiniMax、Speechify、Telnyx、Ollama、VibeVoice(本地)、xAI Grok Voice(语音到语音),检索支持 pgvector / Supabase。见[服务商](./docs/providers.zh-CN.md)。 OpenAI STT 可通过独立的 `openai.stt_model` 配置选择 `whisper-1`、`gpt-4o-transcribe` 或 `gpt-4o-mini-transcribe`。 diff --git a/config.toml.example b/config.toml.example index 5d80012..a98151f 100644 --- a/config.toml.example +++ b/config.toml.example @@ -45,7 +45,7 @@ provider = "deepgram" # Supported: aliyun, assemblyai, deepgram, openai, vibev provider = "openai" # Supported: openai, ollama, agent [tts] -provider = "cartesia" # Supported: aliyun, cartesia, deepgram, elevenlabs, mimo, minimax, speechify, vibevoice, volcengine +provider = "cartesia" # Supported: aliyun, cartesia, deepgram, elevenlabs, mimo, minimax, speechify, telnyx, vibevoice, volcengine # Provider credentials @@ -101,12 +101,13 @@ model = "gpt-4o-mini" stt_model = "whisper-1" # STT model: whisper-1, gpt-4o-transcribe, or gpt-4o-mini-transcribe system_prompt = "You are a helpful AI voice assistant. Keep your responses concise and conversational." base_url = "" # Optional. Any endpoint speaking OpenAI's protocol — DeepSeek - # (https://api.deepseek.com/v1, model deepseek-chat), Moonshot - # (https://api.moonshot.cn/v1), Qwen, MiniMax. Empty targets OpenAI. - # Prefer a non-reasoning model here: reasoning models emit their - # thinking before the answer, and on a voice call that is dead air. - # Note this is unrelated to [ollama] base_url, which speaks Ollama's - # own protocol — the two are not interchangeable + # (https://api.deepseek.com/v1, model deepseek-chat), Moonshot + # (https://api.moonshot.cn/v1), Qwen, MiniMax, Telnyx + # (https://api.telnyx.com/v2/ai, e.g. model glm-5.3). Empty targets OpenAI. + # Prefer a non-reasoning model here: reasoning models emit their + # thinking before the answer, and on a voice call that is dead air. + # Note this is unrelated to [ollama] base_url, which speaks Ollama's + # own protocol — the two are not interchangeable [ollama] base_url = "http://localhost:11434" # Ollama server URL @@ -141,6 +142,12 @@ api_key = "" # Required if tts.provider = "speechify" voice_id = "" # Optional; defaults to Geffen (geffen_32). Simba 3.2 uses its curated voice set. model = "" # Optional; defaults to simba-3.2 +[telnyx] +api_key = "" # Required if tts.provider = "telnyx" +voice = "Telnyx.Bayan.Amanda" # Any catalog voice from GET /v2/text-to-speech/voices. Availability varies by + # account; a voice your key is not provisioned for fails the dial with HTTP 403 +voice_speed = 1.0 # Playback-rate multiplier, clamped to 0.8-1.2 like the per-utterance delivery tags + [mimo] api_key = "" # Required if tts.provider = "mimo" voice = "" # Optional; defaults to mimo_default. Chinese: 茉莉 冰糖 苏打 白桦; English: Mia Chloe Milo Dean diff --git a/docs/configuration.md b/docs/configuration.md index 07b8b5f..055f8a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,7 +44,7 @@ provider = "deepgram" # aliyun | assemblyai | deepgram | openai | provider = "openai" # openai | ollama | agent [tts] -provider = "cartesia" # cartesia | deepgram | elevenlabs | mimo | minimax | speechify | vibevoice +provider = "cartesia" # cartesia | deepgram | elevenlabs | mimo | minimax | speechify | telnyx | vibevoice # [grok] # Used when realtime.provider = "grok" # api_key = "" @@ -119,6 +119,11 @@ api_key = "" voice_id = "" model = "" +[telnyx] # Telnyx hosted synthesis, used when tts.provider = "telnyx" +api_key = "" +voice = "Telnyx.Bayan.Amanda" # Any catalog voice from GET /v2/text-to-speech/voices; availability varies by account +voice_speed = 1.0 # Playback-rate multiplier, clamped to 0.8-1.2 + [minimax] api_key = "" voice_id = "" # Defaults to English_Graceful_Lady; 40+ languages available @@ -198,6 +203,7 @@ Provider keys use each provider's conventional variable name; secrets owned by t | `CARTESIA_API_KEY` | `cartesia.api_key` | | `ELEVENLABS_API_KEY` | `elevenlabs.api_key` | | `SPEECHIFY_API_KEY` | `speechify.api_key` | +| `TELNYX_API_KEY` | `telnyx.api_key` | | `MINIMAX_API_KEY` | `minimax.api_key` | | `MIMO_API_KEY` | `mimo.api_key` | | `SUPABASE_API_KEY` | `supabase.api_key` | diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 247743e..0071d2d 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -38,7 +38,7 @@ provider = "deepgram" # aliyun | assemblyai | deepgram | openai | provider = "openai" # openai | ollama | agent [tts] -provider = "cartesia" # cartesia | deepgram | elevenlabs | mimo | minimax | speechify | vibevoice +provider = "cartesia" # cartesia | deepgram | elevenlabs | mimo | minimax | speechify | telnyx | vibevoice # [grok] # Used when realtime.provider = "grok" # api_key = "" @@ -113,6 +113,11 @@ api_key = "" voice_id = "" model = "" +[telnyx] # Telnyx 托管合成,当 tts.provider = "telnyx" 时使用 +api_key = "" +voice = "Telnyx.Bayan.Amanda" # GET /v2/text-to-speech/voices 目录中的任意音色;可用性因账号而异 +voice_speed = 1.0 # 播放速率倍数,限制在 0.8-1.2 + [minimax] api_key = "" voice_id = "" # Defaults to English_Graceful_Lady; 40+ languages available @@ -181,6 +186,7 @@ voice = "en-Emma_woman" | `CARTESIA_API_KEY` | `cartesia.api_key` | | `ELEVENLABS_API_KEY` | `elevenlabs.api_key` | | `SPEECHIFY_API_KEY` | `speechify.api_key` | +| `TELNYX_API_KEY` | `telnyx.api_key` | | `MINIMAX_API_KEY` | `minimax.api_key` | | `MIMO_API_KEY` | `mimo.api_key` | | `SUPABASE_API_KEY` | `supabase.api_key` | diff --git a/docs/providers.md b/docs/providers.md index 06e0402..483da1b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -6,7 +6,7 @@ |------|-----------|----------------------| | STT | `aliyun`, `assemblyai`, `deepgram`, `openai`, `vibevoice`, `volcengine` | Matching provider API key, or a local VibeVoice ASR server | | LLM | `openai`, `ollama`, `agent` | OpenAI API key, an Ollama instance you control, or your own HTTP agent endpoint | -| TTS | `cartesia`, `deepgram`, `elevenlabs`, `mimo`, `minimax`, `speechify`, `vibevoice` | Matching provider API key, or a local VibeVoice TTS server | +| TTS | `cartesia`, `deepgram`, `elevenlabs`, `mimo`, `minimax`, `speechify`, `telnyx`, `vibevoice` | Matching provider API key, or a local VibeVoice TTS server | | Speech-to-speech | `grok` | xAI API key — replaces STT, LLM, and TTS together | | RAG (optional) | `pgvector`, `supabase` | Postgres connection string or Supabase URL + key, plus an OpenAI key for embeddings | @@ -17,6 +17,7 @@ Notes: - `llm.provider = "agent"` POSTs each turn to an HTTP endpoint you host; your agent owns memory, prompting, and tools, and replies stream back as SSE, chunked text, or JSON. See [Bring your own agent](./bring-your-own-agent.md). - `stt.provider = "vibevoice"` and `tts.provider = "vibevoice"` use local models; start the Python sidecars first. - `tts.provider = "minimax"` covers 40+ languages and is the strongest option for Mandarin. See [MiniMax TTS](#minimax-tts) for the region and model-plan caveats. +- `tts.provider = "telnyx"` is Telnyx hosted synthesis over a per-utterance WebSocket; voice availability varies by account. See [Telnyx TTS](#telnyx-tts) for the connection model and the voice catalog. - `tts.provider = "mimo"` is Xiaomi's MiMo TTS, with Chinese and English voices and optional voice cloning on the paid models. - `stt.provider = "aliyun"` is Alibaba Cloud Model Studio (DashScope) streaming ASR; `vocabulary_id` biases it toward domain terms. - `stt.provider = "volcengine"` is Doubao streaming ASR — useful where Deepgram is slow to reach or its Mandarin is not good enough. The console gives a free hourly allowance. @@ -125,6 +126,28 @@ Three things to get right: Delivery tags map onto MiniMax's emotion enum: `[warm]` and `[excited]` become `happy`, `[calm]` and `[empathetic]` become `calm`. `[empathetic]` deliberately lands on `calm` rather than `sad`, which overshoots into sounding upset on apologies and bad news. Tags with no emotion mapping still take effect through speed, which is clamped to MiniMax's 0.5–2.0 range. +## Telnyx TTS + +Telnyx hosted speech synthesis over WebSocket, streaming linear16 at the pipeline's native 16 kHz so the audio path never resamples. + +```toml +[tts] +provider = "telnyx" + +[telnyx] +api_key = "" +voice = "Telnyx.Bayan.Amanda" +voice_speed = 1.0 +``` + +Three things to know: + +- **One WebSocket per utterance.** The protocol has no per-utterance completion marker; `isFinal` arrives only after the client sends an empty-text teardown, so the client dials a fresh connection for each utterance: init, text, teardown, collect audio until the final frame, server closes. Measured against a persistent connection this costs nothing on the live path: synthesis outpaces playback (~2.4x) and first audio arrives well under a second after dial. +- **Voices are per-account.** `voice` is any catalog name from `GET /v2/text-to-speech/voices`, and availability varies by account; a voice your key is not provisioned for fails the WebSocket handshake with HTTP 403 rather than erroring mid-call. The config default (`Telnyx.Bayan.Amanda`) is a verified en-US female voice, not a guarantee for every key. +- **Telnyx LLMs need no new provider.** `openai.base_url = "https://api.telnyx.com/v2/ai"` points the existing `openai` LLM provider at Telnyx inference (e.g. model `glm-5.3`) with zero code. + +Delivery tags map onto `voice_speed` (clamped to 0.8–1.2, the same conversational band as Cartesia), and `voice_speed` in config sets the baseline pace for untagged sentences. + ## Local VibeVoice setup VibeVoice provides fully local STT and TTS with no API keys, using [VibeVoice-ASR](https://huggingface.co/mlx-community/VibeVoice-ASR-4bit) for recognition and [VibeVoice-Realtime-0.5B](https://huggingface.co/mlx-community/VibeVoice-Realtime-0.5B-6bit) for synthesis via two lightweight Python sidecars. On Apple Silicon they use [mlx-audio](https://github.com/Blaizzy/mlx-audio) (MLX); on Linux/Windows they fall back to PyTorch automatically. diff --git a/docs/providers.zh-CN.md b/docs/providers.zh-CN.md index c53a66f..bca2d7c 100644 --- a/docs/providers.zh-CN.md +++ b/docs/providers.zh-CN.md @@ -6,7 +6,7 @@ |------|-----------|----------------------| | STT | `aliyun`、`assemblyai`、`deepgram`、`openai`、`vibevoice`、`volcengine` | 对应服务商的 API key,或一个本地 VibeVoice ASR 服务 | | LLM | `openai`、`ollama`、`agent` | OpenAI API key、你自己掌控的 Ollama 实例,或你自己的 HTTP 智能体端点 | -| TTS | `cartesia`、`deepgram`、`elevenlabs`、`mimo`、`minimax`、`speechify`、`vibevoice` | 对应服务商的 API key,或一个本地 VibeVoice TTS 服务 | +| TTS | `cartesia`、`deepgram`、`elevenlabs`、`mimo`、`minimax`、`speechify`、`telnyx`、`vibevoice` | 对应服务商的 API key,或一个本地 VibeVoice TTS 服务 | | 语音到语音 | `grok` | xAI API key —— 一并取代 STT、LLM 与 TTS | | RAG(可选) | `pgvector`、`supabase` | Postgres 连接串或 Supabase URL + key,另需 OpenAI key 用于 embedding | @@ -17,6 +17,7 @@ - `llm.provider = "agent"` 把每一轮对话 POST 到你托管的 HTTP 端点;记忆、提示词与工具都由你的智能体掌控,回复以 SSE、分块文本或 JSON 流式返回。见[接入你自己的智能体](./bring-your-own-agent.zh-CN.md)。 - `stt.provider = "vibevoice"` 与 `tts.provider = "vibevoice"` 使用本地模型;请先启动 Python 边车进程。 - `tts.provider = "minimax"` 覆盖 40+ 语言,是中文场景下最强的选项。区域与套餐相关的坑见 [MiniMax TTS](#minimax-tts)。 +- `tts.provider = "telnyx"` 是 Telnyx 托管合成,每个话语一条 WebSocket 连接;音色可用性因账号而异。连接模型与音色目录见 [Telnyx TTS](#telnyx-tts)。 - `tts.provider = "mimo"` 是小米 MiMo TTS,中英文音色齐备,付费模型还支持声音克隆。 - `stt.provider = "aliyun"` 是阿里云百炼(DashScope)流式 ASR;`vocabulary_id` 可以把模型往你的领域词上带。 - `stt.provider = "volcengine"` 是豆包流式 ASR —— 适合 Deepgram 访问慢、或它的中文识别不够好的场景。控制台有免费时长可以先试。 @@ -125,6 +126,28 @@ model = "speech-2.6-turbo" 表达标签映射到 MiniMax 的情绪枚举:`[warm]` 与 `[excited]` 变成 `happy`,`[calm]` 与 `[empathetic]` 变成 `calm`。`[empathetic]` 刻意落在 `calm` 而不是 `sad` —— 后者在道歉与坏消息场景下会过头,听起来像在难过。没有情绪映射的标签仍会通过语速生效,语速被限制在 MiniMax 的 0.5–2.0 范围内。 +## Telnyx TTS + +Telnyx 托管语音合成,走 WebSocket,以流水线原生的 16 kHz linear16 流式输出,音频路径无需任何重采样。 + +```toml +[tts] +provider = "telnyx" + +[telnyx] +api_key = "" +voice = "Telnyx.Bayan.Amanda" +voice_speed = 1.0 +``` + +有三件事必须弄对: + +- **每个话语一条 WebSocket 连接。** 协议没有逐话语的完成标记:只有客户端发出空文本 teardown 后,服务端才会发 `isFinal`,因此客户端为每个话语新建一条连接:init、文本、teardown、收齐音频直到 final 帧、服务端关闭。与常驻连接相比,这在实时链路上没有代价:合成速度约为播放的 2.4 倍,拨号后不到一秒就有首个音频到达。 +- **音色因账号而异。** `voice` 是 `GET /v2/text-to-speech/voices` 目录中的任意名称,可用性因账号而异:你的 key 未开通的音色会在 WebSocket 握手阶段就返回 HTTP 403,而不是在通话中途报错。配置默认值(`Telnyx.Bayan.Amanda`)是一个已验证的 en-US 女声,并不对每个 key 都保证可用。 +- **Telnyx 的 LLM 无需新增服务商。** `openai.base_url = "https://api.telnyx.com/v2/ai"` 即可让现有的 `openai` LLM 服务商直连 Telnyx 推理(例如模型 `glm-5.3`),零代码改动。 + +表达标签映射到 `voice_speed`(限制在 0.8–1.2,与 Cartesia 相同的对话档位),配置里的 `voice_speed` 则是未打标签句子的基准语速。 + ## 本地 VibeVoice 配置 VibeVoice 提供完全本地、无需 API key 的 STT 与 TTS:识别用 [VibeVoice-ASR](https://huggingface.co/mlx-community/VibeVoice-ASR-4bit),合成用 [VibeVoice-Realtime-0.5B](https://huggingface.co/mlx-community/VibeVoice-Realtime-0.5B-6bit),通过两个轻量 Python 边车进程运行。在 Apple Silicon 上使用 [mlx-audio](https://github.com/Blaizzy/mlx-audio)(MLX);在 Linux/Windows 上自动回退到 PyTorch。 diff --git a/internal/config/config.go b/internal/config/config.go index 2c86957..f43c088 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,7 @@ type Config struct { Cartesia CartesiaConfig `toml:"cartesia"` ElevenLabs ElevenLabsConfig `toml:"elevenlabs"` Speechify SpeechifyConfig `toml:"speechify"` + Telnyx TelnyxConfig `toml:"telnyx"` MiniMax MiniMaxConfig `toml:"minimax"` MiMo MiMoConfig `toml:"mimo"` Pgvector PgvectorConfig `toml:"pgvector"` @@ -292,6 +293,20 @@ type SpeechifyConfig struct { Model string `toml:"model"` } +// TelnyxConfig configures Telnyx hosted speech synthesis, used when +// tts.provider = "telnyx". +type TelnyxConfig struct { + APIKey string `toml:"api_key"` + // Voice is a catalog voice from GET /v2/text-to-speech/voices. Empty + // defaults to Telnyx.Bayan.Amanda. Availability varies by account, and + // a voice the account is not provisioned for fails the WebSocket + // handshake with HTTP 403. + Voice string `toml:"voice"` + // VoiceSpeed is a playback-rate multiplier (1.0 = normal), clamped to + // the 0.8-1.2 conversational band. Zero defaults to 1.0. + VoiceSpeed float64 `toml:"voice_speed"` +} + type MiMoConfig struct { APIKey string `toml:"api_key"` // Voice is a built-in voice name, passed through verbatim — the Chinese @@ -434,6 +449,9 @@ func Load(path string) (*Config, error) { if cfg.Cartesia.MaxConcurrency == 0 { cfg.Cartesia.MaxConcurrency = 3 } + if cfg.Telnyx.VoiceSpeed == 0 { + cfg.Telnyx.VoiceSpeed = 1.0 + } setDefault(&cfg.LLM.Provider, "openai") setDefault(&cfg.TTS.Provider, "cartesia") setDefault(&cfg.OpenAI.Model, "gpt-4o-mini") @@ -549,6 +567,7 @@ var envOverrides = []struct { {"CARTESIA_API_KEY", func(c *Config) *string { return &c.Cartesia.APIKey }}, {"ELEVENLABS_API_KEY", func(c *Config) *string { return &c.ElevenLabs.APIKey }}, {"SPEECHIFY_API_KEY", func(c *Config) *string { return &c.Speechify.APIKey }}, + {"TELNYX_API_KEY", func(c *Config) *string { return &c.Telnyx.APIKey }}, {"MINIMAX_API_KEY", func(c *Config) *string { return &c.MiniMax.APIKey }}, {"MIMO_API_KEY", func(c *Config) *string { return &c.MiMo.APIKey }}, {"SUPABASE_API_KEY", func(c *Config) *string { return &c.Supabase.APIKey }}, diff --git a/internal/tts/client.go b/internal/tts/client.go index 39f0f1e..5bd6cdd 100644 --- a/internal/tts/client.go +++ b/internal/tts/client.go @@ -72,6 +72,11 @@ func newProviderClient(cfg *config.Config) (Client, error) { return nil, ErrMissingAPIKey{Provider: "speechify", Field: "[speechify] api_key"} } return NewSpeechifyClient(cfg.Speechify.APIKey, cfg.Speechify.VoiceID, cfg.Speechify.Model), nil + case "telnyx": + if cfg.Telnyx.APIKey == "" { + return nil, ErrMissingAPIKey{Provider: "telnyx", Field: "[telnyx] api_key"} + } + return NewTelnyxClient(cfg.Telnyx.APIKey, cfg.Telnyx.Voice, cfg.Telnyx.VoiceSpeed), nil case "minimax": if cfg.MiniMax.APIKey == "" { return nil, ErrMissingAPIKey{Provider: "minimax", Field: "[minimax] api_key"} @@ -96,7 +101,7 @@ func newProviderClient(cfg *config.Config) (Client, error) { case "vibevoice": return NewVibeVoiceClient(cfg.VibeVoice.TTSURL, cfg.VibeVoice.Voice), nil default: - return nil, fmt.Errorf("unknown tts provider %q (supported: aliyun, cartesia, deepgram, elevenlabs, mimo, minimax, speechify, vibevoice, volcengine)", cfg.TTS.Provider) + return nil, fmt.Errorf("unknown tts provider %q (supported: aliyun, cartesia, deepgram, elevenlabs, mimo, minimax, speechify, telnyx, vibevoice, volcengine)", cfg.TTS.Provider) } } diff --git a/internal/tts/telnyx.go b/internal/tts/telnyx.go new file mode 100644 index 0000000..5cba5c0 --- /dev/null +++ b/internal/tts/telnyx.go @@ -0,0 +1,321 @@ +package tts + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "time" + + "github.com/gorilla/websocket" +) + +const ( + // telnyxTTSURL is the streaming speech synthesis WebSocket endpoint. + telnyxTTSURL = "wss://api.telnyx.com/v2/text-to-speech/speech" + // defaultTelnyxVoice is a verified en-US female catalog voice. Voice + // availability varies by account, so this is a starting point rather + // than a guarantee: the catalog lives at + // GET /v2/text-to-speech/voices. + defaultTelnyxVoice = "Telnyx.Bayan.Amanda" +) + +// TelnyxClient implements the Client interface against Telnyx's streaming +// text-to-speech WebSocket API. +// +// The protocol has no per-utterance completion marker: the server emits its +// isFinal frame only after the client sends an empty-text teardown, and on a +// persistent connection there is no way to tell one utterance's audio from +// the next. The adapter therefore opens one connection per utterance: dial, +// init, text, teardown, collect audio until isFinal, server closes. Measured +// against the persistent alternative this costs nothing on the live path: +// synthesis outpaces playback ~2.4x, first audio lands well under a second +// after dial, and every completion heuristic is avoided. +type TelnyxClient struct { + apiKey string + voice string + voiceSpeed float64 +} + +// NewTelnyxClient creates a Telnyx TTS client. Empty voice defaults to +// Telnyx.Bayan.Amanda; zero voiceSpeed defaults to 1.0. +func NewTelnyxClient(apiKey, voice string, voiceSpeed float64) Client { + if voice == "" { + voice = defaultTelnyxVoice + } + if voiceSpeed == 0 { + voiceSpeed = 1.0 + } + return &TelnyxClient{ + apiKey: apiKey, + voice: voice, + voiceSpeed: voiceSpeed, + } +} + +// telnyxInitFrame is the required opener: its single-space text carries no +// speech, and it is where the per-utterance voice settings ride. +type telnyxInitFrame struct { + Text string `json:"text"` + VoiceSettings telnyxVoiceSettings `json:"voice_settings"` +} + +type telnyxVoiceSettings struct { + VoiceSpeed float64 `json:"voice_speed"` +} + +// telnyxTextFrame carries synthesis text. The server buffers plain text; +// sentence boundaries alone do not start synthesis, so the adapter always +// sets flush and the whole utterance is synthesized immediately. +type telnyxTextFrame struct { + Text string `json:"text"` + Flush bool `json:"flush"` +} + +// telnyxTeardownFrame tells the server no more text is coming. It flushes +// remaining synthesis, makes the server emit its isFinal frame, and closes +// the connection: it is the only completion marker the protocol has. +type telnyxTeardownFrame struct { + Text string `json:"text"` +} + +// telnyxForceJSON is pre-marshaled so the barge-in interrupt path never +// depends on marshaling succeeding at cancel time. +var telnyxForceJSON = []byte(`{"force":true}`) + +// telnyxServerFrame is one message from the Telnyx speech WebSocket. The +// protocol has no type discriminator; frames are routed by which keys are +// populated. +type telnyxServerFrame struct { + // Audio is a base64 linear16 PCM chunk. Null on cache-status + // notifications and on the final frame. + Audio *string `json:"audio"` + // IsFinal marks the synthesis-complete frame, emitted only after the + // empty-text teardown. + IsFinal bool `json:"isFinal"` + // Error carries a server-side failure. + Error string `json:"error"` +} + +// routeTelnyxFrame classifies one server frame: pcm is non-nil for audio +// frames, final reports the teardown-complete frame, and err is set for +// error frames and undecodable messages. Frames with no audio and isFinal +// false (cache-status notifications) come back all zero; the caller +// skips them silently. +func routeTelnyxFrame(msg []byte) (pcm []byte, final bool, err error) { + var f telnyxServerFrame + if jerr := json.Unmarshal(msg, &f); jerr != nil { + return nil, false, fmt.Errorf("telnyx frame decode: %w", jerr) + } + if f.Error != "" { + return nil, false, fmt.Errorf("telnyx: %s", f.Error) + } + if f.IsFinal { + return nil, true, nil + } + if f.Audio != nil && *f.Audio != "" { + pcm, derr := base64.StdEncoding.DecodeString(*f.Audio) + if derr != nil { + return nil, false, fmt.Errorf("telnyx audio decode: %w", derr) + } + return pcm, false, nil + } + return nil, false, nil +} + +// Synthesize produces the full audio for text in one shot by collecting all +// chunks from SynthesizeStream. +func (c *TelnyxClient) Synthesize(ctx context.Context, text string) ([]byte, error) { + stream, err := c.SynthesizeStream(ctx, text) + if err != nil { + return nil, err + } + + var buf []byte + for chunk := range stream { + if chunk.Err != nil { + return nil, chunk.Err + } + buf = append(buf, chunk.PCM...) + } + + log.Printf("[tts:telnyx] synthesized %d bytes for %d chars of text", len(buf), len(text)) + return buf, nil +} + +// SynthesizeStream produces PCM audio incrementally via a channel. One +// utterance maps to one WebSocket connection: dial, init with the resolved +// voice speed, the full text with flush so synthesis starts immediately, +// then the empty-text teardown that makes the server emit isFinal and +// close. +func (c *TelnyxClient) SynthesizeStream(ctx context.Context, text string) (<-chan StreamChunk, error) { + return c.SynthesizeStreamWithControls(ctx, text, VoiceControls{}) +} + +// SynthesizeStreamWithControls is SynthesizeStream with per-utterance voice +// controls. The speed rides the init frame of each per-utterance connection, +// so it can change from one utterance to the next. +func (c *TelnyxClient) SynthesizeStreamWithControls(ctx context.Context, text string, vc VoiceControls) (<-chan StreamChunk, error) { + conn, err := c.dial(ctx) + if err != nil { + return nil, err + } + + init := telnyxInitFrame{ + Text: " ", + VoiceSettings: telnyxVoiceSettings{VoiceSpeed: c.resolveSpeed(vc)}, + } + if err := writeTelnyxJSON(conn, init); err != nil { + conn.Close() + return nil, err + } + if err := writeTelnyxJSON(conn, telnyxTextFrame{Text: text, Flush: true}); err != nil { + conn.Close() + return nil, err + } + if err := writeTelnyxJSON(conn, telnyxTeardownFrame{}); err != nil { + conn.Close() + return nil, err + } + + outCh := make(chan StreamChunk, 16) + go c.pump(ctx, conn, outCh, len(text)) + return outCh, nil +} + +// dial opens the per-utterance WebSocket. Audio format and sample rate are +// pinned to the pipeline's native linear16 16 kHz mono, so the audio path +// needs no resampling. +func (c *TelnyxClient) dial(ctx context.Context) (*websocket.Conn, error) { + endpoint := fmt.Sprintf("%s?voice=%s&audio_format=linear16&sample_rate=16000", + telnyxTTSURL, url.QueryEscape(c.voice)) + header := http.Header{} + header.Set("Authorization", "Bearer "+c.apiKey) + + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + conn, resp, err := dialer.DialContext(ctx, endpoint, header) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusForbidden { + // A 403 at the handshake is how Telnyx reports an unknown + // voice or one the account is not provisioned for. Point + // the operator at the voice catalog rather than leaving a + // bare handshake failure. + return nil, fmt.Errorf("telnyx dial: voice %q is not available for this account (HTTP 403); list valid voices with GET /v2/text-to-speech/voices: %w", c.voice, err) + } + return nil, fmt.Errorf("telnyx dial: %w", err) + } + return conn, nil +} + +// resolveSpeed picks the voice_speed for one utterance: the per-utterance +// control when set, otherwise the configured baseline. Both are clamped to +// the same conversational band as Cartesia so a bad delivery tag can never +// produce comedy audio on a live call. +func (c *TelnyxClient) resolveSpeed(vc VoiceControls) float64 { + speed := vc.Speed + if speed == 0 { + speed = c.voiceSpeed + } + if speed < 0.8 { + speed = 0.8 + } + if speed > 1.2 { + speed = 1.2 + } + return speed +} + +// pump reads frames until the final frame or an error, forwarding audio +// chunks to outCh; it closes outCh and the connection on exit. The done +// channel stops the cancellation watcher once the pump has returned, so the +// watcher never outlives the stream. +func (c *TelnyxClient) pump(ctx context.Context, conn *websocket.Conn, outCh chan<- StreamChunk, textLen int) { + defer close(outCh) + defer conn.Close() + + done := make(chan struct{}) + defer close(done) + + // Cancellation must unblock a stalled read: closing the connection + // makes ReadMessage return, so a barge-in cannot leak a goroutine + // parked on a silent server. The force frame first is best-effort + // server-side cleanup, bounded by a short write deadline. + go func() { + select { + case <-ctx.Done(): + c.sendForce(conn) + conn.Close() + case <-done: + } + }() + + totalBytes := 0 + for { + _, msg, err := conn.ReadMessage() + if err != nil { + if ctx.Err() == nil { + if !isNormalClose(err) { + log.Printf("[tts:telnyx] read error: %v", err) + } + // A connection drop before the final frame is an + // error, not a silently short utterance. + select { + case outCh <- StreamChunk{Err: fmt.Errorf("telnyx: connection lost: %w", err)}: + case <-ctx.Done(): + } + } + return + } + + pcm, final, ferr := routeTelnyxFrame(msg) + if ferr != nil { + select { + case outCh <- StreamChunk{Err: ferr}: + case <-ctx.Done(): + } + return + } + if final { + // Log total bytes and expected duration for diagnosing + // speed/sample-rate issues. At 16 kHz mono 16-bit: + // 32000 bytes/sec → 32 bytes/ms. + log.Printf("[tts:telnyx] streamed %d bytes (%.0f ms expected) for %d chars of text", + totalBytes, float64(totalBytes)/32.0, textLen) + return + } + if len(pcm) > 0 { + totalBytes += len(pcm) + select { + case outCh <- StreamChunk{PCM: pcm}: + case <-ctx.Done(): + return + } + } + // No audio and not final: a cache-status notification. Skip. + } +} + +// sendForce interrupts in-flight synthesis on the server. Best-effort with a +// short write deadline: it runs on the barge-in path, where the caller is +// already gone and a stalled server must not hold the goroutine. +func (c *TelnyxClient) sendForce(conn *websocket.Conn) { + _ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, telnyxForceJSON); err != nil { + log.Printf("[tts:telnyx] force send failed: %v", err) + } +} + +// writeTelnyxJSON marshals v and writes it as a text message. +func writeTelnyxJSON(conn *websocket.Conn, v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("telnyx marshal: %w", err) + } + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + return fmt.Errorf("telnyx write: %w", err) + } + return nil +} diff --git a/internal/tts/telnyx_test.go b/internal/tts/telnyx_test.go new file mode 100644 index 0000000..1eb6b76 --- /dev/null +++ b/internal/tts/telnyx_test.go @@ -0,0 +1,171 @@ +package tts + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +// The audio frames carry base64 linear16 PCM. A routing mistake here either +// plays the base64 text itself as noise or drops the chunk entirely. +func TestTelnyxRoutesAudioFrame(t *testing.T) { + pcm := []byte{0x01, 0x02, 0x03, 0x04} + frame := `{"audio":"` + base64.StdEncoding.EncodeToString(pcm) + `","cached":true,"isFinal":false,"text":null}` + + got, final, err := routeTelnyxFrame([]byte(frame)) + if err != nil { + t.Fatalf("audio frame rejected: %v", err) + } + if final { + t.Error("audio frame reported final, want it to be a chunk") + } + if string(got) != string(pcm) { + t.Errorf("pcm = %v, want %v", got, pcm) + } +} + +// isFinal arrives only after the teardown and marks the end of the whole +// connection; treating any other frame as final would cut audio short. +func TestTelnyxRoutesFinalFrame(t *testing.T) { + pcm, final, err := routeTelnyxFrame([]byte(`{"audio":null,"isFinal":true,"text":""}`)) + if err != nil { + t.Fatalf("final frame rejected: %v", err) + } + if !final { + t.Error("final frame not reported as final") + } + if pcm != nil { + t.Errorf("final frame carried audio %v, want none", pcm) + } +} + +// The server interleaves cache-status notifications with audio: no audio +// payload, isFinal false. They must come back all zero so the caller can +// skip them: emitting one would surface an empty chunk mid-utterance. +func TestTelnyxSkipsCacheStatusFrame(t *testing.T) { + pcm, final, err := routeTelnyxFrame([]byte(`{"audio":null,"cached":false,"isFinal":false,"text":"StreamCore is the layer that handles the media path."}`)) + if err != nil { + t.Fatalf("cache-status frame rejected: %v", err) + } + if final { + t.Error("cache-status frame reported final, want it skipped") + } + if pcm != nil { + t.Errorf("cache-status frame produced audio %v, want none", pcm) + } +} + +// Error frames carry the failure in a plain error field. Swallowing them +// turns a failed synthesis into an utterance of silence, which reads +// downstream as the agent simply having nothing to say. +func TestTelnyxSurfacesErrorFrame(t *testing.T) { + _, _, err := routeTelnyxFrame([]byte(`{"error":"synthesis failed for the requested voice"}`)) + if err == nil { + t.Fatal("error frame not surfaced") + } + if !strings.Contains(err.Error(), "synthesis failed") { + t.Errorf("error = %v, want it to carry the server message", err) + } +} + +// Undecodable audio must fail loudly rather than emit garbage bytes: a bad +// base64 chunk would play as noise on the live path. +func TestTelnyxRejectsBadBase64(t *testing.T) { + if _, _, err := routeTelnyxFrame([]byte(`{"audio":"!!not base64!!","isFinal":false}`)); err == nil { + t.Fatal("invalid base64 accepted, want a decode error") + } +} + +// A frame that is not JSON means the connection is misbehaving; continuing +// would silently drop whatever audio followed. +func TestTelnyxRejectsMalformedJSON(t *testing.T) { + if _, _, err := routeTelnyxFrame([]byte(`{not json`)); err == nil { + t.Fatal("malformed frame accepted, want a decode error") + } +} + +// The client frame layout is fixed by the service: the init frame is the +// required single-space opener that carries voice_speed, and the teardown +// must serialize an empty text field: the empty text is what makes the +// server flush and emit its final frame. +func TestTelnyxClientFrameWireFormat(t *testing.T) { + init, err := json.Marshal(telnyxInitFrame{Text: " ", VoiceSettings: telnyxVoiceSettings{VoiceSpeed: 1.5}}) + if err != nil { + t.Fatal(err) + } + if got, want := string(init), `{"text":" ","voice_settings":{"voice_speed":1.5}}`; got != want { + t.Errorf("init frame = %s, want %s", got, want) + } + + text, err := json.Marshal(telnyxTextFrame{Text: "hello", Flush: true}) + if err != nil { + t.Fatal(err) + } + if got, want := string(text), `{"text":"hello","flush":true}`; got != want { + t.Errorf("text frame = %s, want %s", got, want) + } + + teardown, err := json.Marshal(telnyxTeardownFrame{}) + if err != nil { + t.Fatal(err) + } + if got, want := string(teardown), `{"text":""}`; got != want { + t.Errorf("teardown frame = %s, want %s", got, want) + } + + if got, want := string(telnyxForceJSON), `{"force":true}`; got != want { + t.Errorf("force frame = %s, want %s", got, want) + } +} + +// Speed follows the Cartesia band: a delivery tag outside 0.8–1.2 must never +// reach the wire, where it would produce comedy-speed audio on a live call. +// Unset controls fall back to the configured baseline. +func TestTelnyxSpeedClamp(t *testing.T) { + c := NewTelnyxClient("k", "", 0).(*TelnyxClient) + + cases := []struct { + name string + vc VoiceControls + want float64 + }{ + {"unset controls use the configured default", VoiceControls{}, 1.0}, + {"in-band control passes through", VoiceControls{Speed: 1.1}, 1.1}, + {"too slow clamps up", VoiceControls{Speed: 0.5}, 0.8}, + {"too fast clamps down", VoiceControls{Speed: 1.5}, 1.2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := c.resolveSpeed(tc.vc); got != tc.want { + t.Errorf("resolveSpeed(%v) = %v, want %v", tc.vc, got, tc.want) + } + }) + } +} + +// The configured baseline is clamped too: it feeds resolveSpeed's fallback, +// so an out-of-band config value would otherwise reach every utterance. +func TestTelnyxConfiguredSpeedIsClamped(t *testing.T) { + c := NewTelnyxClient("k", "", 2.0).(*TelnyxClient) + if got := c.resolveSpeed(VoiceControls{}); got != 1.2 { + t.Errorf("resolveSpeed with baseline 2.0 = %v, want 1.2", got) + } +} + +// Empty voice and speed fall back rather than being sent blank: an empty +// voice fails the WebSocket handshake with an opaque 403. +func TestTelnyxDefaults(t *testing.T) { + c := NewTelnyxClient("k", "", 0).(*TelnyxClient) + if c.voice != defaultTelnyxVoice { + t.Errorf("voice = %q, want %q", c.voice, defaultTelnyxVoice) + } + if c.voiceSpeed != 1.0 { + t.Errorf("voiceSpeed = %v, want 1.0", c.voiceSpeed) + } + + custom := NewTelnyxClient("k", "Telnyx.Najdi.Fahad", 1.1).(*TelnyxClient) + if custom.voice != "Telnyx.Najdi.Fahad" || custom.voiceSpeed != 1.1 { + t.Errorf("explicit values overwritten: %+v", custom) + } +} From 5f51e676b6e84e4160c24e0ff01ae507145794e5 Mon Sep 17 00:00:00 2001 From: a692570 Date: Wed, 2 Sep 2026 09:26:50 -0700 Subject: [PATCH 2/2] Use Qwen3TTS Delta as the default Telnyx voice --- config.toml.example | 5 +++-- docs/configuration.md | 2 +- docs/configuration.zh-CN.md | 2 +- docs/providers.md | 4 ++-- docs/providers.zh-CN.md | 4 ++-- internal/config/config.go | 2 +- internal/tts/telnyx.go | 4 ++-- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/config.toml.example b/config.toml.example index a98151f..4a97e8b 100644 --- a/config.toml.example +++ b/config.toml.example @@ -144,8 +144,9 @@ model = "" # Optional; defaults to simba-3.2 [telnyx] api_key = "" # Required if tts.provider = "telnyx" -voice = "Telnyx.Bayan.Amanda" # Any catalog voice from GET /v2/text-to-speech/voices. Availability varies by - # account; a voice your key is not provisioned for fails the dial with HTTP 403 +voice = "Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03" # Any catalog voice from GET /v2/text-to-speech/voices + # (Qwen3TTS voices use UUID ids). Availability varies by account; a voice your + # key is not provisioned for fails the dial with HTTP 403 voice_speed = 1.0 # Playback-rate multiplier, clamped to 0.8-1.2 like the per-utterance delivery tags [mimo] diff --git a/docs/configuration.md b/docs/configuration.md index 055f8a5..fa90135 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -121,7 +121,7 @@ model = "" [telnyx] # Telnyx hosted synthesis, used when tts.provider = "telnyx" api_key = "" -voice = "Telnyx.Bayan.Amanda" # Any catalog voice from GET /v2/text-to-speech/voices; availability varies by account +voice = "Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03" # Any catalog voice from GET /v2/text-to-speech/voices; availability varies by account voice_speed = 1.0 # Playback-rate multiplier, clamped to 0.8-1.2 [minimax] diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 0071d2d..7c42098 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -115,7 +115,7 @@ model = "" [telnyx] # Telnyx 托管合成,当 tts.provider = "telnyx" 时使用 api_key = "" -voice = "Telnyx.Bayan.Amanda" # GET /v2/text-to-speech/voices 目录中的任意音色;可用性因账号而异 +voice = "Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03" # GET /v2/text-to-speech/voices 目录中的任意音色;可用性因账号而异 voice_speed = 1.0 # 播放速率倍数,限制在 0.8-1.2 [minimax] diff --git a/docs/providers.md b/docs/providers.md index 483da1b..4532ba0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -136,14 +136,14 @@ provider = "telnyx" [telnyx] api_key = "" -voice = "Telnyx.Bayan.Amanda" +voice = "Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03" voice_speed = 1.0 ``` Three things to know: - **One WebSocket per utterance.** The protocol has no per-utterance completion marker; `isFinal` arrives only after the client sends an empty-text teardown, so the client dials a fresh connection for each utterance: init, text, teardown, collect audio until the final frame, server closes. Measured against a persistent connection this costs nothing on the live path: synthesis outpaces playback (~2.4x) and first audio arrives well under a second after dial. -- **Voices are per-account.** `voice` is any catalog name from `GET /v2/text-to-speech/voices`, and availability varies by account; a voice your key is not provisioned for fails the WebSocket handshake with HTTP 403 rather than erroring mid-call. The config default (`Telnyx.Bayan.Amanda`) is a verified en-US female voice, not a guarantee for every key. +- **Voices are per-account.** `voice` is any catalog name from `GET /v2/text-to-speech/voices`, and availability varies by account; a voice your key is not provisioned for fails the WebSocket handshake with HTTP 403 rather than erroring mid-call. The config default (`Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03`) is a verified Qwen3TTS voice (Delta, female in the catalog); not a guarantee for every key. - **Telnyx LLMs need no new provider.** `openai.base_url = "https://api.telnyx.com/v2/ai"` points the existing `openai` LLM provider at Telnyx inference (e.g. model `glm-5.3`) with zero code. Delivery tags map onto `voice_speed` (clamped to 0.8–1.2, the same conversational band as Cartesia), and `voice_speed` in config sets the baseline pace for untagged sentences. diff --git a/docs/providers.zh-CN.md b/docs/providers.zh-CN.md index bca2d7c..222ba37 100644 --- a/docs/providers.zh-CN.md +++ b/docs/providers.zh-CN.md @@ -136,14 +136,14 @@ provider = "telnyx" [telnyx] api_key = "" -voice = "Telnyx.Bayan.Amanda" +voice = "Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03" voice_speed = 1.0 ``` 有三件事必须弄对: - **每个话语一条 WebSocket 连接。** 协议没有逐话语的完成标记:只有客户端发出空文本 teardown 后,服务端才会发 `isFinal`,因此客户端为每个话语新建一条连接:init、文本、teardown、收齐音频直到 final 帧、服务端关闭。与常驻连接相比,这在实时链路上没有代价:合成速度约为播放的 2.4 倍,拨号后不到一秒就有首个音频到达。 -- **音色因账号而异。** `voice` 是 `GET /v2/text-to-speech/voices` 目录中的任意名称,可用性因账号而异:你的 key 未开通的音色会在 WebSocket 握手阶段就返回 HTTP 403,而不是在通话中途报错。配置默认值(`Telnyx.Bayan.Amanda`)是一个已验证的 en-US 女声,并不对每个 key 都保证可用。 +- **音色因账号而异。** `voice` 是 `GET /v2/text-to-speech/voices` 目录中的任意名称,可用性因账号而异:你的 key 未开通的音色会在 WebSocket 握手阶段就返回 HTTP 403,而不是在通话中途报错。配置默认值(`Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03`)是一个已验证的 Qwen3TTS 音色(目录中名为 Delta,女声),并不对每个 key 都保证可用。 - **Telnyx 的 LLM 无需新增服务商。** `openai.base_url = "https://api.telnyx.com/v2/ai"` 即可让现有的 `openai` LLM 服务商直连 Telnyx 推理(例如模型 `glm-5.3`),零代码改动。 表达标签映射到 `voice_speed`(限制在 0.8–1.2,与 Cartesia 相同的对话档位),配置里的 `voice_speed` 则是未打标签句子的基准语速。 diff --git a/internal/config/config.go b/internal/config/config.go index f43c088..699597b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -298,7 +298,7 @@ type SpeechifyConfig struct { type TelnyxConfig struct { APIKey string `toml:"api_key"` // Voice is a catalog voice from GET /v2/text-to-speech/voices. Empty - // defaults to Telnyx.Bayan.Amanda. Availability varies by account, and + // defaults to the Qwen3TTS voice Delta (Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03). Availability varies by account, and // a voice the account is not provisioned for fails the WebSocket // handshake with HTTP 403. Voice string `toml:"voice"` diff --git a/internal/tts/telnyx.go b/internal/tts/telnyx.go index 5cba5c0..f9f3421 100644 --- a/internal/tts/telnyx.go +++ b/internal/tts/telnyx.go @@ -20,7 +20,7 @@ const ( // availability varies by account, so this is a starting point rather // than a guarantee: the catalog lives at // GET /v2/text-to-speech/voices. - defaultTelnyxVoice = "Telnyx.Bayan.Amanda" + defaultTelnyxVoice = "Telnyx.Qwen3TTS.d9348e0d-988a-42cc-a64e-18093fe45c03" ) // TelnyxClient implements the Client interface against Telnyx's streaming @@ -41,7 +41,7 @@ type TelnyxClient struct { } // NewTelnyxClient creates a Telnyx TTS client. Empty voice defaults to -// Telnyx.Bayan.Amanda; zero voiceSpeed defaults to 1.0. +// the Qwen3TTS voice Delta; zero voiceSpeed defaults to 1.0. func NewTelnyxClient(apiKey, voice string, voiceSpeed float64) Client { if voice == "" { voice = defaultTelnyxVoice