HipcallTts Documentation

View Source

A multi-provider Text-to-Speech (TTS) client for Elixir with a unified API, automatic text splitting, retry logic, and comprehensive telemetry support.

Version: 0.1.0 License: MIT Repository: https://github.com/hipcall/hipcall_tts


Table of Contents

  1. Overview
  2. Installation
  3. Quick Start
  4. Configuration
  5. Making Requests
  6. Provider Details
  7. Introspection API
  8. Advanced Features
  9. Error Handling
  10. Examples

Overview

HipcallTts provides a unified interface for generating speech audio from text using multiple TTS providers. It abstracts away provider-specific implementation details while offering:

  • Multi-provider support: OpenAI, AWS Polly, and ElevenLabs
  • Automatic text splitting: Handles texts exceeding provider limits
  • Retry with exponential backoff: Configurable retry logic for resilience
  • Telemetry integration: Comprehensive observability events
  • Parameter validation: Schema-based validation using NimbleOptions

Architecture

HipcallTts (Public API)
 Provider (Behavior/Contract)
 Registry (Provider Lookup)
 Schema (Parameter Validation)
 Config (Configuration Resolution)
 Providers
    OpenAI
    Polly
    ElevenLabs
 TextSplitter (Auto-chunking)
 AudioConcatenator (Binary Merging)
 Retry (Exponential Backoff)
 Telemetry (Event Emission)

Installation

Add hipcall_tts to your list of dependencies in mix.exs:

def deps do
  [
    {:hipcall_tts, "~> 0.1.0"}
  ]
end

Then run:

mix deps.get

Quick Start

1. Configure a Provider

Add provider credentials to your config/config.exs:

config :hipcall_tts, :providers,
  openai: [
    api_key: {:system, "OPENAI_API_KEY"}
  ]

2. Generate Speech

{:ok, audio_binary} = HipcallTts.generate(
  provider: :openai,
  text: "Hello, world!",
  voice: "nova"
)

# The result is the raw bytes of a complete audio file — write them straight out.
# Use an extension matching the `:format` you asked for (default is "mp3").
File.write!("output.mp3", audio_binary)

See Working with the returned audio for serving over HTTP, Base64-encoding, and the raw-PCM caveat.


Configuration

Configuration Structure

Provider configuration is stored under the :hipcall_tts application key:

# config/config.exs or config/runtime.exs
config :hipcall_tts, :providers,
  openai: [
    api_key: {:system, "OPENAI_API_KEY"},
    base_url: "https://api.openai.com",
    default_model: "tts-1",
    default_voice: "nova",
    default_format: "mp3"
  ],
  elevenlabs: [
    api_key: {:system, "ELEVENLABS_API_KEY"},
    default_model: "eleven_flash_v2_5",
    default_voice: "Xb7hH8MSUJpSbSDYk0k2",
    default_format: "mp3"
  ],
  polly: [
    access_key_id: {:system, "AWS_ACCESS_KEY_ID"},
    secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"},
    region: {:system, "AWS_REGION"},
    default_model: "standard",
    default_voice: "Joanna",
    default_format: "mp3"
  ],
  soniox: [
    api_key: {:system, "SONIOX_API_KEY"},
    default_model: "tts-rt-v2",
    default_voice: "Mina",
    default_format: "mp3",
    default_language: "en"
  ]

Environment Variable Resolution

Use the {:system, "ENV_VAR"} tuple syntax to read values from environment variables at runtime:

api_key: {:system, "OPENAI_API_KEY"}
# Resolves to System.get_env("OPENAI_API_KEY") at runtime

Runtime Credential Override

You can override credentials per-request using function parameters:

HipcallTts.generate(
  provider: :openai,
  text: "Hello",
  api_key: "sk-custom-key-for-this-request"
)

Making Requests

Main Function: generate/1

The primary function for generating speech audio.

Signature:

@spec generate(params) :: {:ok, binary()} | {:error, map()}

Parameters:

ParameterTypeRequiredDescription
provideratomYes:openai, :elevenlabs, :polly, or :soniox
textstringYesText to synthesize
voicestringNoVoice identifier (provider-specific)
modelstringNoModel identifier
formatstringNoAudio format (default: "mp3")
sample_rateintegerNoSample rate in Hz (default: 22050)
speedfloatNoSpeech speed multiplier (default: 1.0)
pitchfloatNoPitch adjustment in semitones (default: 0.0)
languagestringNoLanguage code
provider_optskeywordNoProvider-specific options, merged into the provider params
retry_optskeywordNoRetry configuration

Credential overrides (all optional, all fall back to application config):

