use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::runtime_provider::ProviderEndpoint;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceMode {
#[default]
Delegated,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Interruption {
#[default]
Steer,
Cancel,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnDetection {
#[default]
ServerVad,
SemanticVad,
}
pub const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-2";
pub const DEFAULT_VOICE: &str = "marin";
pub const DEFAULT_FILLER_AFTER_MS: u64 = 1_500;
pub const DEFAULT_FILLER: &str = "One moment.";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct VoiceChannelConfig {
#[serde(default)]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "delegated"))]
pub mode: VoiceMode,
#[serde(default = "default_model")]
pub model: String,
#[serde(default = "default_voice")]
pub voice: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub greeting: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "server_vad"))]
pub turn_detection: TurnDetection,
#[serde(default)]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "steer"))]
pub interruption: Interruption,
#[serde(default = "default_filler_after_ms")]
pub filler_after_ms: u64,
#[serde(default = "default_filler")]
pub filler: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speaking_style: Option<String>,
}
fn default_model() -> String {
DEFAULT_REALTIME_MODEL.to_string()
}
fn default_voice() -> String {
DEFAULT_VOICE.to_string()
}
fn default_filler_after_ms() -> u64 {
DEFAULT_FILLER_AFTER_MS
}
fn default_filler() -> String {
DEFAULT_FILLER.to_string()
}
impl Default for VoiceChannelConfig {
fn default() -> Self {
Self {
mode: VoiceMode::default(),
model: default_model(),
voice: default_voice(),
language: None,
greeting: None,
turn_detection: TurnDetection::default(),
interruption: Interruption::default(),
filler_after_ms: DEFAULT_FILLER_AFTER_MS,
filler: default_filler(),
speaking_style: None,
}
}
}
impl VoiceChannelConfig {
pub fn validate(&self) -> Result<(), String> {
let check = |name: &str, value: &str, max: usize| {
if value.trim().is_empty() {
Err(format!("{name} must not be empty"))
} else if value.len() > max {
Err(format!("{name} must be at most {max} characters"))
} else {
Ok(())
}
};
check("model", &self.model, 128)?;
check("voice", &self.voice, 64)?;
check("filler", &self.filler, 200)?;
if let Some(greeting) = &self.greeting {
check("greeting", greeting, 500)?;
}
if let Some(style) = &self.speaking_style {
check("speaking_style", style, 4_000)?;
}
if let Some(language) = &self.language {
check("language", language, 16)?;
}
if self.filler_after_ms > 60_000 {
return Err("filler_after_ms must be at most 60000".into());
}
Ok(())
}
pub fn speech_instructions(&self) -> String {
let mut text = String::from(
"You are the voice of an assistant. You never answer on your own: \
you only speak text you are given, exactly as written, in a natural \
conversational voice. Do not add facts, greetings or follow-up questions.",
);
if let Some(style) = &self.speaking_style {
text.push_str("\n\nSpeaking style: ");
text.push_str(style.trim());
}
text
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RealtimeSessionConfig {
pub model: String,
pub voice: String,
pub instructions: String,
pub language: Option<String>,
pub turn_detection: TurnDetection,
pub safety_identifier: Option<String>,
}
impl RealtimeSessionConfig {
pub fn from_channel(config: &VoiceChannelConfig) -> Self {
Self {
model: config.model.clone(),
voice: config.voice.clone(),
instructions: config.speech_instructions(),
language: config.language.clone(),
turn_detection: config.turn_detection,
safety_identifier: None,
}
}
pub fn with_safety_identifier(mut self, id: impl Into<String>) -> Self {
self.safety_identifier = Some(id.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RealtimeCall {
pub answer_sdp: String,
pub call_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum RealtimeEvent {
SpeechStarted,
SpeechStopped,
InputTranscriptDelta { item_id: String, delta: String },
InputTranscriptCompleted { item_id: String, transcript: String },
OutputTranscriptDelta { response_id: String, delta: String },
OutputTranscriptCompleted {
response_id: String,
transcript: String,
},
ResponseDone {
response_id: String,
cancelled: bool,
},
Error { message: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum RealtimeCommand {
Speak { text: String },
StopSpeaking,
}
#[derive(Debug, thiserror::Error)]
pub enum RealtimeDriverError {
#[error("realtime provider returned an error: {0}")]
Provider(String),
#[error("realtime request failed: {0}")]
Transport(String),
#[error("realtime request is invalid: {0}")]
Invalid(String),
}
#[async_trait]
pub trait RealtimeConnection: Send {
async fn next_event(&mut self) -> Option<Result<RealtimeEvent, RealtimeDriverError>>;
async fn send(&mut self, command: RealtimeCommand) -> Result<(), RealtimeDriverError>;
async fn close(&mut self);
}
pub type BoxedRealtimeConnection = Box<dyn RealtimeConnection>;
#[async_trait]
pub trait RealtimeDriver: Send + Sync {
async fn accept_webrtc(
&self,
endpoint: &ProviderEndpoint,
session: &RealtimeSessionConfig,
offer_sdp: &str,
) -> Result<RealtimeCall, RealtimeDriverError>;
async fn attach(
&self,
endpoint: &ProviderEndpoint,
call_id: &str,
) -> Result<BoxedRealtimeConnection, RealtimeDriverError>;
}
pub type SharedRealtimeDriver = Arc<dyn RealtimeDriver>;
impl fmt::Debug for dyn RealtimeDriver {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("RealtimeDriver")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_defaults_round_trip_from_empty_json() {
let config: VoiceChannelConfig = serde_json::from_str("{}").unwrap();
assert_eq!(config, VoiceChannelConfig::default());
assert_eq!(config.mode, VoiceMode::Delegated);
assert_eq!(config.model, DEFAULT_REALTIME_MODEL);
assert_eq!(config.interruption, Interruption::Steer);
config.validate().unwrap();
}
#[test]
fn config_rejects_bad_values() {
let blank = VoiceChannelConfig {
voice: " ".into(),
..Default::default()
};
assert!(blank.validate().unwrap_err().contains("voice"));
let long = VoiceChannelConfig {
greeting: Some("x".repeat(501)),
..Default::default()
};
assert!(long.validate().unwrap_err().contains("greeting"));
let slow = VoiceChannelConfig {
filler_after_ms: 60_001,
..Default::default()
};
assert!(slow.validate().is_err());
}
#[test]
fn unknown_mode_is_rejected() {
let err = serde_json::from_str::<VoiceChannelConfig>(r#"{"mode":"telepathy"}"#);
assert!(err.is_err());
}
#[test]
fn speech_instructions_carry_style_but_no_business_rules() {
let config = VoiceChannelConfig {
speaking_style: Some("Warm and brief.".into()),
..Default::default()
};
let text = config.speech_instructions();
assert!(text.contains("exactly as written"));
assert!(text.ends_with("Warm and brief."));
}
}