We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Elixir Text to Speech: Turn AI Answers into Audio in Phoenix LiveView
By Liam Killingback ·
Elixir Text to Speech: Turn AI Answers into Audio in Phoenix LiveView
Text is the default output of every AI feature, and for most users it is the right one. But there is a growing list of cases where reading is the wrong interface: a support assistant someone uses while their hands are busy, a language-learning app, an accessibility option for users with low vision or dyslexia, a “listen to this summary” button on a long report, a cooking app where the screen is across the kitchen. In all of them you want the same thing: take a string, get natural-sounding audio back, and play it in the browser without making the user wait.
This guide builds Elixir text to speech end to end on Phoenix. We call the OpenAI speech API with Req, cache the audio so you never pay twice for the same sentence, stream bytes to the browser so playback starts before generation finishes, and wire a LiveView “Listen” button with a small JavaScript hook that plays long answers sentence by sentence. It is part of our series on AI apps with Elixir, and it is the mirror image of our Whisper transcription guide: that one turns speech into text, this one turns text into speech.
What the speech API actually gives you
OpenAI’s speech endpoint is a single POST to https://api.openai.com/v1/audio/speech. You send JSON and get raw audio bytes back, not JSON. The fields that matter:
-
model:tts-1(fast, cheaper),tts-1-hd(higher quality, slower) orgpt-4o-mini-tts, which also accepts aninstructionsfield for tone (“speak calmly, like a patient teacher”). -
input: the text, up to 4,096 characters per request. -
voice: one of the built-in voices such asalloy,coral,nova,onyxorsage. -
response_format:mp3(the default),opus,aac,flac,wavorpcm. -
speed: from0.25to4.0, default1.0.
Three consequences shape the design. First, the 4,096 character limit means long answers must be split. Second, the response is a binary body that can be streamed, so you can start playing before the request completes. Third, you pay per character (at the time of writing OpenAI lists tts-1 at $15 per million characters; check the pricing page before you plan around it), so generating the same sentence twice is money on the floor. A cache is not an optimisation here, it is part of the feature.
One more rule before writing code: OpenAI’s usage policies require you to tell users that the voice they hear is AI-generated and not a human. Put a short label next to the player. It costs nothing and it keeps you inside the terms.
Step 1: a speech client with Req
Recent versions of mix phx.new already include Req. If your app does not, add {:req, "~> 0.5"} to mix.exs. The client is a thin module:
defmodule MyApp.Speech do
@moduledoc "Text to speech through the OpenAI audio API."
@endpoint "https://api.openai.com/v1/audio/speech"
@max_chars 4_096
@type opts :: [voice: String.t(), model: String.t(), format: String.t(), instructions: String.t()]
@spec synthesize(String.t(), opts()) :: {:ok, binary()} | {:error, term()}
def synthesize(text, opts \\ []) when byte_size(text) > 0 do
if String.length(text) > @max_chars do
{:error, :too_long}
else
case Req.post(@endpoint, json: body(text, opts), auth: {:bearer, api_key()}, receive_timeout: 60_000) do
{:ok, %{status: 200, body: audio}} -> {:ok, audio}
{:ok, %{status: status, body: body}} -> {:error, {:openai, status, body}}
{:error, reason} -> {:error, reason}
end
end
end
def body(text, opts) do
%{
model: Keyword.get(opts, :model, "gpt-4o-mini-tts"),
voice: Keyword.get(opts, :voice, "coral"),
response_format: Keyword.get(opts, :format, "mp3"),
input: text
}
|> maybe_put(:instructions, Keyword.get(opts, :instructions))
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp api_key, do: Application.fetch_env!(:my_app, :openai_api_key)
end
A few details are deliberate. The receive_timeout is raised because a long passage on the HD model can take several seconds. The error clause keeps the status and body so you can tell a 429 (rate limit) from a 400 (bad input) in your logs. And body/2 is public so the streaming controller in step 4 can reuse it.
Try it in IEx:
{:ok, mp3} = MyApp.Speech.synthesize("Your report is ready. Revenue grew twelve percent.")
File.write!("/tmp/hello.mp3", mp3)
If you get a 429, see our guide to OpenAI rate limits in Elixir: Req’s retry: :transient plus a backoff covers most of it.
Step 2: cache by content hash
The same text with the same voice and model always produces equivalent audio, so the cache key is a hash of exactly those inputs. Anything that changes the sound (voice, model, format, instructions, speed) must be part of the key, and nothing else should be.
defmodule MyApp.Speech.Cache do
@moduledoc "Stores generated audio on disk, keyed by what produced it."
alias MyApp.Speech
def fetch(text, opts \\ []) do
key = key(text, opts)
path = path(key)
if File.exists?(path) do
{:ok, key}
else
with {:ok, audio} <- Speech.synthesize(text, opts),
:ok <- File.mkdir_p(Path.dirname(path)),
:ok <- atomic_write(path, audio) do
{:ok, key}
end
end
end
def key(text, opts) do
:crypto.hash(:sha256, :erlang.term_to_binary({Speech.body(text, opts), :v1}))
|> Base.url_encode64(padding: false)
end
def path(key), do: Path.join(dir(), key <> ".mp3")
# write to a temp name, then rename, so a reader never sees half a file
defp atomic_write(path, audio) do
tmp = path <> ".tmp-" <> Integer.to_string(System.unique_integer([:positive]))
with :ok <- File.write(tmp, audio) do
File.rename(tmp, path)
end
end
defp dir, do: Application.fetch_env!(:my_app, :speech_cache_dir)
end
Hashing Speech.body/2 means the key covers exactly the request you would send, so adding a new option later cannot silently serve stale audio. The :v1 tag lets you invalidate everything at once by bumping it.
Local disk is fine on a single node with a persistent volume. On several nodes, or on a platform with an ephemeral filesystem, put the same keys in S3 or Tigris instead; the shape of fetch/2 does not change, only the storage calls. Our LiveView S3 upload guide covers signing and buckets.
A word on concurrency: two users pressing “Listen” on the same paragraph at the same moment will both miss the cache and both pay for generation. For most apps that is rare enough to ignore. If it matters, route generation through a per-key lock (a Registry with :unique keys works well) so the second caller waits for the first.
Step 3: serve the cached audio
A controller sends the file with the right content type and a long cache header. Because the key is a content hash, the URL never points to different bytes, so immutable is safe.
defmodule MyAppWeb.SpeechController do
use MyAppWeb, :controller
alias MyApp.Speech.Cache
def show(conn, %{"key" => key}) do
path = Cache.path(key)
if valid_key?(key) and File.exists?(path) do
conn
|> put_resp_content_type("audio/mpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_file(200, path)
else
send_resp(conn, 404, "")
end
end
# a sha256 is 43 url-safe base64 characters; refuse anything else so a key can never be a path
defp valid_key?(key), do: Regex.match?(~r/\A[A-Za-z0-9_-]{43}\z/, key)
end
# router.ex, inside your browser scope
get "/speech/:key", SpeechController, :show
The valid_key?/1 check matters: without it a crafted key like ../../config would become a path traversal. Keep it.
Step 4: stream audio for time-to-first-sound
Caching solves repeat requests. The first request still waits for the whole file, which for a paragraph on the HD model can be a few seconds of silence. The fix is to stream: the speech API sends audio progressively, and an MP3 is playable from the first frames, so a controller can forward bytes to the browser as they arrive and an <audio> element starts playing almost immediately.
Req’s into: option takes a function that receives each chunk. We keep the latest conn in the response’s private map, because Plug.Conn.chunk/2 returns an updated conn:
def stream(conn, %{"text" => text}) do
text = String.slice(text, 0, 4_096)
conn = conn |> put_resp_content_type("audio/mpeg") |> send_chunked(200)
forward = fn {:data, data}, {req, resp} ->
if resp.status == 200 do
current = Req.Response.get_private(resp, :conn, conn)
case Plug.Conn.chunk(current, data) do
{:ok, current} -> {:cont, {req, Req.Response.put_private(resp, :conn, current)}}
{:error, _closed} -> {:halt, {req, resp}}
end
else
{:halt, {req, resp}}
end
end
case Req.post("https://api.openai.com/v1/audio/speech",
json: MyApp.Speech.body(text, []),
auth: {:bearer, Application.fetch_env!(:my_app, :openai_api_key)},
into: forward
) do
{:ok, resp} -> Req.Response.get_private(resp, :conn, conn)
{:error, _reason} -> conn
end
end
Two honest caveats. Once you have called send_chunked/2 the status is committed, so an OpenAI error mid-stream cannot become a clean 500; the browser just gets a short or empty file. And a streamed response bypasses the cache. In practice the best of both is to stream the first play and write the cache in the background, or simply to accept the first-play latency and rely on the cache plus sentence chunking (next step), which already gets the first sound out quickly because the first sentence is short.
Never put the user’s text in a query string for anything sensitive; it ends up in access logs. For real use, have the LiveView store the text under a short-lived token and stream by token.
Step 5: chunk long answers by sentence
An AI answer can easily exceed 4,096 characters, and even when it does not, generating a whole page before playing anything is slow. Splitting by sentence fixes both: the first sentence is short, so its audio comes back fast, and later sentences generate while the first one plays.
defmodule MyApp.Speech.Chunker do
@target 600
@doc "Splits text into sentence-aligned chunks of roughly @target characters."
def chunks(text) do
text
|> String.split(~r/(?<=[.!?])\s+/u, trim: true)
|> Enum.chunk_while(
"",
fn sentence, acc ->
joined = String.trim(acc <> " " <> sentence)
if String.length(joined) > @target and acc != "", do: {:cont, acc, sentence}, else: {:cont, joined}
end,
fn
"" -> {:cont, ""}
acc -> {:cont, acc, ""}
end
)
|> Enum.reject(&(&1 == ""))
end
end
Around 600 characters is a good target: big enough that you are not making dozens of requests, small enough that the first chunk returns in about a second. If you split mid-sentence the voice’s intonation breaks at the seam, which is why the split is on sentence punctuation. A single sentence longer than the target is kept whole, and anything over the 4,096 limit is rare enough in prose that a hard String.slice/3 fallback is fine.
Step 6: the LiveView “Listen” button
Now the user-facing part. The LiveView generates chunks in a supervised task so a slow API call never blocks the socket, and pushes each audio URL to the browser as soon as it is cached. A small hook keeps a playlist and plays the URLs in order.
defmodule MyAppWeb.AnswerLive do
use MyAppWeb, :live_view
alias MyApp.Speech.{Cache, Chunker}
def mount(_params, _session, socket) do
{:ok, assign(socket, answer: load_answer(), speaking: false)}
end
def handle_event("listen", _params, %{assigns: %{speaking: true}} = socket), do: {:noreply, socket}
def handle_event("listen", _params, socket) do
lv = self()
chunks = Chunker.chunks(socket.assigns.answer)
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
chunks
|> Task.async_stream(&Cache.fetch(&1, voice: "coral"), max_concurrency: 3, timeout: 60_000, on_timeout: :kill_task)
|> Enum.each(fn
{:ok, {:ok, key}} -> send(lv, {:speech_ready, key})
_failed -> send(lv, :speech_failed)
end)
send(lv, :speech_done)
end)
{:noreply, socket |> assign(speaking: true) |> push_event("speech:reset", %{})}
end
def handle_info({:speech_ready, key}, socket) do
{:noreply, push_event(socket, "speech:enqueue", %{url: ~p"/speech/#{key}"})}
end
def handle_info(:speech_failed, socket) do
{:noreply, put_flash(socket, :error, "Part of the audio could not be generated.")}
end
def handle_info(:speech_done, socket), do: {:noreply, assign(socket, speaking: false)}
defp load_answer, do: "..."
end
Task.async_stream/3 generates up to three chunks at once but yields results in order, which is exactly what a playlist needs: chunk two may finish before chunk one, but it will not be enqueued first. on_timeout: :kill_task turns a stuck request into an {:exit, :timeout} result instead of crashing the whole task. The speaking guard stops a double click from starting two playlists. Add {Task.Supervisor, name: MyApp.TaskSupervisor} to your application’s children if you do not have one already.
The template is a button, a status line and the AI disclosure:
<div id="answer-audio" phx-hook="SpeechPlayer">
<button phx-click="listen" disabled={@speaking} class="btn">
<%= if @speaking, do: "Preparing audio...", else: "Listen" %>
</button>
<p class="text-xs text-zinc-500">This voice is AI-generated.</p>
</div>
And the hook, in assets/js/app.js:
const SpeechPlayer = {
mounted() {
this.queue = []
this.audio = new Audio()
this.audio.addEventListener("ended", () => this.playNext())
this.handleEvent("speech:reset", () => {
this.queue = []
this.audio.pause()
})
this.handleEvent("speech:enqueue", ({url}) => {
this.queue.push(url)
if (this.audio.paused) this.playNext()
})
},
playNext() {
const next = this.queue.shift()
if (!next) return
this.audio.src = next
this.audio.play().catch(() => {})
}
}
let liveSocket = new LiveSocket("/live", Socket, {hooks: {SpeechPlayer}, params: {_csrf_token: csrfToken}})
The single reused Audio object matters for autoplay rules. Browsers only allow audio to start after a user gesture, and the click on “Listen” is that gesture. Reusing one element that was set up on the page, rather than creating a new one per chunk, gives later chunks the best chance of staying inside that permission. The .catch swallows the rare rejected play() so a blocked chunk does not throw; the next ended event never fires in that case, so if you want to be thorough, show a “Tap to continue” button when play() rejects.
Pairing it with streaming chat
If your answers come from a streaming chat completion (see streaming OpenAI responses in LiveView), you do not have to wait for the full answer before speaking. Buffer the streamed tokens, and every time the buffer contains a sentence terminator, cut the complete sentence off the front and hand it to the same Cache.fetch/2 plus speech:enqueue path. The user hears the first sentence while the model is still writing the third. Keep the same ordering discipline: generate concurrently if you like, but enqueue in sentence order.
Choosing voice, model and format
Some practical defaults after building a few of these:
-
Model. Start with
gpt-4o-mini-tts. It is fast, sounds natural, and theinstructionsfield is genuinely useful for matching a product’s tone. Usetts-1when cost per character dominates, andtts-1-hdfor pre-rendered content (a podcast version of your blog, onboarding audio) where latency does not matter. - Voice. Pick one and keep it. Users notice when the assistant’s voice changes between answers. Make it a setting only if you have a reason.
- Format. MP3 plays everywhere and streams well. Opus is smaller and good for low-bandwidth mobile, but check your target browsers. WAV and PCM are for when you post-process the audio server-side.
- Text hygiene. Strip Markdown before synthesis. A voice reading “asterisk asterisk important asterisk asterisk” is the fastest way to make the feature feel broken. Expand things a listener needs (convert a code block to “a code example is shown on screen”) rather than reading them literally.
Costs and limits in production
Treat speech like any other paid AI call. Characters are the unit, so log String.length(text) per request next to the user or organisation, and you can see exactly who is driving spend. Cap it per plan the same way you would cap tokens: if your app already meters AI usage, speech characters are one more feature on the same meter. Aurora Meter (MIT, free core) does that in a line of Elixir per request if you would rather not build the counter yourself.
Two other limits to plan for. OpenAI rate limits apply per minute, so the max_concurrency: 3 above is also a politeness setting; a page with ten users all pressing “Listen” multiplies it. And the cache grows forever unless you prune it: a nightly Oban job that deletes files not read in 30 days keeps disk bounded (see our Oban background jobs guide for the cron setup).
Testing without calling OpenAI
Keep the network out of your test suite. Req has a built-in test adapter: pass a plug: option in test config and Req calls a function instead of the network.
# config/test.exs
config :my_app, :speech_req_options, plug: {Req.Test, MyApp.Speech}
# in MyApp.Speech, merge Application.get_env(:my_app, :speech_req_options, []) into the Req.post options
# in a test
test "caches generated audio by content" do
Req.Test.stub(MyApp.Speech, fn conn ->
Plug.Conn.send_resp(conn, 200, "fake-mp3-bytes")
end)
{:ok, key} = MyApp.Speech.Cache.fetch("Hello there.")
assert File.read!(MyApp.Speech.Cache.path(key)) == "fake-mp3-bytes"
assert {:ok, ^key} = MyApp.Speech.Cache.fetch("Hello there.")
end
Test the chunker as a pure function (sentence boundaries, the long-sentence case, empty input), and test the LiveView with render_click/2 and assert_push_event/3 to confirm speech:enqueue arrives in order.
Summary
Elixir text to speech is a small amount of code once the pieces are named:
-
A Req client for
POST /v1/audio/speechthat returns MP3 bytes and keeps error detail. - A cache keyed by a hash of the exact request body, written atomically.
- A controller that serves cached audio with an immutable cache header and a validated key.
-
Optional byte streaming with Req’s
into:for the fastest first play. - Sentence chunking so long answers start speaking in about a second.
- A LiveView button plus a playlist hook, with the required AI-voice disclosure.
If you would rather start from a working AI app than wire the plumbing yourself, the PHX AI Document Starter ships PDF upload and AI chat over documents in LiveView, behind a provider-agnostic AI boundary with a deterministic fake for dev and CI. A speech module slots in beside the chat provider, so a “Listen to this answer” button is an afternoon rather than a project. And if you plan to build more than one product, the Builder Pass gives you every current and future PhxTemplates template for a single lifetime price.