ParameterTypeUsed byDescription
api_keystringOpenAI, ElevenLabs, SonioxPer-request API key
api_organizationstringOpenAIOpenAI-Organization header
access_key_idstringPollyAWS access key id
secret_access_keystringPollyAWS secret access key
session_tokenstringPollyAWS session token for temporary credentials
regionstringPollyAWS region, e.g. "eu-west-1"

Which parameters each provider actually uses

The schema accepts every parameter for every provider, but a provider only sends what its API supports. A parameter a provider does not use is silently ignored — it does not raise and does not warn. Check this table before relying on one.

ParameterOpenAIElevenLabsPollySoniox
text
voice
model✅ (engine)
format
speed✅ (0.7–1.3)
language✅ (required)
sample_rate
pitch

Notes on the gaps:

  • pitch is not implemented by any provider. It is accepted and defaulted by the schema but never reaches an API. Do not rely on it.
  • sample_rate is only honored by Soniox. capabilities/0 reports a sample_rates list for every provider, but that list describes what the service produces, not a value you can currently select for OpenAI, ElevenLabs, or Polly.
  • language is only used by Soniox, where it is required. The other providers infer the language from the text or from the chosen voice.
  • speed ranges differ. Soniox rejects anything outside 0.7–1.3 during validate_params/1; Polly ignores the parameter entirely.

Minimal request per provider

The shortest call that works for each provider, assuming credentials are in application config:

# OpenAI — voice and model fall back to config defaults
HipcallTts.generate(provider: :openai, text: "Hello")

# ElevenLabs — voice is a voice ID string
HipcallTts.generate(provider: :elevenlabs, text: "Hello")

# AWS Polly — note that voice and model must be compatible, see
# "Voice-Model Compatibility" below
HipcallTts.generate(provider: :polly, text: "Hello", voice: "Joanna")

# Soniox — language is required by the API; omitting it falls back to
# `default_language` from config
HipcallTts.generate(provider: :soniox, text: "Merhaba", language: "tr")

A fully specified call looks the same for every provider:

{:ok, audio} = HipcallTts.generate(
  provider: :openai,
  text: "Welcome to our application!",
  voice: "alloy",
  model: "tts-1-hd",
  format: "mp3",
  speed: 1.0
)

Top-level parameters vs provider_opts

provider_opts is merged into the provider params after the top-level ones, so it can both add provider-specific options and override top-level values:

# Adds an option that has no top-level equivalent
HipcallTts.generate(
  provider: :elevenlabs,
  text: "Hello",
  provider_opts: [stability: 0.3, similarity_boost: 0.9]
)

# Overrides credentials for this request only
HipcallTts.generate(
  provider: :soniox,
  text: "Merhaba",
  language: "tr",
  provider_opts: [api_key: "sk-..."]
)

Prefer provider_opts for anything provider-specific; the top-level schema is intentionally not growing a key per provider feature.

Return Values

Success:

{:ok, <<binary_audio_data>>}

Error:

{:error, %{
  code: :http_error,
  message: "Request failed with status 401",
  provider: :openai,
  status: 401,
  headers: [...]
}}

Working with the returned audio

generate/1 returns the raw bytes of the encoded audio file — the same bytes the provider's API sent back, with no wrapper:

{:ok, audio} = HipcallTts.generate(provider: :soniox, text: "Merhaba", language: "tr")
# => {:ok, <<73, 68, 51, 4, 0, 0, 0, 0, 0, 34, 84, 83, 83, 69, ...>>}

Those first three bytes are "ID3" — this is a complete, playable MP3 file already, tags and all. There is nothing to decode or convert.

The return value does not tell you which format it is. It is bytes, not a struct, so the caller has to remember what it asked for in :format. Track it alongside the binary if it can vary at runtime.

Writing to a file

Give the file the extension matching the :format you requested — a .mp3 name on FLAC bytes will confuse players and browsers:

:formatExtensionMIME typeNotes
"mp3".mp3audio/mpegDefault
"wav".wavaudio/wavSoniox only
"opus".opusaudio/opus
"aac".aacaudio/aac
"flac".flacaudio/flac
"ogg_vorbis".oggaudio/oggPolly only
"pcm".pcmapplication/octet-streamHeaderless, see below
"ulaw_8000".ulawaudio/basicElevenLabs only, see caveat below

Two caveats that the format lists do not make obvious:

  • Polly + "pcm" needs an explicit sample_rate. The schema defaults :sample_rate to 22050, and Polly forwards it, but AWS only accepts 8000 or 16000 for PCM output — so the default combination fails with InvalidSampleRateException. Pass sample_rate: 16000 (or 8000) explicitly. The other Polly formats accept 22050 and work as-is.

  • "ulaw_8000" is not accepted by :format. ElevenLabs reports it in capabilities/1, and the provider knows how to send it, but the schema's :format enum does not include it, so format: "ulaw_8000" fails validation. Reach it through provider_opts instead, which is merged after validation:

    HipcallTts.generate(
      provider: :elevenlabs,
      text: "Hello",
      provider_opts: [format: "ulaw_8000"]
    )
