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)]
23pub struct PreparedText {
24 pub text: String,
25 pub text_chars: usize,
26 pub text_truncated: bool,
28}
29
30pub fn tts_input_byte_budget(max_chars: usize) -> usize {
34 max_chars
35 .saturating_mul(READ_BYTES_PER_CHAR)
36 .saturating_add(READ_BYTE_OVERHEAD)
37}
38
39pub fn normalize_tts_language(lang: &str) -> Result<String> {
44 let trimmed = lang.trim();
45 if trimmed.is_empty() {
46 return Ok("en".to_string());
47 }
48 let key = trimmed.to_ascii_lowercase().replace('_', "-");
49 match key.as_str() {
50 "en" | "en-us" | "en-gb" => Ok("en".to_string()),
51 _ => Err(UserError::Other {
52 message: format!(
53 "unsupported TTS language '{trimmed}'\n \
54 Hint: local TTS G2P is English-only in this release; use en, en-US, or en-GB."
55 ),
56 }
57 .into()),
58 }
59}
60
61pub fn validate_text(text: &str) -> Result<()> {
63 if text.trim().is_empty() {
64 return Err(UserError::Other {
65 message: "TTS text is empty (provide positional text, '-', or --input-file)".into(),
66 }
67 .into());
68 }
69 Ok(())
70}
71
72pub fn prepare_text(text: &str, max_chars: usize) -> Result<PreparedText> {
78 validate_text(text)?;
79 let trimmed = text.trim();
80 let char_count = trimmed.chars().count();
81 if char_count > max_chars {
82 return Err(UserError::Other {
83 message: format!(
84 "TTS text is too long ({char_count} characters; limit is {max_chars}).\n \
85 Hint: shorten the input, split it yourself, or raise [tts].max_chars in config. \
86 Aurum does not silently truncate synthesis input."
87 ),
88 }
89 .into());
90 }
91 Ok(PreparedText {
92 text: trimmed.to_string(),
93 text_chars: char_count,
94 text_truncated: false,
95 })
96}
97
98pub fn resolve_sample_rate(requested: Option<u32>, native_hz: u32) -> Result<u32> {
102 match requested {
103 None => Ok(native_hz),
104 Some(hz) if hz == native_hz => Ok(native_hz),
105 Some(hz) => Err(UserError::Other {
106 message: format!(
107 "unsupported TTS sample rate {hz} Hz (model native rate is {native_hz} Hz).\n \
108 Hint: omit sample_rate_hz to use the native rate. Real resampling is not \
109 available in this release."
110 ),
111 }
112 .into()),
113 }
114}
115
116pub fn clamp_speaking_rate(rate: f32) -> f32 {
118 if !rate.is_finite() {
119 return 1.0;
120 }
121 rate.clamp(SPEAKING_RATE_MIN, SPEAKING_RATE_MAX)
122}
123
124pub fn validate_output_path(path: &Path) -> Result<()> {
126 let s = path.as_os_str();
127 if s.is_empty() {
128 return Err(UserError::Other {
129 message: "output path is empty".into(),
130 }
131 .into());
132 }
133 if path.file_name().map(|n| n.is_empty()).unwrap_or(true) {
134 return Err(UserError::Other {
135 message: format!(
136 "output path '{}' must be a file path, not a directory",
137 path.display()
138 ),
139 }
140 .into());
141 }
142 Ok(())
143}
144
145pub fn check_overwrite(path: &Path, force: bool) -> Result<()> {
147 if !path.exists() {
148 return Ok(());
149 }
150 let meta = std::fs::metadata(path)?;
151 if meta.len() == 0 {
152 return Ok(());
153 }
154 if force {
155 return Ok(());
156 }
157 Err(UserError::Other {
158 message: format!(
159 "output file already exists: {}\n Hint: pass --force to overwrite, or choose another path.",
160 path.display()
161 ),
162 }
163 .into())
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use tempfile::tempdir;
170
171 #[test]
172 fn empty_text_rejected() {
173 assert!(validate_text("").is_err());
174 assert!(validate_text(" \n\t").is_err());
175 }
176
177 #[test]
178 fn prepare_rejects_over_limit() {
179 let long = "word ".repeat(20);
180 let err = prepare_text(&long, 30).unwrap_err();
181 assert_eq!(err.exit_code(), 2);
182 assert!(err.to_string().contains("too long"));
183 }
184
185 #[test]
186 fn prepare_no_trunc_under_limit() {
187 let p = prepare_text("hello", 100).unwrap();
188 assert!(!p.text_truncated);
189 assert_eq!(p.text, "hello");
190 assert_eq!(p.text_chars, 5);
191 }
192
193 #[test]
194 fn sample_rate_reject_non_native() {
195 assert_eq!(resolve_sample_rate(None, 24_000).unwrap(), 24_000);
196 assert_eq!(resolve_sample_rate(Some(24_000), 24_000).unwrap(), 24_000);
197 let err = resolve_sample_rate(Some(16_000), 24_000).unwrap_err();
198 assert_eq!(err.exit_code(), 2);
199 }
200
201 #[test]
202 fn rate_clamp() {
203 assert_eq!(clamp_speaking_rate(1.0), 1.0);
204 assert_eq!(clamp_speaking_rate(0.1), SPEAKING_RATE_MIN);
205 assert_eq!(clamp_speaking_rate(9.0), SPEAKING_RATE_MAX);
206 assert_eq!(clamp_speaking_rate(f32::NAN), 1.0);
207 }
208
209 #[test]
210 fn overwrite_policy() {
211 let dir = tempdir().unwrap();
212 let path = dir.path().join("out.wav");
213 std::fs::write(&path, b"not empty").unwrap();
214 assert!(check_overwrite(&path, false).is_err());
215 assert!(check_overwrite(&path, true).is_ok());
216 let empty = dir.path().join("empty.wav");
217 std::fs::write(&empty, b"").unwrap();
218 assert!(check_overwrite(&empty, false).is_ok());
219 }
220
221 #[test]
222 fn language_accepts_english_aliases() {
223 assert_eq!(normalize_tts_language("").unwrap(), "en");
224 assert_eq!(normalize_tts_language("en").unwrap(), "en");
225 assert_eq!(normalize_tts_language("en-US").unwrap(), "en");
226 assert_eq!(normalize_tts_language("en_us").unwrap(), "en");
227 assert_eq!(normalize_tts_language(" EN-us ").unwrap(), "en");
228 assert_eq!(normalize_tts_language("en-GB").unwrap(), "en");
229 assert_eq!(normalize_tts_language("en_gb").unwrap(), "en");
230 }
231
232 #[test]
233 fn language_rejects_unsupported() {
234 for bad in ["fr", "de", "es-ES", "zh", "ja"] {
235 let err = normalize_tts_language(bad).unwrap_err();
236 assert_eq!(err.exit_code(), 2, "{bad}");
237 }
238 }
239
240 #[test]
241 fn input_byte_budget_scales_with_max_chars() {
242 assert_eq!(tts_input_byte_budget(0), READ_BYTE_OVERHEAD);
243 assert_eq!(
244 tts_input_byte_budget(100),
245 100 * READ_BYTES_PER_CHAR + READ_BYTE_OVERHEAD
246 );
247 assert!(tts_input_byte_budget(5_000) < 30_000 + READ_BYTE_OVERHEAD);
248 }
249}