Current section

Files

Jump to
codex_sdk examples voice_with_agent.exs
Raw

examples/voice_with_agent.exs

#!/usr/bin/env mix run
# Voice with Agent Example
#
# Demonstrates using a Codex Agent with the voice pipeline.
# TLS note: set CODEX_CA_CERTIFICATE to a PEM bundle to override trust roots for HTTPS.
# If unset, SSL_CERT_FILE is used instead; blank values are ignored.
# Uses a real audio file (test/fixtures/audio/voice_sample.wav) for input.
# Saves received audio to /tmp/codex_voice_agent.pcm
#
# Audio formats:
# - Input: 16-bit PCM, 24kHz, mono (WAV file)
# - Output: 16-bit PCM, 24kHz, mono (saved to /tmp)
#
# To play the output: aplay -f S16_LE -r 24000 -c 1 /tmp/codex_voice_agent.pcm
#
# Usage:
# mix run examples/voice_with_agent.exs
defmodule VoiceWithAgentExample do
@moduledoc """
Example demonstrating voice pipelines with Codex Agents using real audio.
"""
alias Codex.Agent
alias Codex.Voice.AgentWorkflow
alias Codex.Voice.Config
alias Codex.Voice.Config.TTSSettings
alias Codex.Voice.Events.VoiceStreamEventAudio
alias Codex.Voice.Events.VoiceStreamEventLifecycle
alias Codex.Voice.Events.VoiceStreamEventError
alias Codex.Voice.Input.AudioInput
alias Codex.Voice.Pipeline
alias Codex.Voice.Result
@output_audio_path "/tmp/codex_voice_agent.pcm"
def main do
case run() do
:ok ->
:ok
{:skip, reason} ->
IO.puts("SKIPPED: #{reason}")
{:error, reason} ->
IO.puts("[Error] #{inspect(reason)}")
System.halt(1)
end
end
def run do
IO.puts("=== Voice with Agent Example ===\n")
# Voice auth accepts Codex.Auth precedence and OPENAI_API_KEY.
unless fetch_api_key() do
{:error, "no API key found (CODEX_API_KEY, auth.json OPENAI_API_KEY, or OPENAI_API_KEY)"}
else
with {:ok, audio_data} <- load_fixture_audio(),
:ok <- initialize_output_file() do
# Create an agent with instructions
{:ok, agent} =
Agent.new(
name: "CustomerServiceAgent",
model: "gpt-4o",
instructions: """
You are a customer service agent for an e-commerce company.
Help customers with their orders. Be friendly and professional.
Keep responses concise since this is a voice interaction.
"""
)
IO.puts("[OK] Created agent: #{agent.name}")
IO.puts(" Model: #{agent.model}")
# Create workflow from agent with context
context = %{
customer_id: "CUST-12345",
session_start: DateTime.utc_now()
}
workflow = AgentWorkflow.new(agent, context: context)
IO.puts("\nWorkflow context:")
IO.puts(" customer_id: #{context.customer_id}")
IO.puts(" session_start: #{context.session_start}")
# Create pipeline configuration
config = %Config{
workflow_name: "CustomerServiceDemo",
tts_settings: %TTSSettings{
voice: :nova,
speed: 1.0
}
}
# Create pipeline
pipeline =
Pipeline.new(
workflow: workflow,
config: config
)
IO.puts("\n[OK] Pipeline created")
IO.puts(" STT Model: #{pipeline.stt_model.model}")
IO.puts(" TTS Model: #{pipeline.tts_model.model}")
# Create audio input from WAV file
audio = AudioInput.new(audio_data)
IO.puts("\nRunning voice pipeline...")
# Pipeline.run always returns {:ok, result} - errors are delivered as events
{:ok, result} = Pipeline.run(pipeline, audio)
process_results(result)
end
end
end
defp process_results(result) do
stats =
result
|> Result.stream()
|> Enum.reduce(
%{
audio_bytes: 0,
turns: 0,
error_count: 0,
session_ended?: false,
insufficient_quota?: false
},
fn event, acc ->
handle_event(event, acc)
end
)
if stats.insufficient_quota? do
{:skip, "insufficient_quota"}
else
if stats.error_count > 0 and not stats.session_ended? do
{:error, :voice_agent_pipeline_failed}
else
IO.puts("\n[OK] Pipeline Complete")
IO.puts(" Total audio output: #{stats.audio_bytes} bytes")
IO.puts(" Total turns: #{stats.turns}")
# Show output file info and playback instructions
output_size = File.stat!(@output_audio_path).size
IO.puts("""
Audio saved to: #{@output_audio_path}
Output file size: #{output_size} bytes
To play the response audio:
aplay -f S16_LE -r 24000 -c 1 #{@output_audio_path}
Or convert to WAV:
sox -t raw -r 24000 -b 16 -c 1 -e signed-integer #{@output_audio_path} /tmp/response.wav
""")
:ok
end
end
end
defp load_fixture_audio do
audio_file_path = Path.join([__DIR__, "..", "test", "fixtures", "audio", "voice_sample.wav"])
case File.read(audio_file_path) do
{:ok, wav_data} ->
IO.puts("[OK] Loaded audio file: #{byte_size(wav_data)} bytes")
{:ok, wav_data}
{:error, reason} ->
{:error, {:audio_fixture_read_failed, reason}}
end
end
defp initialize_output_file do
File.write!(@output_audio_path, "")
IO.puts("[OK] Output audio will be saved to: #{@output_audio_path}")
:ok
end
defp handle_event(%VoiceStreamEventAudio{data: data}, stats) when is_binary(data) do
# Append audio to output file
File.write!(@output_audio_path, data, [:append])
IO.write(".")
%{stats | audio_bytes: stats.audio_bytes + byte_size(data)}
end
defp handle_event(%VoiceStreamEventAudio{data: nil}, stats) do
stats
end
defp handle_event(%VoiceStreamEventLifecycle{event: :turn_started}, stats) do
IO.puts("\n[Turn started]")
stats
end
defp handle_event(%VoiceStreamEventLifecycle{event: :turn_ended}, stats) do
IO.puts("\n[Turn ended]")
%{stats | turns: stats.turns + 1}
end
defp handle_event(%VoiceStreamEventLifecycle{event: :session_ended}, stats) do
IO.puts("\n[Session ended]")
%{stats | session_ended?: true}
end
defp handle_event(%VoiceStreamEventError{error: error}, stats) do
IO.puts("\n[Error] #{inspect(error)}")
%{
stats
| error_count: stats.error_count + 1,
insufficient_quota?: stats.insufficient_quota? or insufficient_quota_error?(error)
}
end
defp handle_event(_event, stats) do
stats
end
defp insufficient_quota_error?(error) do
error
|> inspect(limit: :infinity)
|> String.downcase()
|> String.contains?("insufficient_quota")
end
defp fetch_api_key, do: Codex.Auth.direct_api_key()
end
VoiceWithAgentExample.main()