def save(text, format) do
  with {:ok, audio} <- HipcallTts.generate(
         provider: :soniox,
         text: text,
         language: "tr",
         format: format
       ) do
    path = "announcement.#{extension(format)}"
    File.write!(path, audio)
    {:ok, path}
  end
end

defp extension("ogg_vorbis"), do: "ogg"
defp extension("ulaw_8000"), do: "ulaw"
defp extension(format), do: format

Raw PCM has no header

"pcm" and "ulaw_8000" return raw samples with no container — no sample rate, no channel count, nothing a player can read. Writing them to a file produces something most players refuse to open. You have to supply those values yourself:

# Soniox pcm defaults to 24 kHz, 16-bit, mono
ffplay -f s16le -ar 24000 -ac 1 announcement.pcm

Prefer "wav" over "pcm" when a file has to stand on its own — it is the same samples with a 44-byte header that makes them self-describing.

Serving over HTTP

In Phoenix or Plug, send the bytes with the matching content type:

def announcement(conn, %{"text" => text}) do
  case HipcallTts.generate(provider: :soniox, text: text, language: "tr") do
    {:ok, audio} ->
      conn
      |> put_resp_content_type("audio/mpeg")
      |> send_resp(200, audio)

    {:error, error} ->
      conn |> put_status(502) |> json(%{error: error.message})
  end
end

Embedding in JSON

JSON cannot carry raw bytes, so Base64-encode when returning audio from a JSON API or storing it in a text column:

{:ok, audio} = HipcallTts.generate(provider: :openai, text: "Hello")
encoded = Base.encode64(audio)

# and back
{:ok, ^audio} = Base.decode64(encoded)

Note this inflates the payload by about a third.

Checking what you got

file announcement.mp3
# => Audio file with ID3 version 2.4.0, contains:
#    - MPEG ADTS, layer III, v2, 128 kbps, 24 kHz, Monaural

ffprobe -v error -show_entries format=duration \
  -show_entries stream=codec_name,sample_rate,channels \
  -of csv=p=0 announcement.mp3
# => mp3,24000,1
#    3.720000

Provider Details

OpenAI

OpenAI's Text-to-Speech API offers high-quality voice synthesis.

Endpoint: https://api.openai.com/v1/audio/speech

Configuration

config :hipcall_tts, :providers,
  openai: [
    api_key: {:system, "OPENAI_API_KEY"},
    base_url: "https://api.openai.com",
    default_model: "tts-1",
    default_voice: "nova",
    default_format: "mp3"
  ]

Models

Model IDDescription
tts-1Standard quality, faster generation
tts-1-hdHigh quality, slower generation

Voices

Voice IDGenderDescription
alloyNeutralBalanced, versatile voice
echoMaleDeep, resonant voice
fableNeutralWarm, storytelling voice
onyxMaleStrong, authoritative voice
novaFemaleClear, professional voice
shimmerFemaleSoft, gentle voice

Supported Languages

English, Turkish, German, Spanish, French, Italian, Portuguese, Russian, Japanese, Korean, Chinese

Capabilities

FeatureValue
Max Text Length4,096 characters
Formatsmp3, opus, aac, flac
Sample Rates22050, 44100 Hz
StreamingNot implemented

Example

{:ok, audio} = HipcallTts.generate(
  provider: :openai,
  text: "Hello from OpenAI!",
  voice: "nova",
  model: "tts-1-hd",
  format: "mp3"
)

AWS Polly

Amazon Polly offers natural-sounding text-to-speech with SSML support.

Endpoint: https://polly.<region>.amazonaws.com/v1/speech

Configuration

config :hipcall_tts, :providers,
  polly: [
    access_key_id: {:system, "AWS_ACCESS_KEY_ID"},
    secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"},
    region: {:system, "AWS_REGION"},           # Optional, defaults to "us-east-1"
    session_token: {:system, "AWS_SESSION_TOKEN"},  # Optional, for temporary credentials
    default_model: "standard",
    default_voice: "Joanna",
    default_format: "mp3"
  ]

Models (Engines)

Model IDDescription
standardStandard Polly engine
neuralNeural engine (higher quality, region/voice dependent)

Voices

English (US): | Voice ID | Gender | |----------|--------| | Joanna | Female | | Matthew | Male |

English (UK): | Voice ID | Gender | |----------|--------| | Amy | Female | | Brian | Male |

German: | Voice ID | Gender | |----------|--------| | Marlene | Female | | Daniel | Male | | Vicki | Female |

