Skip to main content

aurum_core/
dto.rs

1//! Versioned external JSON DTOs (JOE-1614).
2//!
3//! In-memory domain types are converted *to* these DTOs for CLI/embed JSON.
4//! Deserializing a DTO does **not** reconstruct native audio buffers.
5
6use crate::cleanup::{CleanupProviderKind, CleanupStyle, SegmentCleanupPolicy};
7use crate::error::TranscriptionError;
8use crate::providers::{BackendKind, Segment, TranscriptionResult};
9use serde::{Deserialize, Serialize};
10
11/// Current STT result schema version for `--emit-json` / library export.
12pub const STT_RESULT_SCHEMA_VERSION: u32 = 1;
13/// TTS metadata schema (PCM never included).
14pub const TTS_META_SCHEMA_VERSION: u32 = 1;
15/// Error envelope schema.
16pub const ERROR_SCHEMA_VERSION: u32 = 1;
17
18/// Versioned STT result DTO (public contract).
19#[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    /// Normalization warnings (empty when none).
40    #[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.clone(),
55            language: result.language.clone(),
56            model: result.model.clone(),
57            provider: result.provider.clone(),
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.clone(),
64            original_segments: result.original_segments.clone(),
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    /// Pretty JSON that never emits NaN/Inf.
83    pub fn to_json_pretty(&self) -> Result<String, TranscriptionError> {
84        serde_json::to_string_pretty(self)
85            .map_err(|e| TranscriptionError::internal(format!("STT DTO serialize failed: {e}")))
86    }
87}
88
89/// TTS metadata DTO — **no PCM bytes**.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct TtsMetaDto {
92    pub schema_version: u32,
93    pub sample_rate_hz: u32,
94    pub channels: u16,
95    pub backend_kind: String,
96    pub provider: String,
97    pub model: String,
98    pub voice: String,
99    pub language: String,
100    pub duration_ms: u64,
101    pub text_chars: usize,
102    pub text_truncated: bool,
103    pub chunk_count: usize,
104    pub synthesized_chars: usize,
105    /// Adapter id (JOE-1576); omitted when unknown.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub adapter: Option<String>,
108    /// Trust mode: builtin | verified | local_unverified.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub trust: Option<String>,
111    /// Provenance: builtin | custom | local_pack.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub provenance: Option<String>,
114}
115
116#[cfg(feature = "tts")]
117impl TtsMetaDto {
118    pub fn from_result(r: &crate::tts::SynthesisResult) -> Self {
119        Self {
120            schema_version: TTS_META_SCHEMA_VERSION,
121            sample_rate_hz: r.sample_rate_hz,
122            channels: r.channels,
123            backend_kind: match r.backend_kind {
124                crate::tts::BackendKind::Local => "local".into(),
125            },
126            provider: r.provider.clone(),
127            model: r.model.clone(),
128            voice: r.voice.clone(),
129            language: r.language.clone(),
130            duration_ms: r.duration_ms,
131            text_chars: r.text_chars,
132            text_truncated: r.text_truncated,
133            chunk_count: r.chunk_count,
134            synthesized_chars: r.synthesized_chars,
135            adapter: r.adapter.clone(),
136            trust: r.trust.clone(),
137            provenance: r.provenance.clone(),
138        }
139    }
140}
141
142/// Stable error envelope for JSON/CLI.
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144pub struct ErrorDto {
145    pub schema_version: u32,
146    pub category: String,
147    pub retryable: bool,
148    pub message: String,
149    pub exit_code: i32,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub operation: Option<String>,
152}
153
154impl ErrorDto {
155    pub fn from_error(err: &TranscriptionError) -> Self {
156        Self {
157            schema_version: ERROR_SCHEMA_VERSION,
158            category: err.error_category().as_str().into(),
159            retryable: err.retryable(),
160            message: err.to_string(),
161            exit_code: err.exit_code(),
162            operation: None,
163        }
164    }
165}
166
167fn finite_or_zero(v: f64) -> f64 {
168    if v.is_finite() {
169        v
170    } else {
171        0.0
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn stt_dto_roundtrip_fields() {
181        let r = TranscriptionResult::local(
182            "hi".into(),
183            vec![Segment::from_parts_unchecked(0.0, 1.0, "hi".to_string())],
184            Some("en".into()),
185            "base".into(),
186            1.0,
187        );
188        let dto = SttResultDto::from_result(&r);
189        let json = dto.to_json_pretty().unwrap();
190        assert!(json.contains("schema_version"));
191        assert!(!json.contains("NaN"));
192        let back: SttResultDto = serde_json::from_str(&json).unwrap();
193        assert_eq!(back.text, "hi");
194        assert_eq!(back.schema_version, 1);
195    }
196
197    #[test]
198    fn nan_duration_sanitized() {
199        let mut r = TranscriptionResult::local("x".into(), vec![], None, "m".into(), f64::NAN);
200        r.duration_secs = f64::NAN;
201        let dto = SttResultDto::from_result(&r);
202        assert_eq!(dto.duration_secs, 0.0);
203    }
204}