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