Turkish: | Voice ID | Gender | |----------|--------| | Filiz | Female | | Burcu | Female |

Capabilities

FeatureValue
Max Text Length3,000 characters
Formatsmp3, ogg_vorbis, pcm
Sample Rates8000, 16000, 22050 Hz
SSML SupportYes (auto-detected)
StreamingNot implemented

SSML Support

Polly automatically detects SSML when your text starts with <speak>:

{:ok, audio} = HipcallTts.generate(
  provider: :polly,
  text: "<speak>Hello <break time='500ms'/> World!</speak>",
  voice: "Joanna"
)

Example

{:ok, audio} = HipcallTts.generate(
  provider: :polly,
  text: "Hello from Amazon Polly!",
  voice: "Joanna",
  model: "neural",
  format: "mp3",
  region: "us-east-1"
)

ElevenLabs

ElevenLabs offers highly realistic, emotionally expressive voice synthesis.

Endpoint: https://api.elevenlabs.io/v1/text-to-speech

Configuration

config :hipcall_tts, :providers,
  elevenlabs: [
    api_key: {:system, "ELEVENLABS_API_KEY"},
    default_model: "eleven_flash_v2_5",
    default_voice: "Xb7hH8MSUJpSbSDYk0k2",
    default_format: "mp3"
  ]

Models

Model IDDescriptionMax Text
eleven_multilingual_v2Most life-like, emotionally rich, 29 languages10,000 chars
eleven_flash_v2_5Ultra-low latency, 32 languages40,000 chars

Sample Voices

Voice IDNameGenderLanguage
Xb7hH8MSUJpSbSDYk0k2AliceFemaleEnglish
TX3LPaxmHKxFdv7VOQHJLiamMaleEnglish
Custom voice IDs--Various

Note: ElevenLabs supports custom and cloned voices. Use the ElevenLabs dashboard to find voice IDs.

Supported Languages (30+)

Arabic, Bulgarian, Chinese, Croatian, Czech, Danish, Dutch, English, Filipino, Finnish, French, German, Greek, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Romanian, Russian, Slovak, Spanish, Swedish, Tamil, Turkish, Ukrainian, Vietnamese

Voice Settings (Provider Options)

ElevenLabs supports advanced voice settings via provider_opts:

{:ok, audio} = HipcallTts.generate(
  provider: :elevenlabs,
  text: "Hello with custom settings!",
  voice: "Xb7hH8MSUJpSbSDYk0k2",
  provider_opts: [
    stability: 0.5,           # Voice stability (0.0 - 1.0)
    similarity_boost: 0.8,    # Similarity to original voice (0.0 - 1.0)
    style: 0.5,               # Voice style intensity
    use_speaker_boost: true,  # Enable speaker boost
    speed: 1.0                # Speech speed
  ]
)

Capabilities

FeatureValue
Max Text Length10,000 - 40,000 characters (model-dependent)
Formatsmp3, pcm, ulaw_8000
Sample Rates22050, 24000, 44100, 48000 Hz
StreamingNot implemented

Example

{:ok, audio} = HipcallTts.generate(
  provider: :elevenlabs,
  text: "Welcome to ElevenLabs text-to-speech!",
  voice: "Xb7hH8MSUJpSbSDYk0k2",
  model: "eleven_multilingual_v2",
  format: "mp3",
  sample_rate: 44100
)

Soniox

Soniox provides a single unified multilingual model: every voice speaks every supported language while keeping the same speaker identity.

Endpoint: https://tts-rt.soniox.com/tts

Configuration

config :hipcall_tts, :providers,
  soniox: [
    api_key: {:system, "SONIOX_API_KEY"},
    default_model: "tts-rt-v2",
    default_voice: "Mina",
    default_format: "mp3",
    # Soniox requires a language on every request; used when the caller omits it.
    default_language: "en"
  ]

Models

Model IDDescriptionNotes
tts-rt-v2Real-time multilingual model, 63 languagesOnly model offered by this package

tts-rt-v1 was removed by Soniox on 2026-08-31 and is deliberately not exposed.

Sample Voices

70 built-in voices (40 male, 30 female). All of them accept all 63 languages.

VoiceGenderCharacter
MinaFemaleSoft, thoughtful, steady pacing
EmmaFemaleSmooth, relaxed, subtle warmth
DanielMaleRich, steady, polished
AdrianMaleDeep, crisp articulation
ImogenFemaleClear British, professional
ArjunMaleDeep, natural Indian accent

Use HipcallTts.voices(:soniox) for the full catalog.

The :language parameter

Soniox rejects requests without a language, and expects a bare ISO code. The provider normalizes locale-style values, so all of these resolve to "tr":

