Skip to content

feat: add Telnyx STT and TTS providers - #39

Open
a692570 wants to merge 2 commits into
ZeframLou:mainfrom
a692570:feat/telnyx-stt-tts-providers
Open

a692570 wants to merge 2 commits into
ZeframLou:mainfrom
a692570:feat/telnyx-stt-tts-providers

Conversation

@a692570

@a692570 a692570 commented Jul 19, 2026

Copy link
Copy Markdown

What this changes

Adds Telnyx STT and TTS providers so that when you're already using Telnyx as your phone provider, you can use Telnyx for speech-to-text and text-to-speech too, with the same API key. No separate OpenAI account needed for speech services.

CallMe already recommends Telnyx as the default phone provider ("50% cheaper"). This PR completes the "Telnyx everywhere" story: phone, STT, and TTS all on one account, one key, one bill.

Files

File Change
server/src/providers/stt-telnyx.ts New. TelnyxSTTProvider implementing RealtimeSTTProvider. Streaming STT via WebSocket.
server/src/providers/tts-telnyx.ts New. TelnyxTTSProvider implementing TTSProvider. Streaming TTS via WebSocket.
server/src/providers/index.ts Wire Telnyx into the provider factory. Add STTProviderType, sttProvider config field, Telnyx-specific env vars. Auto-detect telnyx as default STT when CALLME_PHONE_PROVIDER=telnyx. Relax OpenAI key requirement when using Telnyx for both STT and TTS.
.env.example Document Telnyx STT/TTS env vars.
README.md Update variables tables with Telnyx STT/TTS options.

How it works

