feat(core): vad-driven listening window state machine
This commit is contained in:
parent
3f19366ad0
commit
37c8371f3f
6 changed files with 149 additions and 0 deletions
|
|
@ -1,5 +1,8 @@
|
||||||
mod none;
|
mod none;
|
||||||
mod energy;
|
mod energy;
|
||||||
|
pub mod listen_window;
|
||||||
|
#[cfg(feature = "webrtc-vad")]
|
||||||
|
pub mod webrtc;
|
||||||
|
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
|
||||||
79
crates/jarvis-core/src/audio_processing/vad/listen_window.rs
Normal file
79
crates/jarvis-core/src/audio_processing/vad/listen_window.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use crate::config;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WindowDecision {
|
||||||
|
KeepListening,
|
||||||
|
Close,
|
||||||
|
HardCap,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ListenWindow {
|
||||||
|
frame_ms: u32,
|
||||||
|
elapsed_ms: u32,
|
||||||
|
speech_ms: u32,
|
||||||
|
silence_ms: u32,
|
||||||
|
end_silence_ms: u32,
|
||||||
|
min_speech_ms: u32,
|
||||||
|
min_listen_ms: u32,
|
||||||
|
max_listen_ms: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListenWindow {
|
||||||
|
pub fn new(frame_ms: u32) -> Self {
|
||||||
|
Self::with_params(
|
||||||
|
frame_ms,
|
||||||
|
config::VAD_COMMAND_END_SILENCE_MS,
|
||||||
|
config::VAD_COMMAND_MIN_SPEECH_MS,
|
||||||
|
config::VAD_COMMAND_MIN_LISTEN_MS,
|
||||||
|
config::VAD_COMMAND_MAX_LISTEN_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_params(
|
||||||
|
frame_ms: u32,
|
||||||
|
end_silence_ms: u32,
|
||||||
|
min_speech_ms: u32,
|
||||||
|
min_listen_ms: u32,
|
||||||
|
max_listen_ms: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
frame_ms,
|
||||||
|
elapsed_ms: 0,
|
||||||
|
speech_ms: 0,
|
||||||
|
silence_ms: 0,
|
||||||
|
end_silence_ms,
|
||||||
|
min_speech_ms,
|
||||||
|
min_listen_ms,
|
||||||
|
max_listen_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, is_speech: bool) -> WindowDecision {
|
||||||
|
self.elapsed_ms = self.elapsed_ms.saturating_add(self.frame_ms);
|
||||||
|
|
||||||
|
if is_speech {
|
||||||
|
self.speech_ms = self.speech_ms.saturating_add(self.frame_ms);
|
||||||
|
self.silence_ms = 0;
|
||||||
|
} else {
|
||||||
|
self.silence_ms = self.silence_ms.saturating_add(self.frame_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.elapsed_ms >= self.max_listen_ms {
|
||||||
|
return WindowDecision::HardCap;
|
||||||
|
}
|
||||||
|
if self.elapsed_ms < self.min_listen_ms {
|
||||||
|
return WindowDecision::KeepListening;
|
||||||
|
}
|
||||||
|
if self.speech_ms >= self.min_speech_ms && self.silence_ms >= self.end_silence_ms {
|
||||||
|
return WindowDecision::Close;
|
||||||
|
}
|
||||||
|
WindowDecision::KeepListening
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn elapsed_ms(&self) -> u32 { self.elapsed_ms }
|
||||||
|
pub fn speech_ms(&self) -> u32 { self.speech_ms }
|
||||||
|
pub fn silence_ms(&self) -> u32 { self.silence_ms }
|
||||||
|
|
||||||
|
pub fn had_speech(&self) -> bool { self.speech_ms > 0 }
|
||||||
|
}
|
||||||
49
crates/jarvis-core/src/audio_processing/vad/webrtc.rs
Normal file
49
crates/jarvis-core/src/audio_processing/vad/webrtc.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
use webrtc_vad::{SampleRate, Vad, VadMode};
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
|
||||||
|
pub const FRAME_SAMPLES: usize = 480;
|
||||||
|
pub const FRAME_MS: u32 = 30;
|
||||||
|
|
||||||
|
pub struct WebRtcVad {
|
||||||
|
inner: Vad,
|
||||||
|
buf: Vec<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebRtcVad {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::with_aggressiveness(config::VAD_AGGRESSIVENESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_aggressiveness(level: u8) -> Self {
|
||||||
|
let mode = match level {
|
||||||
|
0 => VadMode::Quality,
|
||||||
|
1 => VadMode::LowBitrate,
|
||||||
|
2 => VadMode::Aggressive,
|
||||||
|
_ => VadMode::VeryAggressive,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
inner: Vad::new_with_rate_and_mode(SampleRate::Rate16kHz, mode),
|
||||||
|
buf: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_samples(&mut self, samples: &[i16]) -> Vec<bool> {
|
||||||
|
self.buf.extend_from_slice(samples);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while self.buf.len() >= FRAME_SAMPLES {
|
||||||
|
let frame: Vec<i16> = self.buf.drain(..FRAME_SAMPLES).collect();
|
||||||
|
let is_speech = self.inner.is_voice_segment(&frame).unwrap_or(false);
|
||||||
|
out.push(is_speech);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebRtcVad {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
@ -171,6 +171,13 @@ pub const VAD_ENERGY_THRESHOLD: f32 = 100.0; // RMS threshold for energy-based
|
||||||
pub const VAD_NNNOISELESS_THRESHOLD: f32 = 0.8; // probability threshold for nnnoiseless
|
pub const VAD_NNNOISELESS_THRESHOLD: f32 = 0.8; // probability threshold for nnnoiseless
|
||||||
pub const VAD_SILENCE_FRAMES: u32 = 15; // frames of silence before speech end (~480ms)
|
pub const VAD_SILENCE_FRAMES: u32 = 15; // frames of silence before speech end (~480ms)
|
||||||
|
|
||||||
|
// post-wake command listening window (паритет с Python v0.2.0, webrtcvad)
|
||||||
|
pub const VAD_AGGRESSIVENESS: u8 = 2;
|
||||||
|
pub const VAD_COMMAND_END_SILENCE_MS: u32 = 1200;
|
||||||
|
pub const VAD_COMMAND_MIN_SPEECH_MS: u32 = 500;
|
||||||
|
pub const VAD_COMMAND_MIN_LISTEN_MS: u32 = 1000;
|
||||||
|
pub const VAD_COMMAND_MAX_LISTEN_MS: u32 = 15000;
|
||||||
|
|
||||||
// gain normalizer settings
|
// gain normalizer settings
|
||||||
pub const GAIN_TARGET_RMS: f32 = 3000.0; // target RMS level
|
pub const GAIN_TARGET_RMS: f32 = 3000.0; // target RMS level
|
||||||
pub const GAIN_MIN: f32 = 0.5; // minimum gain multiplier
|
pub const GAIN_MIN: f32 = 0.5; // minimum gain multiplier
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub use self::vosk::recognize_wake_word;
|
||||||
pub use self::vosk::recognize_speech;
|
pub use self::vosk::recognize_speech;
|
||||||
pub use self::vosk::reset_speech_recognizer;
|
pub use self::vosk::reset_speech_recognizer;
|
||||||
pub use self::vosk::reset_wake_recognizer;
|
pub use self::vosk::reset_wake_recognizer;
|
||||||
|
pub use self::vosk::finalize_speech;
|
||||||
|
|
||||||
static STT_TYPE: OnceCell<SpeechToTextEngine> = OnceCell::new();
|
static STT_TYPE: OnceCell<SpeechToTextEngine> = OnceCell::new();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,16 @@ pub fn reset_speech_recognizer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn finalize_speech() -> Option<String> {
|
||||||
|
let mut recognizer = SPEECH_RECOGNIZER.get()?.lock();
|
||||||
|
let result = recognizer.final_result()
|
||||||
|
.multiple()
|
||||||
|
.and_then(|m| m.alternatives.first().map(|a| a.text.to_string()))
|
||||||
|
.filter(|s| !s.trim().is_empty());
|
||||||
|
recognizer.reset();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reset_wake_recognizer() {
|
pub fn reset_wake_recognizer() {
|
||||||
if let Some(recognizer) = WAKE_RECOGNIZER.get() {
|
if let Some(recognizer) = WAKE_RECOGNIZER.get() {
|
||||||
recognizer.lock().reset();
|
recognizer.lock().reset();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue