1use crate::cleanup::{CleanupProviderKind, CleanupStyle, SegmentCleanupPolicy};
7use crate::error::TranscriptionError;
8use crate::providers::{BackendKind, Segment, TranscriptionResult};
9use serde::{Deserialize, Serialize};
10
11pub const STT_RESULT_SCHEMA_VERSION: u32 = 1;
13pub const TTS_META_SCHEMA_VERSION: u32 = 1;
15pub const ERROR_SCHEMA_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct SttResultDto {
21 pub schema_version: u32,
22 pub text: String,
23 pub language: Option<String>,
24 pub model: String,
25 pub provider: String,
26 pub duration_secs: f64,
27 pub backend_kind: BackendKind,
28 pub timestamps_reliable: bool,
29 pub cleanup_style: CleanupStyle,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub cleanup_provider: Option<CleanupProviderKind>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub original_text: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub original_segments: Option<Vec<Segment>>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub cleanup_segment_policy: Option<SegmentCleanupPolicy>,
38 pub segments: Vec<Segment>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub normalization_warnings: Vec<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub request_id: Option<u64>,
44}
45
46impl SttResultDto {
47 pub fn from_result(result: &TranscriptionResult) -> Self {
48 Self::from_result_with_warnings(result, &[])
49 }
50
51 pub fn from_result_with_warnings(result: &TranscriptionResult, warnings: &[String]) -> Self {
52 Self {
53 schema_version: STT_RESULT_SCHEMA_VERSION,
54 text: result.text().to_string(),
55 language: result.language().map(|s| s.to_string()),
56 model: result.model().to_string(),
57 provider: result.provider().to_string(),
58 duration_secs: finite_or_zero(result.duration_secs()),
59 backend_kind: result.backend_kind(),
60 timestamps_reliable: result.timestamps_reliable(),
61 cleanup_style: result.cleanup_style(),
62 cleanup_provider: result.cleanup_provider(),
63 original_text: result.original_text().map(|s| s.to_string()),
64 original_segments: result.original_segments().map(|s| s.to_vec()),
65 cleanup_segment_policy: result.cleanup_segment_policy(),
66 segments: result
67 .segments()
68 .iter()
69 .map(|s| {
70 Segment::from_parts_unchecked(
71 finite_or_zero(s.start()),
72 finite_or_zero(s.end()),
73 s.text().to_string(),
74 )
75 })
76 .collect(),
77 normalization_warnings: warnings.to_vec(),
78 request_id: None,
79 }
80 }
81
82 pub fn into_domain(self) -> crate::error::Result<TranscriptionResult> {
84 TranscriptionResult::try_from_dto(&self)
85 }
86
87 pub fn to_json_pretty(&self) -> Result<String, TranscriptionError> {
89 serde_json::to_string_pretty(self)
90 .map_err(|e| TranscriptionError::internal(format!("STT DTO serialize failed: {e}")))
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct TtsMetaDto {
97 pub schema_version: u32,
98 pub sample_rate_hz: u32,
99 pub channels: u16,
100 pub backend_kind: String,
101 pub provider: String,
102 pub model: String,
103 pub voice: String,
104 pub language: String,
105 pub duration_ms: u64,
106 pub text_chars: usize,
107 pub text_truncated: bool,
108 pub chunk_count: usize,
109 pub synthesized_chars: usize,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub adapter: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub trust: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub provenance: Option<String>,
119}
120
121#[cfg(feature = "tts")]
122impl TtsMetaDto {
123 pub fn from_result(r: &crate::tts::SynthesisResult) -> Self {
124 Self {
125 schema_version: TTS_META_SCHEMA_VERSION,
126 sample_rate_hz: r.sample_rate_hz,
127 channels: r.channels,
128 backend_kind: r.backend_kind.as_str().into(),
129 provider: r.provider.clone(),
130 model: r.model.clone(),
131 voice: r.voice.clone(),
132 language: r.language.clone(),
133 duration_ms: r.duration_ms,
134 text_chars: r.text_chars,
135 text_truncated: r.text_truncated,
136 chunk_count: r.chunk_count,
137 synthesized_chars: r.synthesized_chars,
138 adapter: r.adapter.clone(),
139 trust: r.trust.clone(),
140 provenance: r.provenance.clone(),
141 }
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct ErrorDto {
148 pub schema_version: u32,
149 pub category: String,
150 pub retryable: bool,
151 pub message: String,
152 pub exit_code: i32,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub operation: Option<String>,
155}
156
157impl ErrorDto {
158 pub fn from_error(err: &TranscriptionError) -> Self {
159 Self {
160 schema_version: ERROR_SCHEMA_VERSION,
161 category: err.error_category().as_str().into(),
162 retryable: err.retryable(),
163 message: err.to_string(),
164 exit_code: err.exit_code(),
165 operation: None,
166 }
167 }
168
169 pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
170 self.operation = Some(operation.into());
171 self
172 }
173
174 pub fn to_json_pretty(&self) -> Result<String, TranscriptionError> {
176 serde_json::to_string_pretty(self)
177 .map_err(|e| TranscriptionError::internal(format!("ErrorDto serialize failed: {e}")))
178 }
179
180 pub fn to_json(&self) -> Result<String, TranscriptionError> {
182 serde_json::to_string(self)
183 .map_err(|e| TranscriptionError::internal(format!("ErrorDto serialize failed: {e}")))
184 }
185}
186
187fn finite_or_zero(v: f64) -> f64 {
188 if v.is_finite() {
189 v
190 } else {
191 0.0
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn stt_dto_roundtrip_fields() {
201 let r = TranscriptionResult::local(
202 "hi".into(),
203 vec![Segment::from_parts_unchecked(0.0, 1.0, "hi".to_string())],
204 Some("en".into()),
205 "base".into(),
206 1.0,
207 );
208 let dto = SttResultDto::from_result(&r);
209 let json = dto.to_json_pretty().unwrap();
210 assert!(json.contains("schema_version"));
211 assert!(!json.contains("NaN"));
212 let back: SttResultDto = serde_json::from_str(&json).unwrap();
213 assert_eq!(back.text, "hi");
214 assert_eq!(back.schema_version, 1);
215 }
216
217 #[test]
218 fn nan_duration_sanitized() {
219 let mut r = TranscriptionResult::local("x".into(), vec![], None, "m".into(), f64::NAN);
220 r.set_duration_secs(f64::NAN);
221 let dto = SttResultDto::from_result(&r);
222 assert_eq!(dto.duration_secs, 0.0);
223 }
224
225 #[test]
226 fn error_dto_json_has_schema_and_exit_code() {
227 let err = crate::error::UserError::MissingApiKey.into();
228 let dto = ErrorDto::from_error(&err);
229 let json = dto.to_json_pretty().unwrap();
230 assert!(json.contains("schema_version"));
231 assert!(json.contains("exit_code"));
232 assert!(json.contains("category"));
233 assert_eq!(dto.schema_version, ERROR_SCHEMA_VERSION);
234 assert_eq!(dto.exit_code, err.exit_code());
235 }
236
237 #[cfg(feature = "tts")]
238 #[test]
239 fn tts_meta_local_and_remote_backend_kind() {
240 use crate::tts::{BackendKind, SynthesisResult};
241
242 let local = SynthesisResult {
243 pcm_i16_mono: vec![1, 2, 3],
244 sample_rate_hz: 24_000,
245 channels: 1,
246 backend_kind: BackendKind::Local,
247 provider: "local".into(),
248 model: "kitten-nano-int8".into(),
249 voice: "Luna".into(),
250 language: "en".into(),
251 duration_ms: 1,
252 text_chars: 1,
253 text_truncated: false,
254 chunk_count: 1,
255 synthesized_chars: 1,
256 adapter: None,
257 trust: None,
258 provenance: None,
259 };
260 let dto = TtsMetaDto::from_result(&local);
261 assert_eq!(dto.schema_version, TTS_META_SCHEMA_VERSION);
262 assert_eq!(dto.backend_kind, "local");
263 let json = serde_json::to_string(&dto).unwrap();
265 assert!(!json.contains("pcm"));
266 assert_eq!(dto.schema_version, 1);
267
268 let mut remote = local.clone();
269 remote.backend_kind = BackendKind::Remote;
270 remote.provider = "openai".into();
271 let dto_r = TtsMetaDto::from_result(&remote);
272 assert_eq!(dto_r.backend_kind, "remote");
273 assert_eq!(dto_r.schema_version, 1);
275 }
276}