Add prompt-core, the platform-neutral teleprompter engine
Ground-up Rust implementation of the algorithms behind Textream's three guidance modes. Nothing here touches the OS, so it is testable without a microphone and a future Linux build needs only a new shell. Offsets are grapheme clusters rather than chars, matching how the macOS app counts Characters. A char-based index would drift on emoji cues and combining marks and pull the highlight off the word the reader is looking at. - text: CJK-aware tokenisation, RTL inference that ignores [cue] contents - alignment: cue ranges, skip-ahead, and the commit policy that rejects a large unconfirmed jump from a single dead estimate - script: parsed script with word table and progress/offset conversions - matcher: character- and word-level passes reconciled per update - vad: RMS metering plus a two-threshold gate with hangover - scroll: constant-pace driver shared by Classic and Voice-Activated 75 unit tests and a doctest cover cue skipping, filler words, misrecognition, CJK, RTL, and the degenerate empty-script and non-finite-sample paths.
This commit is contained in:
commit
8a34bd1df1
14 changed files with 1988 additions and 0 deletions
185
crates/prompt-core/src/vad.rs
Normal file
185
crates/prompt-core/src/vad.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! Microphone level metering and voice activity detection.
|
||||
|
||||
/// Root-mean-square level of a buffer, normalised to `0.0..=1.0`.
|
||||
///
|
||||
/// Non-finite samples are dropped rather than poisoning the sum — a single NaN
|
||||
/// from a glitching capture device would otherwise make the level NaN and every
|
||||
/// downstream comparison false, silently wedging Voice-Activated mode shut.
|
||||
pub fn normalized_rms(samples: &[f32]) -> f32 {
|
||||
let mut squared_total = 0.0f64;
|
||||
let mut count = 0usize;
|
||||
for &sample in samples {
|
||||
if !sample.is_finite() {
|
||||
continue;
|
||||
}
|
||||
squared_total += (sample as f64) * (sample as f64);
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
((squared_total / count as f64).sqrt() as f32).min(1.0)
|
||||
}
|
||||
|
||||
/// Converts interleaved 16-bit PCM to normalised RMS.
|
||||
pub fn normalized_rms_i16(samples: &[i16]) -> f32 {
|
||||
let converted: Vec<f32> = samples.iter().map(|&s| s as f32 / 32_768.0).collect();
|
||||
normalized_rms(&converted)
|
||||
}
|
||||
|
||||
/// Hysteretic speech gate.
|
||||
///
|
||||
/// Two thresholds and a hangover, because naive level gating chatters: a single
|
||||
/// threshold flickers on breath and consonant gaps, which in Voice-Activated
|
||||
/// mode reads as the script stuttering. Quiet speech must cross the low
|
||||
/// threshold on consecutive frames to open the gate, anything clearly loud
|
||||
/// opens it at once, and the gate then stays open through the hangover so
|
||||
/// inter-word pauses do not close it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceActivityDetector {
|
||||
activation_level: f32,
|
||||
immediate_activation_level: f32,
|
||||
required_active_frames: u32,
|
||||
hangover: f64,
|
||||
consecutive_active_frames: u32,
|
||||
active_until: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for VoiceActivityDetector {
|
||||
fn default() -> Self {
|
||||
Self::new(0.012, 0.04, 2, 0.75)
|
||||
}
|
||||
}
|
||||
|
||||
impl VoiceActivityDetector {
|
||||
/// * `activation_level` — sustained level that counts as speech.
|
||||
/// * `immediate_activation_level` — level that opens the gate instantly.
|
||||
/// * `required_active_frames` — frames above `activation_level` needed.
|
||||
/// * `hangover` — seconds the gate stays open after the last active frame.
|
||||
pub fn new(
|
||||
activation_level: f32,
|
||||
immediate_activation_level: f32,
|
||||
required_active_frames: u32,
|
||||
hangover: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
activation_level,
|
||||
immediate_activation_level,
|
||||
required_active_frames: required_active_frames.max(1),
|
||||
hangover,
|
||||
consecutive_active_frames: 0,
|
||||
active_until: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds one metered frame. `timestamp` is in seconds, monotonic.
|
||||
pub fn process(&mut self, level: f32, timestamp: f64) {
|
||||
if level >= self.immediate_activation_level {
|
||||
self.consecutive_active_frames = self.required_active_frames;
|
||||
self.extend(timestamp);
|
||||
} else if level >= self.activation_level {
|
||||
self.consecutive_active_frames += 1;
|
||||
if self.consecutive_active_frames >= self.required_active_frames {
|
||||
self.extend(timestamp);
|
||||
}
|
||||
} else {
|
||||
self.consecutive_active_frames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether speech is considered active at `timestamp`.
|
||||
pub fn is_active(&self, timestamp: f64) -> bool {
|
||||
self.active_until.is_some_and(|until| timestamp < until)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.consecutive_active_frames = 0;
|
||||
self.active_until = None;
|
||||
}
|
||||
|
||||
fn extend(&mut self, timestamp: f64) {
|
||||
let until = timestamp + self.hangover;
|
||||
self.active_until = Some(match self.active_until {
|
||||
Some(current) => current.max(until),
|
||||
None => until,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn silence_measures_zero() {
|
||||
assert_eq!(normalized_rms(&[0.0; 128]), 0.0);
|
||||
assert_eq!(normalized_rms(&[]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_scale_measures_one() {
|
||||
assert_eq!(normalized_rms(&[1.0, -1.0, 1.0, -1.0]), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_samples_are_ignored() {
|
||||
let level = normalized_rms(&[f32::NAN, 0.5, f32::INFINITY, 0.5]);
|
||||
assert!((level - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_non_finite_degrades_to_silence() {
|
||||
assert_eq!(normalized_rms(&[f32::NAN, f32::INFINITY]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i16_conversion_matches_float() {
|
||||
assert!(normalized_rms_i16(&[16_384, -16_384]) > 0.49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_speech_needs_consecutive_frames() {
|
||||
let mut vad = VoiceActivityDetector::default();
|
||||
vad.process(0.02, 0.0);
|
||||
assert!(
|
||||
!vad.is_active(0.0),
|
||||
"one quiet frame should not open the gate"
|
||||
);
|
||||
vad.process(0.02, 0.1);
|
||||
assert!(vad.is_active(0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loud_speech_opens_the_gate_immediately() {
|
||||
let mut vad = VoiceActivityDetector::default();
|
||||
vad.process(0.5, 0.0);
|
||||
assert!(vad.is_active(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hangover_bridges_gaps_between_words() {
|
||||
let mut vad = VoiceActivityDetector::default();
|
||||
vad.process(0.5, 1.0);
|
||||
assert!(vad.is_active(1.5), "still inside the 0.75 s hangover");
|
||||
assert!(!vad.is_active(1.8), "hangover elapsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_below_threshold_restarts_the_frame_count() {
|
||||
let mut vad = VoiceActivityDetector::default();
|
||||
vad.process(0.02, 0.0);
|
||||
vad.process(0.0, 0.1); // drop out
|
||||
vad.process(0.02, 0.2);
|
||||
assert!(!vad.is_active(0.2), "the counter should have restarted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_closed_and_reset_closes_it_again() {
|
||||
let mut vad = VoiceActivityDetector::default();
|
||||
assert!(!vad.is_active(0.0));
|
||||
vad.process(0.9, 0.0);
|
||||
assert!(vad.is_active(0.1));
|
||||
vad.reset();
|
||||
assert!(!vad.is_active(0.1));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue