1use crate::error::{Result, UserError};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
13
14pub const ADAPTER_KITTEN_ONNX_V1: &str = "kitten-onnx-v1";
16pub const ADAPTER_FAKE_SINE_V1: &str = "fake-sine-v1";
19pub const ADAPTER_KOKORO_ONNX_V0: &str = "kokoro-onnx-v0";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum TrustMode {
26 #[default]
28 Builtin,
29 Verified,
31 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#[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#[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#[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 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
259pub 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 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}