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.Net.CA 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) req_opts = [ headers: [ {"Authorization", "Bearer #{api_key}"}, {"Content-Type", "application/json"} ], json: body, into: :self ] |> CA.merge_req_options() # Create a streaming request case request_client.("#{base_url}/audio/speech", req_opts) 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