STT (wss://api.telnyx.com/v2/speech-to-text/transcription):

  • Streaming transcription over WebSocket. Config via URL query params (transcription_engine, input_format, sample_rate, language).
  • Accepts mu-law audio directly (8kHz mono), which is the format the phone bridge already produces. No audio conversion needed, unlike the OpenAI Realtime STT path.
  • Configurable engine: Telnyx, Deepgram, Google, or Azure.

TTS (wss://api.telnyx.com/v2/text-to-speech/speech):

  • Streaming synthesis over WebSocket. Returns raw PCM audio (base64-encoded), matching the existing TTSProvider.synthesize() interface.
  • Voice catalog at GET /v2/text-to-speech/voices (AWS Polly, Azure, Google, ElevenLabs, Resemble, xAI). Default: AWS.Polly.Matthew-Neural.

Both providers fall back to CALLME_PHONE_AUTH_TOKEN when CALLME_TELNYX_API_KEY is not set, so zero extra config is needed when already using Telnyx for phone.

How I tested it

  • tsc --noEmit passes (no type errors)
  • Verified the Telnyx STT WebSocket returns transcripts against the live API: connected to wss://api.telnyx.com/v2/speech-to-text/transcription?transcription_engine=Telnyx&input_format=linear16&sample_rate=8000, sent raw PCM binary frames, received {"transcript":" you","confidence":null,"is_final":true}.
  • Verified the Telnyx TTS REST endpoint returns audio (MP3) against the live API. The WebSocket path returns raw PCM per the pipecat Telnyx TTS service protocol (init frame {"text":" ","voice_settings":{"voice_speed":N}}, text frames {"text":"..."}, audio frames {"audio":"<base64>"}, final frame {"isFinal":true}).
  • No new dependencies added. Uses the existing ws package for WebSocket connections.

Environment variables

Variable Default Description
CALLME_STT_PROVIDER telnyx* openai-realtime or telnyx. *Auto-defaults to telnyx when CALLME_PHONE_PROVIDER=telnyx
CALLME_TTS_PROVIDER openai openai, kokoro, or telnyx
CALLME_TELNYX_API_KEY - Telnyx API key for STT/TTS (defaults to CALLME_PHONE_AUTH_TOKEN)
CALLME_TELNYX_STT_ENGINE Telnyx STT engine: Telnyx, Deepgram, Google, Azure
CALLME_TELNYX_STT_INPUT_FORMAT mulaw Audio format: mulaw, linear16, alaw
CALLME_TELNYX_STT_LANGUAGE en-US BCP-47 language code
CALLME_TELNYX_TTS_VOICE AWS.Polly.Matthew-Neural Voice ID (see /v2/text-to-speech/voices)

Abhishek added 2 commits July 19, 2026 09:28
When using Telnyx as the phone provider, you can now use Telnyx for
speech-to-text and text-to-speech too, reusing the same API key. No
separate OpenAI account needed for speech services.

STT: streaming transcription via wss://api.telnyx.com/v2/speech-to-text/transcription
- Accepts mu-law audio directly (8kHz mono) from the phone bridge, no conversion needed
- Configurable engine (Telnyx, Deepgram, Google, Azure) and input format
- Auto-detected as default STT when CALLME_PHONE_PROVIDER=telnyx

TTS: streaming synthesis via wss://api.telnyx.com/v2/text-to-speech/speech
- Returns raw PCM audio (matches the existing TTSProvider interface)
- Voice catalog at GET /v2/text-to-speech/voices (AWS Polly, Azure, Google, etc)
- Default voice: AWS.Polly.Matthew-Neural (en-US male)

Both providers fall back to CALLME_PHONE_AUTH_TOKEN when
CALLME_TELNYX_API_KEY is not set, so zero extra config is needed when
already using Telnyx for phone.
Three critical bugs that would break the TTS path at runtime, plus
three medium-severity issues in the STT path.

TTS (server/src/providers/tts-telnyx.ts):
- audio_format 'pcm' is not a valid Telnyx value. The server silently
  rejects it, which is almost certainly why live verification got
  zero audio back. Switch to 'linear16' (16-bit signed LE PCM at
  24kHz mono), which is what the existing TTSProvider contract and
  the resampler in phone-call.ts already expect.
- Stop frame was { force: true }, which the Telnyx AsyncAPI defines as
  an interrupt-mid-stream signal, not a completion signal. The
  documented stop frame is { text: '' }. Sending force:true leaves
  the server waiting for more text and never closes the session.
- 30s setTimeout was never cleared on resolve/error/close, leaking a
  timer per synthesize call. Wrapped all settle paths in a single
  settle() helper that clears the timer, removes WS listeners, and
  closes the socket exactly once.

STT (server/src/providers/stt-telnyx.ts):
- connect() had no timeout. A hung WebSocket would block
  initiateCall() forever. Added a 10s connect timeout that matches
  the OpenAI Realtime provider.
- waitForTranscript cleared pendingTranscript on entry, silently
  dropping any is_final events that arrived before listen() started
  (e.g. user spoke during TTS playback). Now returns the buffered
  transcript immediately if any finals are pending.
- Removed dead reconnectAttempts / maxReconnectAttempts /
  reconnectDelayMs fields. They were declared and reset but never
  used; reconnect logic was never implemented for Telnyx STT.

Provider factory (server/src/providers/index.ts):
- validateProviderConfig OpenAI-key check was
  'sttProvider != telnyx AND ttsProvider != telnyx', which only
  errored when BOTH were non-Telnyx. Mixed configs like
  openai-realtime STT + telnyx TTS passed validation and then failed
  at runtime. Replaced with needsOpenAI = stt openai-realtime OR
  tts openai.

Verified: tsc --noEmit clean, bun smoke test confirms URL params
(linear16, 24kHz, mulaw, 8kHz) and stop frame { text: '' }.

Refs ZeframLou#39
@a692570

a692570 commented Jul 19, 2026

Copy link
Copy Markdown
Author

QA pass + fixes pushed

I did a thorough QA pass on this PR (cloned, typechecked, verified the protocol against the official Telnyx WebSocket docs at developers.telnyx.com) and found six bugs. Three would have broken the TTS path at runtime, three were medium-severity STT issues. All six are fixed in commit 76d43cf.

Critical (would have broken TTS at runtime)

1. audio_format: 'pcm' is not a valid Telnyx value. The Telnyx WebSocket API accepts only mp3, linear16, wav, mulaw, alaw, ogg_vorbis (per the configuration docs). pcm is silently rejected by the server. This is almost certainly why live verification got zero audio back, not "TTS feature may not be enabled on my account." Switched to linear16 at 24kHz mono, which matches the existing TTSProvider.synthesize() contract and the resample24kTo8k resampler in phone-call.ts.

2. Stop frame was { "force": true }, should be { "text": "" }. The Telnyx AsyncAPI spec defines force: true as an interrupt-mid-stream signal that stops the current synthesis worker and starts a new one. The documented completion signal is an empty text frame. Sending force: true leaves the server waiting for more text and the WebSocket never closes cleanly. Replaced with { "text": "" }.

3. validateProviderConfig OpenAI-key check was wrong. The condition was sttProvider != 'telnyx' AND ttsProvider != 'telnyx', which only errored when both were non-Telnyx. Mixed configs like openai-realtime STT + telnyx TTS passed validation and then failed at runtime when the OpenAI STT provider tried to initialize without a key. Replaced with needsOpenAI = sttProvider == 'openai-realtime' OR ttsProvider == 'openai'.

Medium

4. TTS 30s timeout timer was never cleared. Each synthesize() call allocated a 30s setTimeout that was never cleared on resolve/error/close. Across a multi-turn phone call this leaked timers. Wrapped all settle paths in a single settle() helper that clears the timer, removes WS listeners, and closes the socket exactly once.

5. STT waitForTranscript cleared pendingTranscript on entry. Any is_final events that arrived before listen() started (e.g. the user spoke during TTS playback) were buffered in pendingTranscript and then silently dropped when waitForTranscript zeroed it. Now returns the buffered transcript immediately if any finals are pending.

6. STT connect() had no timeout. A hung WebSocket would block initiateCall() forever. Added a 10s connect timeout that matches the OpenAI Realtime provider.

Cleanup

7. Dead reconnect fields. reconnectAttempts, maxReconnectAttempts, reconnectDelayMs were declared and reset but never used. Reconnect logic was never implemented for Telnyx STT. Removed.

Verification

The original commit's honesty about not being able to verify TTS audio was the right call. The root cause was the invalid audio_format value, which the server rejected silently. With linear16 and the correct stop frame, the path should work end-to-end. The maintainer should still re-test with a TTS-enabled Telnyx account before merge, but the protocol is now correct against the published spec.

@a692570

a692570 commented Aug 11, 2026

Copy link
Copy Markdown
Author

Circling back on this PR — the QA round with the six fixes has been sitting for ~3 weeks now (last push on 2026-07-19), and I want to make sure it isn't blocked on me.

Quick state:

  • tsc --noEmit clean
  • Bun smoke test passes against the corrected protocol (linear16@24kHz TTS, mulaw@8kHz STT, empty-text stop frame)
  • Mergeable, no conflicts, 498+/6- across 5 files

If anything in the diff needs rework for the maintainer's taste — naming, file layout, error handling, comments — happy to push a follow-up commit. Otherwise, if the right call is to keep this parked or close it out, let me know and I'll stop nudging.

No pressure either way. Thanks for the time on this.

@a692570

a692570 commented Aug 31, 2026

Copy link
Copy Markdown
Author

One more bump — about 6 weeks since this PR opened and ~3 weeks since the last ping. If the queue is just long, no worries. If anything in the diff needs rework on your end (naming, file layout, error handling, comments), happy to push a follow-up commit. If I don't hear back by mid-September, I'll close this out rather than let it linger.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant