Current section

Files

Jump to
codex_sdk examples voice_pipeline.exs
Raw

examples/voice_pipeline.exs

#!/usr/bin/env mix run
# Voice Pipeline Example
#
# Demonstrates the non-realtime voice pipeline for STT -> Workflow -> TTS.
# 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_response.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_response.pcm
#
# Usage:
# mix run examples/voice_pipeline.exs
defmodule VoicePipelineExample do
@moduledoc """
Example demonstrating the voice pipeline with real audio.
"""
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
alias Codex.Voice.SimpleWorkflow
@output_audio_path "/tmp/codex_voice_response.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 Pipeline 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 a simple workflow that echoes input
workflow =
SimpleWorkflow.new(
fn text ->
IO.puts("\n[Transcribed] #{text}")
["You said: #{text}. How can I help you with that?"]
end,
greeting: "Hello! I'm ready to listen."
)
IO.puts("[OK] Created workflow with greeting: #{workflow.greeting}")
# Create pipeline configuration
config = %Config{
workflow_name: "VoicePipelineDemo",
tts_settings: %TTSSettings{
voice: :nova,
speed: 1.0
}
}
# Create the pipeline
pipeline =
Pipeline.new(
workflow: workflow,
config: config
)
IO.puts("[OK] Pipeline created")
IO.puts(" STT Model: #{pipeline.stt_model.model}")
IO.puts(" TTS Model: #{pipeline.tts_model.model}")
IO.puts(" TTS Voice: #{config.tts_settings.voice}")
# Create audio input from WAV file
# Note: AudioInput accepts WAV data directly - it will be sent as WAV to OpenAI
audio =
AudioInput.new(audio_data,
frame_rate: 24_000,
sample_width: 2,
channels: 1
)
IO.puts("\nProcessing audio input (#{byte_size(audio_data)} bytes)...")
# Pipeline.run always returns {:ok, result} - errors are delivered as events
{:ok, result} = Pipeline.run(pipeline, audio)
IO.puts("Pipeline started, streaming results...")
stream_results(result)
end
end
end
defp stream_results(result) do
stats =
result
|> Result.stream()
|> Enum.reduce(
%{audio_bytes: 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_pipeline_failed}
else
IO.puts("\n[OK] Pipeline completed!")
IO.puts("Total audio output: #{stats.audio_bytes} bytes")
# 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
IO.puts("\n[Audio] End of audio segment")
stats
end
defp handle_event(%VoiceStreamEventLifecycle{event: :session_ended}, stats) do
IO.puts("[Lifecycle] session_ended")
%{stats | session_ended?: true}
end
defp handle_event(%VoiceStreamEventLifecycle{event: event}, stats) do
IO.puts("[Lifecycle] #{event}")
stats
end
defp handle_event(%VoiceStreamEventError{error: error}, stats) do
IO.puts("[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
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
VoicePipelineExample.main()