aurum_core/tts/
validate.rs1use crate::error::{Result, UserError};
4use std::path::Path;
5
6pub const DEFAULT_MAX_CHARS: usize = 5_000;
8pub const DEFAULT_TIMEOUT_MS: u64 = 120_000;
10pub const SPEAKING_RATE_MIN: f32 = 0.5;
11pub const SPEAKING_RATE_MAX: f32 = 2.0;
12
13const READ_BYTES_PER_CHAR: usize = 4;
15const READ_BYTE_OVERHEAD: usize = 4096;
16
17#[derive(Debug, Clone)]
19pub struct PreparedText {
20 pub text: String,
21 pub text_chars: usize,
22 pub text_truncated: bool,
23}
24
25pub fn tts_input_byte_budget(max_chars: usize) -> usize {
29 max_chars
30 .saturating_mul(READ_BYTES_PER_CHAR)
31 .saturating_add(READ_BYTE_OVERHEAD)
32}
33
34pub fn normalize_tts_language(lang: &str) -> Result<String> {
39 let trimmed = lang.trim();
40 if trimmed.is_empty() {
41 return Ok("en".to_string());
42 }
43 let key = trimmed.to_ascii_lowercase().replace('_', "-");
44 match key.as_str() {
45 "en" | "en-us" => Ok("en".to_string()),
46 _ => Err(UserError::Other {
47 message: format!(
48 "unsupported TTS language '{trimmed}'\n \
49 Hint: KittenTTS G2P is English-only in this release; use en or en-US."
50 ),
51 }
52 .into()),
53 }
54}
55
56pub fn validate_text(text: &str) -> Result<()> {
58 if text.trim().is_empty() {
59 return Err(UserError::Other {
60 message: "TTS text is empty (provide positional text, '-', or --input-file)".into(),
61 }
62 .into());
63 }
64 Ok(())
65}
66
67pub fn prepare_text(text: &str, max_chars: usize) -> Result<PreparedText> {
71 validate_text(text)?;
72 let trimmed = text.trim();
73 let chars: Vec<char> = trimmed.chars().collect();
74 if chars.len() <= max_chars {
75 return Ok(PreparedText {
76 text: trimmed.to_string(),
77 text_chars: chars.len(),
78 text_truncated: false,
79 });
80 }
81 let mut end = max_chars;
83 if let Some(pos) = chars[..max_chars].iter().rposition(|c| c.is_whitespace()) {
84 if pos > max_chars / 2 {
85 end = pos;
86 }
87 }
88 let truncated: String = chars[..end].iter().collect();
89 Ok(PreparedText {
90 text: truncated.trim_end().to_string(),
91 text_chars: end,
92 text_truncated: true,
93 })
94}
95
96pub fn clamp_speaking_rate(rate: f32) -> f32 {
98 if !rate.is_finite() {
99 return 1.0;
100 }
101 rate.clamp(SPEAKING_RATE_MIN, SPEAKING_RATE_MAX)
102}
103
104pub fn validate_output_path(path: &Path) -> Result<()> {
106 let s = path.as_os_str();
107 if s.is_empty() {
108 return Err(UserError::Other {
109 message: "output path is empty".into(),
110 }
111 .into());
112 }
113 if path.file_name().map(|n| n.is_empty()).unwrap_or(true) {
114 return Err(UserError::Other {
115 message: format!(
116 "output path '{}' must be a file path, not a directory",
117 path.display()
118 ),
119 }
120 .into());
121 }
122 Ok(())
123}
124
125pub fn check_overwrite(path: &Path, force: bool) -> Result<()> {
127 if !path.exists() {
128 return Ok(());
129 }
130 let meta = std::fs::metadata(path)?;
131 if meta.len() == 0 {
132 return Ok(());
133 }
134 if force {
135 return Ok(());
136 }
137 Err(UserError::Other {
138 message: format!(
139 "output file already exists: {}\n Hint: pass --force to overwrite, or choose another path.",
140 path.display()
141 ),
142 }
143 .into())
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use tempfile::tempdir;
150
151 #[test]
152 fn empty_text_rejected() {
153 assert!(validate_text("").is_err());
154 assert!(validate_text(" \n\t").is_err());
155 }
156
157 #[test]
158 fn prepare_truncates() {
159 let long = "word ".repeat(20);
160 let p = prepare_text(&long, 30).unwrap();
161 assert!(p.text_truncated);
162 assert!(p.text.chars().count() <= 30);
163 assert!(!p.text.is_empty());
164 }
165
166 #[test]
167 fn prepare_no_trunc_under_limit() {
168 let p = prepare_text("hello", 100).unwrap();
169 assert!(!p.text_truncated);
170 assert_eq!(p.text, "hello");
171 assert_eq!(p.text_chars, 5);
172 }
173
174 #[test]
175 fn rate_clamp() {
176 assert_eq!(clamp_speaking_rate(1.0), 1.0);
177 assert_eq!(clamp_speaking_rate(0.1), SPEAKING_RATE_MIN);
178 assert_eq!(clamp_speaking_rate(9.0), SPEAKING_RATE_MAX);
179 assert_eq!(clamp_speaking_rate(f32::NAN), 1.0);
180 }
181
182 #[test]
183 fn overwrite_policy() {
184 let dir = tempdir().unwrap();
185 let path = dir.path().join("out.wav");
186 std::fs::write(&path, b"not empty").unwrap();
187 assert!(check_overwrite(&path, false).is_err());
188 assert!(check_overwrite(&path, true).is_ok());
189 let empty = dir.path().join("empty.wav");
190 std::fs::write(&empty, b"").unwrap();
191 assert!(check_overwrite(&empty, false).is_ok());
192 }
193
194 #[test]
195 fn language_accepts_english_aliases() {
196 assert_eq!(normalize_tts_language("").unwrap(), "en");
197 assert_eq!(normalize_tts_language("en").unwrap(), "en");
198 assert_eq!(normalize_tts_language("en-US").unwrap(), "en");
199 assert_eq!(normalize_tts_language("en_us").unwrap(), "en");
200 assert_eq!(normalize_tts_language(" EN-us ").unwrap(), "en");
201 }
202
203 #[test]
204 fn language_rejects_unsupported() {
205 for bad in ["fr", "de", "es-ES", "zh", "en-GB", "ja"] {
206 let err = normalize_tts_language(bad).unwrap_err();
207 assert_eq!(err.exit_code(), 2, "{bad}");
208 }
209 }
210
211 #[test]
212 fn input_byte_budget_scales_with_max_chars() {
213 assert_eq!(tts_input_byte_budget(0), READ_BYTE_OVERHEAD);
214 assert_eq!(
215 tts_input_byte_budget(100),
216 100 * READ_BYTES_PER_CHAR + READ_BYTE_OVERHEAD
217 );
218 assert!(tts_input_byte_budget(5_000) < 30_000 + READ_BYTE_OVERHEAD);
219 }
220}