Current section
Files
Jump to
Current section
Files
lib/codex/voice/models/openai_tts.ex
defmodule Codex.Voice.Models.OpenAITTS do
@moduledoc """
OpenAI text-to-speech model implementation.
This module implements the `Codex.Voice.Model.TTSModel` behaviour using
OpenAI's audio speech API. It converts text to audio and returns the
result as a stream of PCM bytes.
## Default Model
The default model is `gpt-4o-mini-tts`, which provides high-quality
text-to-speech with support for multiple voices and instructions.
## Voices
The following voices are available:
- `:alloy` - Neutral and balanced
- `:ash` - Warm and conversational (default)
- `:coral` - Clear and articulate
- `:echo` - Soft and thoughtful
- `:fable` - Expressive and dramatic
- `:onyx` - Deep and authoritative
- `:nova` - Friendly and upbeat
- `:sage` - Calm and measured
- `:shimmer` - Bright and energetic
## Example
model = OpenAITTS.new()
settings = TTSSettings.new(voice: :nova, speed: 1.0)
audio_stream = OpenAITTS.run(model, "Hello, world!", settings)
Enum.each(audio_stream, fn chunk ->
# Process PCM audio chunk
end)
"""
@behaviour Codex.Voice.Model.TTSModel
alias Codex.Auth
alias Codex.Config.Defaults
alias Codex.Voice.Config.TTSSettings
defstruct [:model, :client, :api_key, :base_url]
@type t :: %__MODULE__{
model: String.t(),
client: term(),
api_key: String.t() | nil,
base_url: String.t()
}
@default_model Defaults.tts_model()
@default_voice Defaults.tts_default_voice()
@default_base_url Defaults.openai_api_base_url()
@doc """
Create a new OpenAI TTS model.
## Options
- `:client` - Optional HTTP client (for testing)
- `:api_key` - API key (defaults to OPENAI_API_KEY env var)
- `:base_url` - API base URL (defaults to OpenAI)
## Examples
iex> model = Codex.Voice.Models.OpenAITTS.new()
iex> model.model
"gpt-4o-mini-tts"
iex> model = Codex.Voice.Models.OpenAITTS.new("tts-1")
iex> model.model
"tts-1"
"""
@spec new(String.t() | nil, keyword()) :: t()
def new(model \\ nil, opts \\ []) do
%__MODULE__{
model: model || @default_model,
client: Keyword.get(opts, :client),
api_key: Keyword.get(opts, :api_key),
base_url: Keyword.get(opts, :base_url, @default_base_url)
}
end
@impl true
def model_name, do: @default_model
@doc """
Convert text to speech, returning a stream of PCM audio bytes.
## Parameters
- `model` - The OpenAITTS model struct
- `text` - The text to convert to speech
- `settings` - TTSSettings with voice and speed options
## Returns
An enumerable that yields audio bytes in PCM format. Each chunk
is approximately 1024 bytes.
## Example
model = OpenAITTS.new()
settings = TTSSettings.new(voice: :nova)
audio_chunks =
OpenAITTS.run(model, "Hello!", settings)
|> Enum.to_list()
"""
@spec run(t(), String.t(), TTSSettings.t()) :: Enumerable.t()
def run(%__MODULE__{} = model, text, %TTSSettings{} = settings) do
api_key = model.api_key || Auth.direct_api_key()
voice = voice_to_string(settings.voice || @default_voice)
body =
%{
model: model.model,
input: text,
voice: voice,
response_format: "pcm"
}
|> maybe_add_speed(settings.speed)
|> maybe_add_instructions(settings.instructions)
Stream.resource(
fn -> start_streaming_request(model.base_url, api_key, body, model.client) end,
&receive_chunk/1,
&cleanup_request/1
)
end
@spec start_streaming_request(String.t(), String.t(), map(), term()) ::
{:streaming, Req.Response.t()} | {:error, term()}
defp start_streaming_request(base_url, api_key, body, client) do
request_client = resolve_request_client(client)
# Create a streaming request
case request_client.("#{base_url}/audio/speech",
headers: [
{"Authorization", "Bearer #{api_key}"},
{"Content-Type", "application/json"}
],
json: body,
into: :self
) do
{:ok, response} ->
normalize_stream_start_response(response)
{:error, reason} ->
{:error, reason}
end
end
defp resolve_request_client(nil), do: &Req.post/2
defp resolve_request_client(client) when is_function(client, 2), do: client
defp resolve_request_client(client) when is_atom(client) do
&client.post/2
end
@spec receive_chunk({:streaming, Req.Response.t()} | {:error, term()}) ::
{[binary()], {:streaming, Req.Response.t()}}
| {:halt, {:streaming, Req.Response.t()}}
| {:halt, {:error, term()}}
defp receive_chunk({:error, reason}) do
raise RuntimeError, message: "TTS request failed: #{format_reason(reason)}"
end
defp receive_chunk({:streaming, _response} = state) do
receive do
{_ref, {:data, chunk}} ->
{[chunk], state}
{_ref, :done} ->
{:halt, state}
{_ref, {:error, reason}} ->
raise RuntimeError, message: "TTS stream failed: #{format_reason(reason)}"
{:DOWN, _ref, :process, _pid, reason} ->
raise RuntimeError, message: "TTS stream terminated: #{format_reason(reason)}"
after
Defaults.tts_stream_timeout_ms() ->
raise RuntimeError,
message: "TTS stream timed out after #{Defaults.tts_stream_timeout_ms()}ms"
end
end
@spec cleanup_request({:streaming, Req.Response.t()} | {:error, term()}) :: :ok
defp cleanup_request(_state), do: :ok
@spec voice_to_string(TTSSettings.voice()) :: String.t()
defp voice_to_string(voice) when is_atom(voice), do: Atom.to_string(voice)
defp voice_to_string(voice) when is_binary(voice), do: voice
@spec maybe_add_speed(map(), float() | nil) :: map()
defp maybe_add_speed(body, nil), do: body
defp maybe_add_speed(body, speed), do: Map.put(body, :speed, speed)
@spec maybe_add_instructions(map(), String.t() | nil) :: map()
defp maybe_add_instructions(body, nil), do: body
defp maybe_add_instructions(body, instructions) do
Map.put(body, :instructions, instructions)
end
@spec normalize_stream_start_response(term()) ::
{:streaming, Req.Response.t() | map()} | {:error, term()}
defp normalize_stream_start_response(response) do
status = response_status(response)
if success_status?(status) do
{:streaming, response}
else
{:error, {:api_error, status, response_body(response)}}
end
end
@spec success_status?(integer() | nil) :: boolean()
defp success_status?(status) when is_integer(status), do: status >= 200 and status < 300
defp success_status?(_), do: true
@spec response_status(term()) :: integer() | nil
defp response_status(%{status: status}) when is_integer(status), do: status
defp response_status(_), do: nil
@spec response_body(term()) :: term()
defp response_body(%{body: body}), do: body
defp response_body(_), do: nil
@spec format_reason(term()) :: String.t()
defp format_reason(reason) when is_binary(reason), do: reason
defp format_reason(reason), do: inspect(reason)
end