language: "tr"      # passed through
language: "tr-TR"   # normalized
language: "tr_TR"   # normalized

An unsupported language fails validation before the request is sent:

HipcallTts.generate(provider: :soniox, text: "Hello", language: "zz-ZZ")
# => {:error, %{code: :error, message: "Invalid language: zz-ZZ", ...}}

When :language is omitted, default_language from config is used.

Speed

Soniox accepts speed between 0.7 and 1.3 — narrower than the general range documented on HipcallTts.Schema. Values outside it are rejected locally rather than producing an HTTP 400:

HipcallTts.generate(provider: :soniox, text: "Hello", language: "en", speed: 2.0)
# => {:error, %{message: "Speed must be between 0.7 and 1.3", ...}}

Audio formats

Package format names are mapped onto Soniox audio_format values:

Package :formatSoniox audio_formatSample rates (Hz)
mp3mp316000, 24000, 32000, 44100, 48000
wavwav8000, 16000, 24000, 44100, 48000
opusopus8000, 16000, 24000, 48000
aacaac16000, 24000, 44100, 48000
flacflac16000, 24000, 44100, 48000
pcmpcm_s16le8000, 16000, 24000, 44100, 48000

ogg_vorbis is not supported and fails validation.

The schema defaults :sample_rate to 22050, which Soniox accepts for no format at all. That exact value is treated as "not specified" and omitted so Soniox applies its own per-format default. A sample rate that the chosen format does not support is likewise omitted rather than causing a 400.

Text length and the 2-minute cap

Soniox caps generated audio at 2 minutes of duration, not by character count, and audio past the cap is truncated silently. max_text_length is therefore a character budget chosen to stay under 120 seconds of speech — and how many characters that is depends on the script.

Measured on tts-rt-v2 (voice Mina, speed 1.0):

ScriptRate120s budget
Latin/Cyrillic (tr, en)~15.7 chars/sec~1,880 chars
Japanese (ja)~6.1 chars/sec~730 chars
Chinese (zh)~4.1 chars/sec~490 chars

The default of 1,100 is sized for Latin/Cyrillic text with margin for the slowest supported speed. capabilities/0 cannot see the request language, so deployments that synthesize CJK text must lower it:

config :hipcall_tts, :soniox_max_text_length, 450

Longer text is split on sentence boundaries by HipcallTts.TextSplitter and the segments are concatenated.

Capabilities

FeatureValue
Max Text Length1,100 characters (configurable)
Formatsmp3, wav, opus, aac, flac, pcm
Sample Rates8000, 16000, 24000, 32000, 44100, 48000 Hz
StreamingNot implemented (Soniox offers a WebSocket API)

Expressive delivery with audio tags

Soniox reads bracketed English tags in the text as delivery instructions rather than speaking them. A tag applies to the words that follow it, and several can appear in one utterance:

{:ok, audio} = HipcallTts.generate(
  provider: :soniox,
  text: "[warm] Merhaba. [calm] Talebiniz inceleniyor, lütfen bekleyiniz.",
  voice: "Mina",
  language: "tr"
)

Available categories include emotion ([happy], [sad], [excited], [nervous], [calm], …), tone and manner ([warm], [stern], [serious], [sincerely], [reassuringly], [dramatically], …) and non-lexical sounds ([laughs], [sighs], …). The list is not exhaustive and Soniox recommends testing a tag before relying on it.

Two rules worth remembering:

  • Tags are always written in English, whatever the text language. [sakin] will be spoken aloud; [calm] will not.
  • Tags count toward max_text_length, since they are part of :text.

There is no separate parameter for this — nothing in the package needs to know about tags, they simply travel inside the text.

Emphasis and pacing inside the text

The same layer covers written emphasis, independent of the :speed parameter:

Written formEffect
UPPERCASEStronger emphasis on the word
*stress*Marked stress
soooElongated vowel
...Hesitation / pause
Sharper break
?!Combined intonation
{:ok, audio} = HipcallTts.generate(
  provider: :soniox,
  text: "Talebiniz *onaylandı*. Teşekkür ederiz... iyi günler.",
  language: "tr"
)

Soniox's own guidance is to reach for a voice whose natural pacing already fits, then shape individual moments with tags and punctuation, and only use :speed when the delivery is globally too fast or slow.

Speed

Accepted range is 0.7 to 1.3, narrower than the schema's general range. Anything outside it is rejected by validate_params/1 before a request is sent:

HipcallTts.generate(provider: :soniox, text: "Merhaba", language: "tr", speed: 0.9)

HipcallTts.generate(provider: :soniox, text: "Merhaba", language: "tr", speed: 2.0)
# => {:error, %{message: "Speed must be between 0.7 and 1.3", ...}}

