Skip to main content

aurum_core/tts/
validate.rs

1//! Text and path validation helpers for TTS.
2
3use crate::error::{Result, UserError};
4use std::path::Path;
5
6/// Default max input characters before truncation / rejection.
7pub const DEFAULT_MAX_CHARS: usize = 5_000;
8/// Default wall-clock synthesis timeout.
9pub const DEFAULT_TIMEOUT_MS: u64 = 120_000;
10pub const SPEAKING_RATE_MIN: f32 = 0.5;
11pub const SPEAKING_RATE_MAX: f32 = 2.0;
12
13/// UTF-8 worst-case bytes per Unicode scalar + small framing overhead for reads.
14const READ_BYTES_PER_CHAR: usize = 4;
15const READ_BYTE_OVERHEAD: usize = 4096;
16
17/// Validated / truncated text ready for synthesis.
18#[derive(Debug, Clone)]
19pub struct PreparedText {
20    pub text: String,
21    pub text_chars: usize,
22    pub text_truncated: bool,
23}
24
25/// Byte budget for loading TTS text from a file or stdin before full allocation.
26///
27/// Tied to `max_chars` so a huge input fails as a user error instead of OOM.
28pub 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
34/// Normalize / accept only English locales the G2P engine actually supports.
35///
36/// Empty input defaults to `en`. Unsupported tags are a user error (honest
37/// failure rather than echoing a language the engine did not use).
38pub 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
56/// Reject empty / whitespace-only text (user error).
57pub 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
67/// Trim, enforce max chars, and report truncation.
68///
69/// Truncation prefers a word boundary near the limit when possible.
70pub 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    // Truncate at last whitespace before limit when possible.
82    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
96/// Clamp speaking rate into the supported range.
97pub 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
104/// Basic path safety for output files (refuse empty / bare directory).
105pub 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
125/// Check overwrite policy: refuse existing non-empty file without force.
126pub 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}