use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TurnContent {
Text(String),
Audio(AudioRef),
Mixed(Vec<TurnContentPart>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TurnContentPart {
Text(String),
Audio(AudioRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioRef {
pub substrate_path: String,
pub mime: String,
pub duration_ms: u64,
}
impl AudioRef {
pub fn new(
substrate_path: impl Into<String>,
mime: impl Into<String>,
duration_ms: u64,
) -> Self {
Self {
substrate_path: substrate_path.into(),
mime: mime.into(),
duration_ms,
}
}
pub fn from_value(v: &serde_json::Value) -> Option<Self> {
let obj = v.as_object()?;
let substrate_path = obj.get("substrate_path")?.as_str()?.to_string();
let mime = obj
.get("mime")
.and_then(|m| m.as_str())
.unwrap_or("audio/wav")
.to_string();
let duration_ms = obj
.get("duration_ms")
.and_then(|d| d.as_u64())
.unwrap_or(0);
Some(Self {
substrate_path,
mime,
duration_ms,
})
}
}
impl TurnContent {
pub fn from_text_and_audio(text: impl Into<String>, audio: Option<AudioRef>) -> Self {
let text = text.into();
match (text.is_empty(), audio) {
(true, None) => Self::Text(String::new()),
(false, None) => Self::Text(text),
(true, Some(a)) => Self::Audio(a),
(false, Some(a)) => Self::Mixed(vec![
TurnContentPart::Text(text),
TurnContentPart::Audio(a),
]),
}
}
pub fn content_type(&self) -> &'static str {
match self {
Self::Text(_) => "text",
Self::Audio(_) => "audio",
Self::Mixed(_) => "mixed",
}
}
pub fn text_payload(&self) -> String {
match self {
Self::Text(t) => t.clone(),
Self::Audio(_) => String::new(),
Self::Mixed(parts) => {
let mut out = String::new();
for p in parts {
if let TurnContentPart::Text(t) = p {
out.push_str(t);
}
}
out
}
}
}
pub fn audio_ref(&self) -> Option<&AudioRef> {
match self {
Self::Audio(a) => Some(a),
Self::Mixed(parts) => parts.iter().find_map(|p| match p {
TurnContentPart::Audio(a) => Some(a),
TurnContentPart::Text(_) => None,
}),
Self::Text(_) => None,
}
}
}
pub mod voice_meta {
pub const AUDIO: &str = "audio";
pub const AUDIO_PATH: &str = "audio_substrate_path";
pub const AUDIO_MIME: &str = "audio_mime";
pub const AUDIO_DURATION_MS: &str = "audio_duration_ms";
pub const USER_TURN_RECORDED: &str = "user_turn_recorded";
}
pub fn audio_from_metadata(meta: &serde_json::Map<String, serde_json::Value>) -> Option<AudioRef> {
if let Some(v) = meta.get(voice_meta::AUDIO)
&& let Some(a) = AudioRef::from_value(v)
{
return Some(a);
}
let path = meta.get(voice_meta::AUDIO_PATH)?.as_str()?;
if path.is_empty() {
return None;
}
let mime = meta
.get(voice_meta::AUDIO_MIME)
.and_then(|m| m.as_str())
.unwrap_or("audio/wav")
.to_string();
let duration_ms = meta
.get(voice_meta::AUDIO_DURATION_MS)
.and_then(|d| d.as_u64())
.unwrap_or(0);
Some(AudioRef::new(path, mime, duration_ms))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_text_and_audio_matrix() {
assert_eq!(
TurnContent::from_text_and_audio("hi", None),
TurnContent::Text("hi".into())
);
let a = AudioRef::new("substrate/_derived/chat/c/audio/1", "audio/wav", 500);
assert_eq!(
TurnContent::from_text_and_audio("", Some(a.clone())),
TurnContent::Audio(a.clone())
);
match TurnContent::from_text_and_audio("hi", Some(a.clone())) {
TurnContent::Mixed(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], TurnContentPart::Text("hi".into()));
assert_eq!(parts[1], TurnContentPart::Audio(a));
}
other => panic!("expected Mixed, got {other:?}"),
}
}
#[test]
fn audio_from_metadata_nested_and_flat() {
let mut nested = serde_json::Map::new();
nested.insert(
"audio".into(),
serde_json::json!({
"substrate_path": "substrate/_derived/chat/c/audio/0",
"mime": "audio/opus",
"duration_ms": 1200
}),
);
let a = audio_from_metadata(&nested).unwrap();
assert_eq!(a.mime, "audio/opus");
assert_eq!(a.duration_ms, 1200);
let mut flat = serde_json::Map::new();
flat.insert(
"audio_substrate_path".into(),
serde_json::json!("substrate/_derived/chat/c/audio/9"),
);
flat.insert("audio_duration_ms".into(), serde_json::json!(300));
let b = audio_from_metadata(&flat).unwrap();
assert_eq!(b.substrate_path, "substrate/_derived/chat/c/audio/9");
assert_eq!(b.mime, "audio/wav");
assert_eq!(b.duration_ms, 300);
}
#[test]
fn turn_content_serde_round_trips() {
let mixed = TurnContent::Mixed(vec![
TurnContentPart::Text("hello ".into()),
TurnContentPart::Audio(AudioRef::new("p", "audio/wav", 10)),
]);
let s = serde_json::to_string(&mixed).unwrap();
let back: TurnContent = serde_json::from_str(&s).unwrap();
assert_eq!(back, mixed);
assert_eq!(back.content_type(), "mixed");
assert_eq!(back.text_payload(), "hello ");
}
}