Soniox-only options via provider_opts

reduce_silence and bitrate are not part of HipcallTts.Schema, so they travel through provider_opts:

{:ok, audio} = HipcallTts.generate(
  provider: :soniox,
  text: "Merhaba. Talebiniz inceleniyor.",
  language: "tr",
  format: "mp3",
  provider_opts: [
    reduce_silence: true,   # shorten the gaps between words and sentences
    bitrate: 64_000         # lossy codecs only — see the table below
  ]
)

reduce_silence trims the pauses between words and sentences, which is different from :speed — the words themselves are spoken at the same rate.

Its effect scales with how many pauses the text actually contains. Measured on tts-rt-v2 with voice Daniel, three runs each:

TextOffOn
"Merhaba. Talebiniz alındı. İnceleniyor. Lütfen bekleyin. Teşekkürler."~7.5s~5.0s (−33%)
One long sentenceno measurable changeno measurable change

So it pays off for a run of short IVR prompts and does close to nothing for a single flowing sentence. Note also that generation is not deterministic — the same request varied by about 20% across runs here, so compare averages rather than single results.

bitrate applies only to the lossy codecs. Sending it with wav, flac or pcm is rejected by Soniox with a 400, so the provider validates it up front:

:formatAccepted bitrates (bps)Default
"mp3"32000, 64000, 96000, 128000, 192000, 256000, 320000128000
"opus"16000, 32000, 64000, 96000, 128000, 25600064000
"aac"32000, 64000, 96000, 128000, 192000, 256000, 320000128000
"wav", "flac", "pcm"not applicable
HipcallTts.generate(
  provider: :soniox,
  text: "Merhaba",
  language: "tr",
  format: "wav",
  provider_opts: [bitrate: 128_000]
)
# => {:error, %{message: "Bitrate is not supported for the wav format", ...}}

Dropping the bitrate is a straightforward way to shrink cached announcements — 32 kbps mp3 is roughly a quarter the size of the 128 kbps default, and for speech played down a phone line the difference is largely inaudible.

One voice across languages

Because every voice speaks every language, the same speaker identity can carry a multilingual product without switching voices:

for {lang, text} <- [
      {"tr", "Talebiniz inceleniyor."},
      {"en", "Your ticket is being reviewed."},
      {"de", "Ihr Ticket wird geprüft."}
    ] do
  {:ok, audio} = HipcallTts.generate(
    provider: :soniox,
    text: text,
    voice: "Mina",
    language: lang
  )

  File.write!("status_#{lang}.mp3", audio)
end

Soniox also handles more than one language inside a single utterance, so a foreign brand name or term in an otherwise Turkish sentence does not need to be split out.

Features this package does not expose

GET /v1/tts-models reports several capabilities that tts-rt-v2 supports but that the package has no surface for:

CapabilityStatus
WebSocket streamingNot implemented — stream/1 returns an error
Voice cloning (20s reference clip)Not implemented
Word timestampsNot implemented

There is also no pronunciation control — no SSML, no <phoneme>, no custom lexicon. When Soniox mispronounces a name or brand, the only lever is respelling it phonetically in the text. (AWS Polly, by contrast, accepts SSML <phoneme>.)

Rate limits

Soniox applies 100 requests/minute and 3 concurrent requests per account. Split segments are generated sequentially, so a single generate/1 call stays within the concurrency limit, but parallel callers may not.

Example

{:ok, audio} = HipcallTts.generate(
  provider: :soniox,
  text: "Hipcall müşteri hizmetlerini aradınız.",
  voice: "Mina",
  model: "tts-rt-v2",
  language: "tr-TR",
  format: "mp3"
)

Introspection API

HipcallTts provides functions to query provider capabilities at runtime.

List Available Providers

HipcallTts.providers()
# => [:openai, :elevenlabs, :polly, :soniox]

Get Provider Models

{:ok, models} = HipcallTts.models(:openai)
# => [
#   %{id: "tts-1", name: "TTS-1", description: "Standard quality...", languages: [...]},
#   %{id: "tts-1-hd", name: "TTS-1 HD", description: "High quality..."}
# ]

Get Provider Voices

{:ok, voices} = HipcallTts.voices(:openai)
# => [
#   %{id: "alloy", name: "Alloy", gender: :neutral, language: "en", locale: nil},
#   %{id: "nova", name: "Nova", gender: :female, language: "en", locale: nil},
#   ...
# ]

Get Supported Languages

{:ok, languages} = HipcallTts.languages(:polly)
# => [
#   %{code: "en-US", name: "English", locale: "US"},
#   %{code: "de-DE", name: "German", locale: "DE"},
#   ...
# ]

Get Provider Capabilities

