Skip to main content

lab_rs/
label.rs

1//! a single label (one line of a `.lab` file)
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::error::{ParseError, ParseErrorKind};
7
8/// number of HTK time units (100ns) in one second
9pub const UNITS_PER_SECOND: u64 = 10_000_000;
10
11/// a single labelled segment, times are in 100ns units as stored in the
12/// file, use the `*_secs` methods to work in seconds
13#[derive(Debug, Clone, PartialEq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct Label {
16    /// start time in 100ns units
17    #[cfg_attr(
18        feature = "serde",
19        serde(default, skip_serializing_if = "Option::is_none")
20    )]
21    pub start: Option<u64>,
22    /// end time in 100ns units
23    #[cfg_attr(
24        feature = "serde",
25        serde(default, skip_serializing_if = "Option::is_none")
26    )]
27    pub end: Option<u64>,
28    /// the label text, e.g. a phone or word
29    pub text: String,
30    /// optional score following the label text
31    #[cfg_attr(
32        feature = "serde",
33        serde(default, skip_serializing_if = "Option::is_none")
34    )]
35    pub score: Option<f64>,
36}
37
38impl Label {
39    /// creates a label spanning `start..end` in 100ns units
40    pub fn new(start: u64, end: u64, text: impl Into<String>) -> Self {
41        Label {
42            start: Some(start),
43            end: Some(end),
44            text: text.into(),
45            score: None,
46        }
47    }
48
49    /// creates a label spanning `start..end` given in seconds
50    pub fn from_secs(start: f64, end: f64, text: impl Into<String>) -> Self {
51        Label::new(secs_to_units(start), secs_to_units(end), text)
52    }
53
54    /// start time in seconds
55    pub fn start_secs(&self) -> Option<f64> {
56        self.start.map(units_to_secs)
57    }
58
59    /// end time in seconds
60    pub fn end_secs(&self) -> Option<f64> {
61        self.end.map(units_to_secs)
62    }
63
64    /// duration in 100ns units, if both times are present
65    pub fn duration(&self) -> Option<u64> {
66        match (self.start, self.end) {
67            (Some(s), Some(e)) => Some(e.saturating_sub(s)),
68            _ => None,
69        }
70    }
71
72    /// duration in seconds, if both times are present
73    pub fn duration_secs(&self) -> Option<f64> {
74        self.duration().map(units_to_secs)
75    }
76
77    /// returns `true` if `time` (in 100ns units) falls within `start..end`
78    pub fn contains(&self, time: u64) -> bool {
79        matches!((self.start, self.end), (Some(s), Some(e)) if s <= time && time < e)
80    }
81
82    // parses one line, line_no is 1-indexed for error messages
83    pub(crate) fn parse_line(line: &str, line_no: usize) -> Result<Self, ParseError> {
84        let tokens = tokenize(line, line_no)?;
85        if tokens.is_empty() {
86            return Err(invalid_label(line_no, "label line is empty"));
87        }
88
89        let mut idx = 0;
90        let mut start = None;
91        let mut end = None;
92        if tokens.len() >= 3 && tokens[0].quoted && tokens[0].value.is_empty() && !tokens[1].quoted
93        {
94            if let Some(t) = parse_time(&tokens[1].value, line_no)? {
95                end = Some(t);
96                idx = 2;
97            }
98        }
99        // leading integer tokens are times, but a line must keep at least
100        // one token for the label text itself
101        while idx < tokens.len() - 1 && idx < 2 && !tokens[idx].quoted {
102            match parse_time(&tokens[idx].value, line_no)? {
103                Some(t) if idx == 0 => start = Some(t),
104                Some(t) => end = Some(t),
105                None => break,
106            }
107            idx += 1;
108        }
109
110        if let (Some(s), Some(e)) = (start, end) {
111            if s > e {
112                return Err(ParseError {
113                    line: line_no,
114                    kind: ParseErrorKind::StartAfterEnd { start: s, end: e },
115                });
116            }
117        }
118
119        let rest = &tokens[idx..];
120        let (text_tokens, score) = match rest.split_last() {
121            // a trailing numeric token after the label name is a score
122            Some((last, init)) if !init.is_empty() && !last.quoted => {
123                match last.value.parse::<f64>() {
124                    Ok(score) if score.is_finite() => (init, Some(score)),
125                    Err(_) => (rest, None),
126                    _ => (rest, None),
127                }
128            }
129            _ => (rest, None),
130        };
131
132        Ok(Label {
133            start,
134            end,
135            text: text_tokens
136                .iter()
137                .map(|token| token.value.as_str())
138                .collect::<Vec<_>>()
139                .join(" "),
140            score,
141        })
142    }
143}
144
145impl fmt::Display for Label {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        if let Some(s) = self.start {
148            write!(f, "{s} ")?;
149        } else if let Some(e) = self.end {
150            write!(f, "\"\" {e} ")?;
151        }
152        if self.start.is_some() {
153            if let Some(e) = self.end {
154                write!(f, "{e} ")?;
155            }
156        }
157        write_text(f, &self.text)?;
158        if let Some(score) = self.score {
159            write!(f, " {score}")?;
160        }
161        Ok(())
162    }
163}
164
165impl FromStr for Label {
166    type Err = ParseError;
167
168    fn from_str(s: &str) -> Result<Self, Self::Err> {
169        let line = s.trim_end_matches(['\r', '\n']);
170        if line.contains('\r') || line.contains('\n') {
171            return Err(invalid_label(1, "a label must contain exactly one line"));
172        }
173        Label::parse_line(line, 1)
174    }
175}
176
177/// converts a time in 100ns units to seconds
178pub fn units_to_secs(units: u64) -> f64 {
179    units as f64 / UNITS_PER_SECOND as f64
180}
181
182/// converts a time in seconds to 100ns units, rounding to nearest
183pub fn secs_to_units(secs: f64) -> u64 {
184    (secs * UNITS_PER_SECOND as f64).round().max(0.0) as u64
185}
186
187// Ok(None) means the token is label text, not a time, digit-only tokens
188// that overflow u64 are an error rather than silently becoming text
189fn parse_time(token: &str, line_no: usize) -> Result<Option<u64>, ParseError> {
190    match token.parse::<u64>() {
191        Ok(t) => Ok(Some(t)),
192        Err(_) if token.bytes().all(|b| b.is_ascii_digit()) => Err(ParseError {
193            line: line_no,
194            kind: ParseErrorKind::InvalidTime(token.to_string()),
195        }),
196        Err(_) => Ok(None),
197    }
198}
199
200#[derive(Debug)]
201struct Token {
202    value: String,
203    quoted: bool,
204}
205
206fn tokenize(line: &str, line_no: usize) -> Result<Vec<Token>, ParseError> {
207    let mut tokens = Vec::new();
208    let mut chars = line.chars().peekable();
209
210    while chars.peek().is_some() {
211        while chars.peek().is_some_and(|c| c.is_whitespace()) {
212            chars.next();
213        }
214        if chars.peek().is_none() {
215            break;
216        }
217
218        let mut value = String::new();
219        let mut quoted = false;
220        while let Some(&c) = chars.peek() {
221            if c.is_whitespace() {
222                break;
223            }
224            chars.next();
225            if c != '"' {
226                value.push(c);
227                continue;
228            }
229
230            quoted = true;
231            let mut closed = false;
232            while let Some(c) = chars.next() {
233                match c {
234                    '"' => {
235                        closed = true;
236                        break;
237                    }
238                    '\\' => match chars.next() {
239                        Some('n') => value.push('\n'),
240                        Some('r') => value.push('\r'),
241                        Some('t') => value.push('\t'),
242                        Some('\\') => value.push('\\'),
243                        Some('"') => value.push('"'),
244                        Some(other) => {
245                            return Err(invalid_label(
246                                line_no,
247                                format!("unsupported escape sequence `\\{other}`"),
248                            ));
249                        }
250                        None => return Err(invalid_label(line_no, "unterminated escape sequence")),
251                    },
252                    other => value.push(other),
253                }
254            }
255            if !closed {
256                return Err(invalid_label(line_no, "unterminated quoted label text"));
257            }
258        }
259        tokens.push(Token { value, quoted });
260    }
261
262    Ok(tokens)
263}
264
265fn invalid_label(line: usize, message: impl Into<String>) -> ParseError {
266    ParseError {
267        line,
268        kind: ParseErrorKind::InvalidLabel(message.into()),
269    }
270}
271
272fn write_text(f: &mut fmt::Formatter<'_>, text: &str) -> fmt::Result {
273    let needs_quotes = text.is_empty()
274        || text
275            .chars()
276            .any(|c| c.is_whitespace() || c == '"' || c == '\\')
277        || text.parse::<u64>().is_ok()
278        || text.parse::<f64>().is_ok();
279    if !needs_quotes {
280        return f.write_str(text);
281    }
282
283    f.write_str("\"")?;
284    for c in text.chars() {
285        match c {
286            '\n' => f.write_str("\\n")?,
287            '\r' => f.write_str("\\r")?,
288            '\t' => f.write_str("\\t")?,
289            '\\' => f.write_str("\\\\")?,
290            '"' => f.write_str("\\\"")?,
291            other => write!(f, "{other}")?,
292        }
293    }
294    f.write_str("\"")
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn parses_full_line() {
303        let l: Label = "0 23823130 pau".parse().unwrap();
304        assert_eq!(l, Label::new(0, 23823130, "pau"));
305    }
306
307    #[test]
308    fn parses_score() {
309        let l: Label = "0 100 a -42.5".parse().unwrap();
310        assert_eq!(l.score, Some(-42.5));
311        assert_eq!(l.text, "a");
312    }
313
314    #[test]
315    fn parses_label_only() {
316        let l: Label = "sil".parse().unwrap();
317        assert_eq!((l.start, l.end), (None, None));
318        assert_eq!(l.text, "sil");
319    }
320
321    #[test]
322    fn parses_single_time() {
323        let l: Label = "100 sil".parse().unwrap();
324        assert_eq!((l.start, l.end), (Some(100), None));
325    }
326
327    #[test]
328    fn numeric_only_line_is_a_label() {
329        // the last token is always the label text, even if numeric
330        let l: Label = "100 200".parse().unwrap();
331        assert_eq!((l.start, l.end), (Some(100), None));
332        assert_eq!(l.text, "200");
333    }
334
335    #[test]
336    fn rejects_start_after_end() {
337        let err = "200 100 a".parse::<Label>().unwrap_err();
338        assert_eq!(
339            err.kind,
340            ParseErrorKind::StartAfterEnd {
341                start: 200,
342                end: 100
343            }
344        );
345    }
346
347    #[test]
348    fn rejects_overflowing_time() {
349        let err = "99999999999999999999999 100 a"
350            .parse::<Label>()
351            .unwrap_err();
352        assert!(matches!(err.kind, ParseErrorKind::InvalidTime(_)));
353    }
354
355    #[test]
356    fn display_round_trips() {
357        for line in ["0 23823130 pau", "100 sil", "sil", "0 100 a -42.5"] {
358            let l: Label = line.parse().unwrap();
359            assert_eq!(l.to_string(), line);
360        }
361    }
362
363    #[test]
364    fn display_round_trips_ambiguous_text_and_end_only_labels() {
365        let labels = [
366            Label::new(0, 100, "word 123"),
367            Label {
368                start: None,
369                end: Some(100),
370                text: "word".into(),
371                score: None,
372            },
373            Label {
374                start: None,
375                end: None,
376                text: "a\nquoted \"label\"".into(),
377                score: None,
378            },
379        ];
380
381        for label in labels {
382            assert_eq!(label.to_string().parse::<Label>().unwrap(), label);
383        }
384    }
385
386    #[test]
387    fn rejects_empty_and_multiline_single_labels() {
388        let empty = "  ".parse::<Label>().unwrap_err();
389        assert!(matches!(empty.kind, ParseErrorKind::InvalidLabel(_)));
390
391        let multiline = "0 10 a\n10 20 b".parse::<Label>().unwrap_err();
392        assert!(matches!(multiline.kind, ParseErrorKind::InvalidLabel(_)));
393    }
394
395    #[test]
396    fn seconds_conversion() {
397        let l = Label::from_secs(1.0, 2.5, "a");
398        assert_eq!(l.start, Some(10_000_000));
399        assert_eq!(l.end, Some(25_000_000));
400        assert_eq!(l.duration_secs(), Some(1.5));
401        assert!(l.contains(15_000_000));
402        assert!(!l.contains(25_000_000));
403    }
404}