Skip to main content

What are services?

Voxray organizes all AI calls — speech recognition, language model inference, and speech synthesis — behind three Go interfaces: STTService, LLMService, and TTSService. The rest of the pipeline (VAD, turn detection, transport, recording) never imports a provider SDK directly. It only calls methods on these interfaces. This means you can swap from OpenAI Whisper to Groq’s transcription endpoint, or from OpenAI GPT-4o to Anthropic Claude, by changing one or two keys in config.json. No code changes, no recompilation.
Provider selection, model names, and voices are all resolved at startup from config.json. After the factory constructs the service objects, every pipeline stage works identically regardless of which provider is underneath.

Service interfaces

These are the actual Go interfaces every provider implementation must satisfy. They live in pkg/services/interfaces.go and pkg/services/llmapi/api.go.

Realtime sessions

Some providers (OpenAI Realtime, Hume, Inworld) expose a single bidirectional session that merges STT, LLM, and TTS into one persistent connection, rather than three separate request/response calls. Voxray models this with a separate RealtimeSession interface.
Realtime providers bypass the independent STT → LLM → TTS chain. Instead, RealtimeSession.Events() emits RealtimeEvent values that carry either LLMTextFrame or TTSAudioRawFrame — whatever the provider sends first. This achieves lower latency but removes the ability to mix providers (e.g. OpenAI Realtime STT + Anthropic LLM). Use realtime.NewFromConfig(cfg, provider) to construct realtime sessions; never call NewLLMFromConfig / NewSTTFromConfig / NewTTSFromConfig for realtime providers.

Factory functions

pkg/services/factory.go wires provider constants to concrete implementations. You never construct provider clients directly. NewServicesFromConfig is what the pipeline runner calls at startup. It applies the provider precedence rules so you don’t have to call the individual functions yourself unless you need fine-grained control.

API key resolution

For every provider, the factory calls cfg.GetAPIKey(serviceName, envVarName). The resolution order is:
  1. config.api_keys[serviceName] — value in the JSON config’s api_keys map.
  2. Environment variable — the provider-specific env var (e.g. OPENAI_API_KEY).
  3. Empty string — the service is constructed with an empty key. Most providers will return authentication errors at the first API call.
Never commit API keys to source control. In production, set them via environment variables or a secrets manager and leave api_keys values empty (or omit the keys entirely) in config.json.
Special cases:

Provider support matrix

The table below covers every provider registered in factory.go. The API Key Env Var column is the fallback environment variable if the key is not in api_keys.
AWS STT uses Amazon Transcribe. AWS LLM uses Amazon Bedrock. AWS TTS uses Amazon Polly. All three share the same aws config key but use different regional API surfaces; the AWS SDK credential chain (env vars, ~/.aws/credentials, IAM role) applies independently of the aws entry in api_keys.

Realtime providers in depth

Three providers implement the RealtimeSession interface rather than separate STT/LLM/TTS services. Each maintains a single persistent WebSocket or streaming RPC to the provider. When provider is set to one of these in the config, construct sessions with realtime.NewFromConfig(cfg, provider) — not via the NewLLMFromConfig/NewSTTFromConfig/NewTTSFromConfig factories. The runner selects the realtime path automatically when a realtime provider is detected. Request/response pipeline (standard):
Realtime session pipeline:
The realtime path eliminates two round-trip boundaries (STT result → LLM, LLM token → TTS), which is the primary source of latency reduction.