{:ok, caps} = HipcallTts.capabilities(:elevenlabs)
# => %{
#   streaming: false,
#   formats: ["mp3", "pcm", "ulaw_8000"],
#   sample_rates: [22050, 24000, 44100, 48000],
#   max_text_length: 40000
# }

Advanced Features

Automatic Text Splitting

When text exceeds a provider's maximum length, HipcallTts automatically:

  1. Splits text at sentence boundaries (., !, ?, , etc.)
  2. Groups sentences to maximize chunks within the limit
  3. Generates audio for each chunk
  4. Concatenates the audio segments

This is transparent to the caller - you receive a single audio binary.

Example with long text:

long_text = """
This is a very long text that exceeds the provider's limit.
It will be automatically split into sentences.
Each sentence will be processed separately.
The audio will be concatenated seamlessly.
"""

{:ok, audio} = HipcallTts.generate(
  provider: :openai,  # 4096 char limit
  text: long_text,
  voice: "nova"
)
# Returns combined audio from all chunks

Retry Logic

HipcallTts includes built-in retry with exponential backoff for resilient API calls.

Configuration

{:ok, audio} = HipcallTts.generate(
  provider: :openai,
  text: "Hello",
  retry_opts: [
    max_attempts: 5,           # Maximum retry attempts (default: 3)
    initial_delay: 500,        # Initial delay in ms (default: 1000)
    max_delay: 10_000,         # Maximum delay in ms (default: 10000)
    backoff_factor: 2.0,       # Exponential factor (default: 2.0)
    retryable_errors: []       # Error codes to retry (default: all)
  ]
)

Backoff Formula

delay = min(initial_delay * (backoff_factor ^ attempt), max_delay)

Example with defaults:

  • Attempt 1: 1000ms
  • Attempt 2: 2000ms
  • Attempt 3: 4000ms
  • (capped at max_delay)

Retry-After Header

When providers return Retry-After headers (common with 429 Too Many Requests), HipcallTts respects the specified delay.

Telemetry Events

HipcallTts emits telemetry events for monitoring and observability.

Available Events

EventWhenMetadata
[:hipcall_tts, :generate, :start]Before generationprovider, text_length
[:hipcall_tts, :generate, :stop]After successprovider, text_length, audio_size, format
[:hipcall_tts, :generate, :error]On errorprovider, text_length, error
[:hipcall_tts, :generate, :exception]On exceptionprovider, kind, error, stacktrace
[:hipcall_tts, :http, :request]HTTP request completeprovider, method, url, status_code
[:hipcall_tts, :retry, :attempt]Retry attemptprovider, attempt, delay, error
[:hipcall_tts, :text, :split]Text was splitprovider, original_length, chunks

Attaching Handlers

:telemetry.attach(
  "hipcall-tts-logger",
  [:hipcall_tts, :generate, :stop],
  fn event, measurements, metadata, _config ->
    Logger.info("TTS generated #{metadata.audio_size} bytes in #{measurements.duration}ms")
  end,
  nil
)

Error Handling

All errors return a normalized map structure:

{:error, %{
  code: atom,           # Error type
  message: string,      # Human-readable message
  provider: atom,       # Which provider failed
  status: integer | nil, # HTTP status code (if applicable)
  headers: list | nil   # Response headers (if applicable)
}}

Common Error Codes

CodeDescription
:validation_errorInvalid parameters
:http_errorHTTP request failed
:network_errorNetwork connectivity issue
:timeoutRequest timed out
:authentication_errorInvalid API credentials
:rate_limitedToo many requests
:provider_errorProvider-specific error

Handling Errors

case HipcallTts.generate(provider: :openai, text: "Hello") do
  {:ok, audio} ->
    File.write!("output.mp3", audio)

  {:error, %{code: :rate_limited}} ->
    # Wait and retry
    Process.sleep(5000)
    retry_generation()

  {:error, %{code: :validation_error, message: msg}} ->
    Logger.error("Invalid parameters: #{msg}")

  {:error, error} ->
    Logger.error("TTS failed: #{inspect(error)}")
end

Examples

Basic Usage

# Simple generation with defaults
{:ok, audio} = HipcallTts.generate(
  provider: :openai,
  text: "Hello, world!"
)
File.write!("hello.mp3", audio)

Multiple Voices Comparison

voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]

for voice <- voices do
  {:ok, audio} = HipcallTts.generate(
    provider: :openai,
    text: "This is the #{voice} voice.",
    voice: voice
  )
  File.write!("voice_#{voice}.mp3", audio)
end

Concurrent Generation

texts = [
  "Welcome to our service.",
  "Please hold while we connect you.",
  "Thank you for your patience."
]

