Skip to main content

aurum_core/tts/
adapter.rs

1//! Versioned TTS adapter contract (JOE-1615).
2//!
3//! An ONNX path alone is **not** a supported model: every pack must select a
4//! known adapter ID that defines tokenizer/G2P, tensors, voices, limits, and
5//! output policy.
6
7use crate::error::{Result, UserError};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// Manifest schema version accepted by this build.
12pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
13
14/// Built-in adapter identifiers.
15pub const ADAPTER_KITTEN_ONNX_V1: &str = "kitten-onnx-v1";
16/// Deterministic sine fake for conformance (no ONNX) — proves the contract is
17/// not Kitten-specific (JOE-1615/1616).
18pub const ADAPTER_FAKE_SINE_V1: &str = "fake-sine-v1";
19/// Kokoro-82M ONNX adapter (JOE-1617/1618). Product-supported with catalogue pins.
20pub const ADAPTER_KOKORO_ONNX_V0: &str = "kokoro-onnx-v0";
21
22/// Trust level for a model pack.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum TrustMode {
26    /// Built-in catalogue with reviewed pins.
27    #[default]
28    Builtin,
29    /// Caller-supplied exact digests; all artifacts verify.
30    Verified,
31    /// Explicit opt-in local files; marked unsupported/unverified.
32    LocalUnverified,
33}
34
35impl TrustMode {
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::Builtin => "builtin",
39            Self::Verified => "verified",
40            Self::LocalUnverified => "local_unverified",
41        }
42    }
43
44    pub fn parse(s: &str) -> Result<Self> {
45        match s.trim().to_ascii_lowercase().as_str() {
46            "builtin" | "built-in" | "catalogue" => Ok(Self::Builtin),
47            "verified" | "pinned" => Ok(Self::Verified),
48            "local_unverified" | "local-unverified" | "unverified" => Ok(Self::LocalUnverified),
49            other => Err(UserError::InvalidConfig {
50                reason: format!(
51                    "unknown trust mode '{other}' (use builtin|verified|local_unverified)"
52                ),
53            }
54            .into()),
55        }
56    }
57
58    pub fn is_supported_for_product(self) -> bool {
59        matches!(self, Self::Builtin | Self::Verified)
60    }
61}
62
63/// One file role in a model pack.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct ManifestArtifact {
66    pub role: String,
67    pub filename: String,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub sha256: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub size_bytes: Option<u64>,
72}
73
74/// Versioned model-pack manifest (on disk as `aurum-tts-manifest.json`).
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct ModelPackManifest {
77    pub schema_version: u32,
78    pub adapter_id: String,
79    pub adapter_version: u32,
80    pub model_id: String,
81    pub sample_rate_hz: u32,
82    pub channels: u16,
83    pub max_phoneme_tokens: usize,
84    pub languages: Vec<String>,
85    pub license: String,
86    pub trust: TrustMode,
87    pub artifacts: Vec<ManifestArtifact>,
88    #[serde(default)]
89    pub voices: Vec<ManifestVoice>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub source: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub notes: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct ManifestVoice {
98    pub id: String,
99    pub internal_key: String,
100    #[serde(default)]
101    pub language: String,
102    #[serde(default)]
103    pub notes: String,
104}
105
106impl ModelPackManifest {
107    pub fn validate_schema(&self) -> Result<()> {
108        if self.schema_version != MANIFEST_SCHEMA_VERSION {
109            return Err(UserError::InvalidConfig {
110                reason: format!(
111                    "unsupported TTS manifest schema_version {} (need {MANIFEST_SCHEMA_VERSION})",
112                    self.schema_version
113                ),
114            }
115            .into());
116        }
117        if self.adapter_id.trim().is_empty() || self.model_id.trim().is_empty() {
118            return Err(UserError::InvalidConfig {
119                reason: "manifest adapter_id and model_id are required".into(),
120            }
121            .into());
122        }
123        if self.sample_rate_hz == 0 || self.channels == 0 {
124            return Err(UserError::InvalidConfig {
125                reason: "manifest sample_rate_hz and channels must be non-zero".into(),
126            }
127            .into());
128        }
129        if self.artifacts.is_empty() {
130            return Err(UserError::InvalidConfig {
131                reason: "manifest must list at least one artifact".into(),
132            }
133            .into());
134        }
135        if matches!(self.trust, TrustMode::Verified) {
136            for a in &self.artifacts {
137                if a.sha256.as_ref().map(|s| s.len() != 64).unwrap_or(true) {
138                    return Err(UserError::InvalidConfig {
139                        reason: format!(
140                            "verified trust requires sha256 for artifact role '{}'",
141                            a.role
142                        ),
143                    }
144                    .into());
145                }
146                if a.size_bytes.is_none() {
147                    return Err(UserError::InvalidConfig {
148                        reason: format!(
149                            "verified trust requires size_bytes for artifact role '{}'",
150                            a.role
151                        ),
152                    }
153                    .into());
154                }
155            }
156        }
157        if !known_adapter_id(&self.adapter_id) {
158            return Err(UserError::InvalidConfig {
159                reason: format!(
160                    "unknown TTS adapter '{}' — an ONNX file alone is not supported; use a known adapter id",
161                    self.adapter_id
162                ),
163            }
164            .into());
165        }
166        Ok(())
167    }
168
169    pub fn artifact(&self, role: &str) -> Option<&ManifestArtifact> {
170        self.artifacts.iter().find(|a| a.role == role)
171    }
172
173    pub fn load_path(path: &Path) -> Result<Self> {
174        let bytes = std::fs::read(path).map_err(|e| UserError::InvalidConfig {
175            reason: format!("read manifest {}: {e}", path.display()),
176        })?;
177        if bytes.len() > 256 * 1024 {
178            return Err(UserError::InvalidConfig {
179                reason: "manifest exceeds 256 KiB".into(),
180            }
181            .into());
182        }
183        let m: Self = serde_json::from_slice(&bytes).map_err(|e| UserError::InvalidConfig {
184            reason: format!("parse manifest: {e}"),
185        })?;
186        m.validate_schema()?;
187        Ok(m)
188    }
189}
190
191pub fn known_adapter_id(id: &str) -> bool {
192    matches!(
193        id,
194        ADAPTER_KITTEN_ONNX_V1 | ADAPTER_FAKE_SINE_V1 | ADAPTER_KOKORO_ONNX_V0
195    )
196}
197
198/// Static metadata for a registered adapter.
199#[derive(Debug, Clone)]
200pub struct AdapterDescriptor {
201    pub id: &'static str,
202    pub version: u32,
203    pub description: &'static str,
204    pub required_artifact_roles: &'static [&'static str],
205    pub sample_rate_hz: u32,
206    pub languages: &'static [&'static str],
207    pub license_requirement: &'static str,
208    /// When false, synthesis is not product-supported (scaffold only).
209    pub synthesis_supported: bool,
210}
211
212pub fn list_adapters() -> Vec<AdapterDescriptor> {
213    vec![
214        AdapterDescriptor {
215            id: ADAPTER_KITTEN_ONNX_V1,
216            version: 1,
217            description: "KittenTTS nano ONNX + misaki-rs G2P (default)",
218            required_artifact_roles: &["onnx", "voices", "config"],
219            sample_rate_hz: 24_000,
220            languages: &["en"],
221            license_requirement: "Apache-2.0 weights + MIT G2P",
222            synthesis_supported: true,
223        },
224        AdapterDescriptor {
225            id: ADAPTER_FAKE_SINE_V1,
226            version: 1,
227            description: "Deterministic sine adapter for conformance (no ONNX)",
228            required_artifact_roles: &["config"],
229            sample_rate_hz: 24_000,
230            languages: &["en"],
231            license_requirement: "n/a (test fixture)",
232            synthesis_supported: true,
233        },
234        AdapterDescriptor {
235            id: ADAPTER_KOKORO_ONNX_V0,
236            version: 0,
237            description: "Kokoro-82M ONNX + misaki-rs G2P (opt-in catalogue model)",
238            required_artifact_roles: &["onnx", "voices", "config"],
239            sample_rate_hz: 24_000,
240            languages: &["en"],
241            license_requirement: "Apache-2.0 weights + MIT G2P",
242            synthesis_supported: true,
243        },
244    ]
245}
246
247pub fn lookup_adapter(id: &str) -> Result<AdapterDescriptor> {
248    list_adapters()
249        .into_iter()
250        .find(|a| a.id == id)
251        .ok_or_else(|| {
252            UserError::InvalidConfig {
253                reason: format!("unknown adapter '{id}'"),
254            }
255            .into()
256        })
257}
258
259/// Preflight a manifest against its adapter (no synthesis).
260pub fn preflight_manifest(manifest: &ModelPackManifest) -> Result<AdapterDescriptor> {
261    manifest.validate_schema()?;
262    let adapter = lookup_adapter(&manifest.adapter_id)?;
263    for role in adapter.required_artifact_roles {
264        if manifest.artifact(role).is_none() {
265            return Err(UserError::InvalidConfig {
266                reason: format!(
267                    "adapter '{}' requires artifact role '{role}' in the pack manifest",
268                    adapter.id
269                ),
270            }
271            .into());
272        }
273    }
274    if manifest.sample_rate_hz != adapter.sample_rate_hz && adapter.synthesis_supported {
275        // Soft warning path: still fail closed for supported adapters with mismatch.
276        return Err(UserError::InvalidConfig {
277            reason: format!(
278                "manifest sample_rate_hz {} does not match adapter {} native {}",
279                manifest.sample_rate_hz, adapter.id, adapter.sample_rate_hz
280            ),
281        }
282        .into());
283    }
284    if !adapter.synthesis_supported {
285        return Err(UserError::UnsupportedCapability {
286            provider: "tts".into(),
287            model: manifest.model_id.clone(),
288            reason: format!(
289                "adapter '{}' is scaffold-only and not enabled for synthesis",
290                adapter.id
291            ),
292            hint: "use kitten-onnx-v1 or kokoro-onnx-v0 with a shipped pack".into(),
293        }
294        .into());
295    }
296    Ok(adapter)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn kitten_manifest() -> ModelPackManifest {
304        ModelPackManifest {
305            schema_version: 1,
306            adapter_id: ADAPTER_KITTEN_ONNX_V1.into(),
307            adapter_version: 1,
308            model_id: "kitten-nano-int8".into(),
309            sample_rate_hz: 24_000,
310            channels: 1,
311            max_phoneme_tokens: 400,
312            languages: vec!["en".into()],
313            license: "Apache-2.0".into(),
314            trust: TrustMode::Builtin,
315            artifacts: vec![
316                ManifestArtifact {
317                    role: "onnx".into(),
318                    filename: "m.onnx".into(),
319                    sha256: None,
320                    size_bytes: None,
321                },
322                ManifestArtifact {
323                    role: "voices".into(),
324                    filename: "voices.npz".into(),
325                    sha256: None,
326                    size_bytes: None,
327                },
328                ManifestArtifact {
329                    role: "config".into(),
330                    filename: "config.json".into(),
331                    sha256: None,
332                    size_bytes: None,
333                },
334            ],
335            voices: vec![],
336            source: None,
337            notes: None,
338        }
339    }
340
341    #[test]
342    fn kitten_preflight_ok() {
343        preflight_manifest(&kitten_manifest()).unwrap();
344    }
345
346    #[test]
347    fn raw_onnx_not_enough() {
348        let mut m = kitten_manifest();
349        m.adapter_id = "mystery".into();
350        assert!(m.validate_schema().is_err());
351    }
352
353    #[test]
354    fn verified_requires_pins() {
355        let mut m = kitten_manifest();
356        m.trust = TrustMode::Verified;
357        assert!(m.validate_schema().is_err());
358    }
359
360    #[test]
361    fn kokoro_preflight_ok_when_shipped() {
362        let mut m = kitten_manifest();
363        m.adapter_id = ADAPTER_KOKORO_ONNX_V0.into();
364        m.adapter_version = 0;
365        m.model_id = "kokoro-82m-int8".into();
366        preflight_manifest(&m).unwrap();
367    }
368}