use crate::frames::FrameProcessor;
use super::core::util::percent_encode;
use super::core::{
AudioSpec, Handshake, InterimPolicy, NoiseBackend, Outgoing, SttCoreConfig, SttEvent,
SttProvider, SttService, WsMessage,
};
use std::time::Duration;
const URL: &str = "wss://api.sarvam.ai/speech-to-text-realtime/ws";
const PING_EVERY: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct SarvamRealtimeSttConfig {
pub api_key: String,
pub model: String,
pub language: Option<String>,
pub stream_type: String,
pub mode: Option<String>,
pub prompt: Option<String>,
pub endpointing: String,
pub silence_duration_ms: Option<u32>,
pub threshold: Option<f32>,
pub min_speech_duration_ms: Option<u32>,
pub sample_rate: u32,
pub noise_reduction: bool,
pub noise_backend: NoiseBackend,
pub agc: bool,
pub audio_gating: bool,
pub pre_roll_ms: u32,
pub stop_release_timeout_ms: u64,
pub max_turn_ms: Option<u64>,
pub partials: bool,
}
impl Default for SarvamRealtimeSttConfig {
fn default() -> Self {
Self {
api_key: String::new(),
model: "saaras:v3-realtime".to_string(),
language: Some("unknown".to_string()),
stream_type: "fast".to_string(),
mode: None,
prompt: None,
endpointing: "manual".to_string(),
silence_duration_ms: None,
threshold: None,
min_speech_duration_ms: None,
sample_rate: 16_000,
noise_reduction: true,
noise_backend: NoiseBackend::Rnnoise,
agc: true,
audio_gating: true,
pre_roll_ms: 500,
stop_release_timeout_ms: 1_200,
max_turn_ms: None,
partials: true,
}
}
}
impl SarvamRealtimeSttConfig {
fn split(self) -> (SttCoreConfig, SarvamRealtimeProvider) {
let core = SttCoreConfig {
sample_rate: self.sample_rate,
noise_reduction: self.noise_reduction,
noise_backend: self.noise_backend,
agc: self.agc,
agc_config: Default::default(),
audio_gating: self.audio_gating,
pre_roll_ms: self.pre_roll_ms,
stop_release_timeout_ms: self.stop_release_timeout_ms,
interim_policy: if self.partials { InterimPolicy::Emit } else { InterimPolicy::Drop },
max_turn_ms: self.max_turn_ms,
};
let audio = AudioSpec::new(self.sample_rate, "linear16");
(core, SarvamRealtimeProvider { config: self, audio })
}
}
pub struct SarvamRealtimeProvider {
config: SarvamRealtimeSttConfig,
audio: AudioSpec,
}
impl SarvamRealtimeProvider {
fn language_code(&self) -> String {
match self.config.language.as_deref() {
None | Some("unknown") | Some("") => "auto".to_string(),
Some(lang) => lang.to_string(),
}
}
fn ws_url(&self) -> String {
let c = &self.config;
let mut params = vec![
format!("model={}", percent_encode(&c.model)),
format!("language_code={}", percent_encode(&self.language_code())),
format!("stream_type={}", percent_encode(&c.stream_type)),
format!("endpointing={}", percent_encode(&c.endpointing)),
"encoding=linear16".to_string(),
format!("sample_rate={}", c.sample_rate),
];
if let Some(mode) = &c.mode {
params.push(format!("mode={}", percent_encode(mode)));
}
if let Some(prompt) = &c.prompt {
params.push(format!("prompt={}", percent_encode(prompt)));
}
if let Some(ms) = c.silence_duration_ms {
params.push(format!("silence_duration_ms={ms}"));
}
if let Some(t) = c.threshold {
params.push(format!("threshold={t}"));
}
if let Some(ms) = c.min_speech_duration_ms {
params.push(format!("min_speech_duration_ms={ms}"));
}
format!("{URL}?{}", params.join("&"))
}
fn manual(&self) -> bool {
self.config.endpointing == "manual"
}
fn fixed_language(&self) -> Option<String> {
Some(self.language_code()).filter(|l| l != "auto")
}
}
fn field<'a>(message: &'a serde_json::Value, name: &str) -> Option<&'a serde_json::Value> {
message.get(name).or_else(|| message.get("data").and_then(|d| d.get(name)))
}
impl SttProvider for SarvamRealtimeProvider {
fn name(&self) -> &'static str {
"SarvamRealtimeStt"
}
fn audio(&self) -> &AudioSpec {
&self.audio
}
fn handshake(&self) -> Handshake {
Handshake::new(self.ws_url()).header("api-subscription-key", &self.config.api_key)
}
fn encode_audio(&self, pcm_le: &[u8]) -> Outgoing {
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
Outgoing::Text(
serde_json::json!({ "event": "audio_input", "audio": BASE64.encode(pcm_le) }).to_string(),
)
}
fn turn_start_msg(&self) -> Option<Outgoing> {
self.manual().then(|| Outgoing::Text(r#"{"event":"speech_start"}"#.to_string()))
}
fn finalize_msg(&self) -> Option<Outgoing> {
Some(Outgoing::Text(if self.manual() {
r#"{"event":"speech_end"}"#.to_string()
} else {
r#"{"event":"flush"}"#.to_string()
}))
}
fn close_msg(&self) -> Option<Outgoing> {
Some(Outgoing::Text(r#"{"event":"end"}"#.to_string()))
}
fn keepalive(&self) -> Option<(Duration, Outgoing)> {
Some((PING_EVERY, Outgoing::Text(r#"{"event":"ping"}"#.to_string())))
}
fn parse(&self, msg: WsMessage<'_>) -> SttEvent {
let WsMessage::Text(text) = msg else {
log::debug!("SarvamRealtimeStt: unexpected binary message");
return SttEvent::Ignore;
};
let message: serde_json::Value = match serde_json::from_str(text) {
Ok(m) => m,
Err(e) => {
log::warn!("SarvamRealtimeStt: parse error: {e} — raw: {text}");
return SttEvent::Ignore;
}
};
let kind = message
.get("type")
.or_else(|| message.get("event"))
.and_then(|k| k.as_str())
.unwrap_or_default();
let transcript = || field(&message, "text").and_then(|t| t.as_str()).unwrap_or_default().trim().to_string();
let language = || {
field(&message, "language")
.and_then(|l| l.as_str())
.map(str::to_string)
.or_else(|| self.fixed_language())
};
match kind {
"transcript.partial" => {
let text = transcript();
if text.is_empty() {
SttEvent::Ignore
} else {
SttEvent::Partial { text, language: language() }
}
}
"transcript.final" => {
let text = transcript();
if text.is_empty() {
SttEvent::EmptyFinal { audio_ms: None }
} else {
SttEvent::Final { text, language: language(), audio_ms: None }
}
}
"vad.speech_start" => SttEvent::SpeechStarted,
"vad.speech_end" => SttEvent::SpeechEnded,
"error" => SttEvent::Error(
field(&message, "message")
.and_then(|m| m.as_str())
.map(str::to_string)
.unwrap_or_else(|| message.to_string()),
),
"session.begin" | "session.end" | "pong" | "config.updated" => {
log::debug!("SarvamRealtimeStt: {kind}: {text}");
SttEvent::Ignore
}
other => {
log::debug!("SarvamRealtimeStt: unknown message {other:?}: {text}");
SttEvent::Ignore
}
}
}
}
pub struct SarvamRealtimeSttHandler {
service: SttService<SarvamRealtimeProvider>,
}
impl SarvamRealtimeSttHandler {
pub fn new(config: SarvamRealtimeSttConfig) -> Self {
let (core, provider) = config.split();
Self { service: SttService::new(provider, core) }
}
pub fn with_billing(mut self, billing: std::sync::Arc<dyn crate::billing::BillingCollector>) -> Self {
self.service = self.service.with_billing(billing);
self
}
pub fn into_processor(self) -> FrameProcessor {
self.service.into_processor()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn provider(config: SarvamRealtimeSttConfig) -> SarvamRealtimeProvider {
config.split().1
}
#[test]
fn url_carries_the_realtime_parameters() {
let p = provider(SarvamRealtimeSttConfig {
language: Some("ml-IN".into()),
silence_duration_ms: Some(300),
..Default::default()
});
let url = p.ws_url();
assert!(url.starts_with("wss://api.sarvam.ai/speech-to-text-realtime/ws?"), "{url}");
for part in ["model=saaras%3Av3-realtime", "language_code=ml-IN", "stream_type=fast", "endpointing=manual", "encoding=linear16", "sample_rate=16000", "silence_duration_ms=300"] {
assert!(url.contains(part), "{part} missing from {url}");
}
let auto = provider(SarvamRealtimeSttConfig::default()).ws_url();
assert!(auto.contains("language_code=auto"));
}
#[test]
fn manual_endpointing_follows_local_turns() {
let text = |m: Option<Outgoing>| match m {
Some(Outgoing::Text(t)) => serde_json::from_str::<serde_json::Value>(&t).unwrap()["event"].as_str().unwrap().to_string(),
other => panic!("{other:?}"),
};
let manual = provider(SarvamRealtimeSttConfig::default());
assert_eq!(text(manual.turn_start_msg()), "speech_start");
assert_eq!(text(manual.finalize_msg()), "speech_end");
assert_eq!(text(manual.mid_turn_finalize_msg()), "speech_end");
let server = provider(SarvamRealtimeSttConfig { endpointing: "vad".into(), ..Default::default() });
assert!(server.turn_start_msg().is_none());
assert_eq!(text(server.finalize_msg()), "flush");
}
#[test]
fn audio_goes_as_base64_json() {
let p = provider(SarvamRealtimeSttConfig::default());
let Outgoing::Text(text) = p.encode_audio(&[1, 2, 3, 4]) else { panic!("binary") };
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["event"], "audio_input");
assert_eq!(v["audio"], "AQIDBA==");
}
#[test]
fn partials_finals_and_events_parse_in_either_shape() {
let p = provider(SarvamRealtimeSttConfig { language: Some("ml-IN".into()), ..Default::default() });
let parse = |s: &str| p.parse(WsMessage::Text(s));
assert_eq!(
parse(r#"{"type":"transcript.partial","text":"നാളെ"}"#),
SttEvent::Partial { text: "നാളെ".into(), language: Some("ml-IN".into()) }
);
assert_eq!(
parse(r#"{"event":"transcript.final","data":{"text":"നാളെ മീറ്റിംഗ്","language":"ml-IN"}}"#),
SttEvent::Final { text: "നാളെ മീറ്റിംഗ്".into(), language: Some("ml-IN".into()), audio_ms: None }
);
assert_eq!(parse(r#"{"type":"transcript.final","text":" "}"#), SttEvent::EmptyFinal { audio_ms: None });
assert_eq!(parse(r#"{"type":"vad.speech_start"}"#), SttEvent::SpeechStarted);
assert_eq!(parse(r#"{"type":"pong"}"#), SttEvent::Ignore);
assert!(matches!(parse(r#"{"type":"error","message":"quota","code":1003}"#), SttEvent::Error(m) if m == "quota"));
}
}