results =
  texts
  |> Task.async_stream(fn text ->
    HipcallTts.generate(
      provider: :openai,
      text: text,
      voice: "nova"
    )
  end, max_concurrency: 3)
  |> Enum.map(fn {:ok, result} -> result end)

Dynamic Provider Selection

def generate_speech(text, opts \\ []) do
  provider = Keyword.get(opts, :provider, :openai)

  # Get available voices for the provider
  {:ok, voices} = HipcallTts.voices(provider)
  default_voice = List.first(voices).id

  HipcallTts.generate(
    provider: provider,
    text: text,
    voice: Keyword.get(opts, :voice, default_voice)
  )
end

Building a Voice Selection UI

defmodule VoiceSelector do
  def get_options do
    for provider <- HipcallTts.providers() do
      {:ok, voices} = HipcallTts.voices(provider)
      {:ok, caps} = HipcallTts.capabilities(provider)

      %{
        provider: provider,
        voices: Enum.map(voices, &%{id: &1.id, name: &1.name, gender: &1.gender}),
        formats: caps.formats,
        max_length: caps.max_text_length
      }
    end
  end
end

AWS Polly with SSML

ssml_text = """
<speak>
  Welcome to <emphasis level="strong">HipcallTts</emphasis>.
  <break time="500ms"/>
  This message uses <prosody rate="slow">SSML markup</prosody> for enhanced control.
</speak>
"""

{:ok, audio} = HipcallTts.generate(
  provider: :polly,
  text: ssml_text,
  voice: "Joanna",
  model: "neural"
)

ElevenLabs with Voice Settings

{:ok, audio} = HipcallTts.generate(
  provider: :elevenlabs,
  text: "This voice has custom emotional settings.",
  voice: "Xb7hH8MSUJpSbSDYk0k2",
  model: "eleven_multilingual_v2",
  provider_opts: [
    stability: 0.3,           # More variable/emotional
    similarity_boost: 0.9,    # Stay close to original voice
    style: 0.7,               # Higher style intensity
    use_speaker_boost: true
  ]
)

Soniox with a locale-style language

The :language code comes straight from whatever your caller already has, so locale strings do not need normalizing first:

{:ok, audio} = HipcallTts.generate(
  provider: :soniox,
  text: "Hipcall müşteri hizmetlerini aradınız.",
  voice: "Mina",
  language: "tr-TR",   # normalized to "tr" before the request
  format: "mp3"
)

Picking a voice by gender, since every Soniox voice speaks every language:

{:ok, voices} = HipcallTts.voices(:soniox)
female = Enum.filter(voices, &(&1.gender == :female))

{:ok, audio} = HipcallTts.generate(
  provider: :soniox,
  text: "Merhaba",
  voice: hd(female).id,
  language: "tr"
)

With Custom Retry Configuration

{:ok, audio} = HipcallTts.generate(
  provider: :openai,
  text: "Important message that must succeed.",
  voice: "nova",
  retry_opts: [
    max_attempts: 5,
    initial_delay: 2000,
    max_delay: 30_000,
    backoff_factor: 1.5
  ]
)

Provider Comparison Table

Counts below come from HipcallTts.models/1, voices/1, languages/1 and capabilities/1, so they stay in step with what the package actually exposes.

FeatureOpenAIAWS PollyElevenLabsSoniox
Max Text4,096 chars3,000 chars40,000 chars1,100 chars (2 min audio cap)
Models22 (engines)21
Voices13227 + custom70
Languages5743263
SSMLNoYesNoNo
Voice CloningNoNoYesYes (not exposed)
Formatsmp3, opus, aac, flacmp3, ogg_vorbis, pcmmp3, pcm, ulaw_8000mp3, wav, opus, aac, flac, pcm
language usedNoNoNoYes (required)
sample_rate usedNoNoNoYes
speed usedYesYesNoYes (0.7–1.3)
AuthAPI KeyAWS SigV4API KeyAPI Key

Troubleshooting

Common Issues

"Invalid API key"

  • Ensure your API key is correctly set in config or environment
  • Check that {:system, "ENV_VAR"} syntax is used for env vars

"Rate limited"

  • Reduce concurrent requests
  • Increase retry delays
  • Consider upgrading your API plan

"Text too long"

  • Text splitting is automatic, but verify your text isn't exceeding memory limits
  • Consider pre-splitting very large texts

"Voice not found"

  • Use HipcallTts.voices(provider) to list available voices
  • Ensure voice ID matches exactly (case-sensitive for some providers)

Dependencies

PackageVersionPurpose
finch~> 0.18HTTP client
nimble_options~> 1.1Parameter validation
jason~> 1.4JSON encoding/decoding
telemetry~> 1.2Observability events

License

MIT License - see LICENSE for details.