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 non-empty 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: Vec<&str> = line.split_whitespace().collect();
85        debug_assert!(!tokens.is_empty());
86
87        let mut idx = 0;
88        let mut start = None;
89        let mut end = None;
90        // leading integer tokens are times, but a line must keep at least
91        // one token for the label text itself
92        while idx < tokens.len() - 1 && idx < 2 {
93            match parse_time(tokens[idx], line_no)? {
94                Some(t) if idx == 0 => start = Some(t),
95                Some(t) => end = Some(t),
96                None => break,
97            }
98            idx += 1;
99        }
100
101        if let (Some(s), Some(e)) = (start, end) {
102            if s > e {
103                return Err(ParseError {
104                    line: line_no,
105                    kind: ParseErrorKind::StartAfterEnd { start: s, end: e },
106                });
107            }
108        }
109
110        let rest = &tokens[idx..];
111        let (text_tokens, score) = match rest.split_last() {
112            // a trailing numeric token after the label name is a score
113            Some((last, init)) if !init.is_empty() => match last.parse::<f64>() {
114                Ok(score) => (init, Some(score)),
115                Err(_) => (rest, None),
116            },
117            _ => (rest, None),
118        };
119
120        Ok(Label {
121            start,
122            end,
123            text: text_tokens.join(" "),
124            score,
125        })
126    }
127}
128
129impl fmt::Display for Label {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        if let Some(s) = self.start {
132            write!(f, "{s} ")?;
133        }
134        if let Some(e) = self.end {
135            write!(f, "{e} ")?;
136        }
137        f.write_str(&self.text)?;
138        if let Some(score) = self.score {
139            write!(f, " {score}")?;
140        }
141        Ok(())
142    }
143}
144
145impl FromStr for Label {
146    type Err = ParseError;
147
148    fn from_str(s: &str) -> Result<Self, Self::Err> {
149        Label::parse_line(s, 1)
150    }
151}
152
153/// converts a time in 100ns units to seconds
154pub fn units_to_secs(units: u64) -> f64 {
155    units as f64 / UNITS_PER_SECOND as f64
156}
157
158/// converts a time in seconds to 100ns units, rounding to nearest
159pub fn secs_to_units(secs: f64) -> u64 {
160    (secs * UNITS_PER_SECOND as f64).round().max(0.0) as u64
161}
162
163// Ok(None) means the token is label text, not a time, digit-only tokens
164// that overflow u64 are an error rather than silently becoming text
165fn parse_time(token: &str, line_no: usize) -> Result<Option<u64>, ParseError> {
166    match token.parse::<u64>() {
167        Ok(t) => Ok(Some(t)),
168        Err(_) if token.bytes().all(|b| b.is_ascii_digit()) => Err(ParseError {
169            line: line_no,
170            kind: ParseErrorKind::InvalidTime(token.to_string()),
171        }),
172        Err(_) => Ok(None),
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn parses_full_line() {
182        let l: Label = "0 23823130 pau".parse().unwrap();
183        assert_eq!(l, Label::new(0, 23823130, "pau"));
184    }
185
186    #[test]
187    fn parses_score() {
188        let l: Label = "0 100 a -42.5".parse().unwrap();
189        assert_eq!(l.score, Some(-42.5));
190        assert_eq!(l.text, "a");
191    }
192
193    #[test]
194    fn parses_label_only() {
195        let l: Label = "sil".parse().unwrap();
196        assert_eq!((l.start, l.end), (None, None));
197        assert_eq!(l.text, "sil");
198    }
199
200    #[test]
201    fn parses_single_time() {
202        let l: Label = "100 sil".parse().unwrap();
203        assert_eq!((l.start, l.end), (Some(100), None));
204    }
205
206    #[test]
207    fn numeric_only_line_is_a_label() {
208        // the last token is always the label text, even if numeric
209        let l: Label = "100 200".parse().unwrap();
210        assert_eq!((l.start, l.end), (Some(100), None));
211        assert_eq!(l.text, "200");
212    }
213
214    #[test]
215    fn rejects_start_after_end() {
216        let err = "200 100 a".parse::<Label>().unwrap_err();
217        assert_eq!(
218            err.kind,
219            ParseErrorKind::StartAfterEnd {
220                start: 200,
221                end: 100
222            }
223        );
224    }
225
226    #[test]
227    fn rejects_overflowing_time() {
228        let err = "99999999999999999999999 100 a"
229            .parse::<Label>()
230            .unwrap_err();
231        assert!(matches!(err.kind, ParseErrorKind::InvalidTime(_)));
232    }
233
234    #[test]
235    fn display_round_trips() {
236        for line in ["0 23823130 pau", "100 sil", "sil", "0 100 a -42.5"] {
237            let l: Label = line.parse().unwrap();
238            assert_eq!(l.to_string(), line);
239        }
240    }
241
242    #[test]
243    fn seconds_conversion() {
244        let l = Label::from_secs(1.0, 2.5, "a");
245        assert_eq!(l.start, Some(10_000_000));
246        assert_eq!(l.end, Some(25_000_000));
247        assert_eq!(l.duration_secs(), Some(1.5));
248        assert!(l.contains(15_000_000));
249        assert!(!l.contains(25_000_000));
250    }
251}