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 text ready for synthesis.
18///
19/// Policy is **complete-or-error**: text is never silently truncated. Character
20/// budget is a safety bound (OOM / abuse); model phoneme capacity is enforced
21/// separately during chunking.
22#[derive(Debug, Clone)]
23pub struct PreparedText {
24    pub text: String,
25    pub text_chars: usize,
26    /// Always `false` under the complete-or-error policy.
27    pub text_truncated: bool,
28}
29
30/// Byte budget for loading TTS text from a file or stdin before full allocation.
31///
32/// Tied to `max_chars` so a huge input fails as a user error instead of OOM.
33pub 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
39/// Normalize / accept only English locales the G2P engine actually supports.
40///
41/// Empty input defaults to `en`. Unsupported tags are a user error (honest
42/// failure rather than echoing a language the engine did not use).
43pub 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
61/// Reject empty / whitespace-only text (user error).
62pub 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
72/// Trim and enforce max chars with complete-or-error semantics.
73///
74/// Oversized input is a user error. Silent truncation is not supported; raise
75/// `[tts].max_chars` only when intentionally accepting larger inputs (chunking
76/// still enforces model phoneme limits).
77pub 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
98/// Reject a requested sample-rate override that is not the adapter's native rate.
99///
100/// Real resampling is not implemented; metadata-only relabeling is refused.
101pub 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
116/// Clamp speaking rate into the supported range.
117pub 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
124/// Basic path safety for output files (refuse empty / bare directory).
125pub 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
145/// Check overwrite policy: refuse existing non-empty file without force.
146pub 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}