1use core::{fmt, ops::Range, time::Duration};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct SpeechSegment {
13 start_sample: u64,
14 end_sample: u64,
15}
16
17impl fmt::Display for SpeechSegment {
18 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19 write!(
20 f,
21 "SpeechSegment {{ start: {:.3}s, end: {:.3}s, duration: {:.3}s }}",
22 self.start().as_secs_f64(),
23 self.end().as_secs_f64(),
24 self.duration().as_secs_f64(),
25 )
26 }
27}
28
29impl SpeechSegment {
30 pub const SAMPLE_RATE_HZ: u32 = 16_000;
32
33 #[cfg_attr(not(tarpaulin), inline(always))]
39 pub const fn new(start_sample: u64, end_sample: u64) -> Self {
40 Self {
41 start_sample,
42 end_sample,
43 }
44 }
45
46 #[cfg_attr(not(tarpaulin), inline(always))]
48 pub const fn start_sample(&self) -> u64 {
49 self.start_sample
50 }
51
52 #[cfg_attr(not(tarpaulin), inline(always))]
54 pub const fn end_sample(&self) -> u64 {
55 self.end_sample
56 }
57
58 #[cfg_attr(not(tarpaulin), inline(always))]
60 pub const fn sample_count(&self) -> u64 {
61 self.end_sample.saturating_sub(self.start_sample)
62 }
63
64 pub fn start(&self) -> Duration {
66 Duration::from_secs_f64(self.start_sample as f64 / Self::SAMPLE_RATE_HZ as f64)
67 }
68
69 pub fn end(&self) -> Duration {
71 Duration::from_secs_f64(self.end_sample as f64 / Self::SAMPLE_RATE_HZ as f64)
72 }
73
74 pub fn duration(&self) -> Duration {
76 Duration::from_secs_f64(self.sample_count() as f64 / Self::SAMPLE_RATE_HZ as f64)
77 }
78
79 #[cfg_attr(not(tarpaulin), inline(always))]
81 pub const fn range(&self) -> Range<u64> {
82 self.start_sample..self.end_sample
83 }
84
85 #[cfg_attr(not(tarpaulin), inline(always))]
94 pub fn range_usize(&self) -> Range<usize> {
95 let to_usize = |v: u64| -> usize {
96 if v > usize::MAX as u64 {
97 usize::MAX
98 } else {
99 v as usize
100 }
101 };
102 to_usize(self.start_sample)..to_usize(self.end_sample)
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct FrameResult {
120 frame_index: u64,
121 raw_prob: f32,
122 smoothed_prob: f32,
123 is_speech: bool,
124 is_speech_start: bool,
125 is_speech_end: bool,
126 speech_start_frame: Option<u64>,
127 speech_end_frame: Option<u64>,
128}
129
130impl FrameResult {
131 pub const FRAME_SHIFT_SAMPLES: u32 = 160;
133 pub const SAMPLE_RATE_HZ: u32 = 16_000;
135
136 #[cfg_attr(not(tarpaulin), inline(always))]
139 #[allow(clippy::too_many_arguments)]
140 pub const fn new(
141 frame_index: u64,
142 raw_prob: f32,
143 smoothed_prob: f32,
144 is_speech: bool,
145 is_speech_start: bool,
146 is_speech_end: bool,
147 speech_start_frame: Option<u64>,
148 speech_end_frame: Option<u64>,
149 ) -> Self {
150 Self {
151 frame_index,
152 raw_prob,
153 smoothed_prob,
154 is_speech,
155 is_speech_start,
156 is_speech_end,
157 speech_start_frame,
158 speech_end_frame,
159 }
160 }
161
162 #[cfg_attr(not(tarpaulin), inline(always))]
164 pub const fn frame_index(&self) -> u64 {
165 self.frame_index
166 }
167
168 #[cfg_attr(not(tarpaulin), inline(always))]
170 pub const fn raw_prob(&self) -> f32 {
171 self.raw_prob
172 }
173
174 #[cfg_attr(not(tarpaulin), inline(always))]
176 pub const fn smoothed_prob(&self) -> f32 {
177 self.smoothed_prob
178 }
179
180 #[cfg_attr(not(tarpaulin), inline(always))]
182 pub const fn is_speech(&self) -> bool {
183 self.is_speech
184 }
185
186 #[cfg_attr(not(tarpaulin), inline(always))]
188 pub const fn is_speech_start(&self) -> bool {
189 self.is_speech_start
190 }
191
192 #[cfg_attr(not(tarpaulin), inline(always))]
194 pub const fn is_speech_end(&self) -> bool {
195 self.is_speech_end
196 }
197
198 #[cfg_attr(not(tarpaulin), inline(always))]
200 pub const fn speech_start_frame(&self) -> Option<u64> {
201 self.speech_start_frame
202 }
203
204 #[cfg_attr(not(tarpaulin), inline(always))]
206 pub const fn speech_end_frame(&self) -> Option<u64> {
207 self.speech_end_frame
208 }
209
210 pub fn timestamp(&self) -> Duration {
212 let samples = self.frame_index * Self::FRAME_SHIFT_SAMPLES as u64;
213 Duration::from_secs_f64(samples as f64 / Self::SAMPLE_RATE_HZ as f64)
214 }
215
216 pub fn closed_segment(&self) -> Option<SpeechSegment> {
218 if !self.is_speech_end {
219 return None;
220 }
221 let start = self.speech_start_frame? * Self::FRAME_SHIFT_SAMPLES as u64;
222 let end = self.speech_end_frame? * Self::FRAME_SHIFT_SAMPLES as u64;
223 Some(SpeechSegment::new(start, end))
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn sample_count_is_end_minus_start() {
233 let s = SpeechSegment::new(160, 1600);
234 assert_eq!(s.sample_count(), 1440);
235 }
236
237 #[test]
238 fn timestamps_round_trip_through_sample_rate() {
239 let s = SpeechSegment::new(16_000, 32_000);
240 assert_eq!(s.start(), Duration::from_secs(1));
241 assert_eq!(s.end(), Duration::from_secs(2));
242 assert_eq!(s.duration(), Duration::from_secs(1));
243 }
244
245 #[test]
246 fn range_usize_slices_pcm_directly() {
247 let pcm = [0.0f32; 2_000];
248 let s = SpeechSegment::new(160, 320);
249 let slice = &pcm[s.range_usize()];
250 assert_eq!(slice.len(), 160);
251 }
252
253 #[test]
254 fn empty_segment_has_zero_sample_count() {
255 let s = SpeechSegment::new(100, 100);
256 assert_eq!(s.sample_count(), 0);
257 assert!(s.range().is_empty());
258 }
259
260 #[test]
261 fn frame_result_closed_segment_is_some_only_when_is_speech_end() {
262 let result = FrameResult::new(20, 0.9, 0.85, true, false, true, Some(2), Some(20));
263 let segment = result.closed_segment().expect("segment closes");
264 assert_eq!(segment.start_sample(), 2 * 160);
265 assert_eq!(segment.end_sample(), 20 * 160);
266
267 let mid = FrameResult::new(15, 0.8, 0.75, true, false, false, Some(2), None);
268 assert!(mid.closed_segment().is_none());
269 }
270
271 #[test]
272 fn frame_result_timestamp_uses_frame_shift_samples() {
273 let result = FrameResult::new(100, 0.0, 0.0, false, false, false, None, None);
274 assert_eq!(result.timestamp(), Duration::from_millis(1_000));
275 }
276}