From f37023a3168da5fc8aac6c5098c9aa9604ba0153 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 29 Jun 2026 16:23:18 -0500 Subject: [PATCH] Timeout early without confident speech --- .../components/assist_pipeline/vad.py | 38 ++++++-- tests/components/assist_pipeline/test_vad.py | 93 +++++++++++++++++++ 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 924f8d7baa45..7a32bd31703d 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -120,9 +120,18 @@ class VoiceCommandSegmenter: in_command_silence_threshold: float = 0.2 """Probability below which an in-command chunk counts toward ending the command.""" + min_command_speech_seconds: float = 0.05 + """Confident speech required before a command may finish on silence.""" + + false_start_timeout_seconds: float = 5.0 + """Seconds to wait for confident speech before giving up (a false activation).""" + _speech_seconds_left: float = 0.0 """Seconds left before considering voice command as started.""" + _command_speech_seconds_left: float = 0.0 + """Confident speech still required before the command may finish.""" + _command_seconds_left: float = 0.0 """Seconds left before voice command could stop.""" @@ -143,6 +152,7 @@ class VoiceCommandSegmenter: """Reset all counters and state.""" self._speech_seconds_left = self.speech_seconds self._command_seconds_left = self.command_seconds - self.speech_seconds + self._command_speech_seconds_left = self.min_command_speech_seconds self._silence_seconds_left = self.silence_seconds self._timeout_seconds_left = self.timeout_seconds self._reset_seconds_left = self.reset_seconds @@ -157,10 +167,15 @@ class VoiceCommandSegmenter: self.timed_out = False self._timeout_seconds_left -= chunk_seconds - if self._timeout_seconds_left <= 0: + no_speech_yet = self._command_speech_seconds_left > 0 + elapsed = self.timeout_seconds - self._timeout_seconds_left + if (self._timeout_seconds_left <= 0) or ( + no_speech_yet and (elapsed >= self.false_start_timeout_seconds) + ): _LOGGER.debug( - "VAD end of speech detection timed out after %s seconds", - self.timeout_seconds, + "VAD timed out after %.2f seconds (no_speech_yet=%s)", + elapsed, + no_speech_yet, ) self.reset() self.timed_out = True @@ -178,6 +193,9 @@ class VoiceCommandSegmenter: if is_speech: self._reset_seconds_left = self.reset_seconds self._speech_seconds_left -= chunk_seconds + if speech_probability > self.in_command_speech_threshold: + # Confident speech during onset also counts toward confirmation + self._command_speech_seconds_left -= chunk_seconds if self._speech_seconds_left <= 0: # Inside voice command self.in_command = True @@ -191,12 +209,14 @@ class VoiceCommandSegmenter: self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: self._speech_seconds_left = self.speech_seconds + self._command_speech_seconds_left = self.min_command_speech_seconds self._reset_seconds_left = self.reset_seconds else: # In command self._command_seconds_left -= chunk_seconds if speech_probability > self.in_command_speech_threshold: # Speech in command. + self._command_speech_seconds_left -= chunk_seconds # Reset silence counter if enough speech. self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: @@ -209,10 +229,16 @@ class VoiceCommandSegmenter: if (self._silence_seconds_left <= 0) and ( self._command_seconds_left <= 0 ): - # Command finished successfully + if self._command_speech_seconds_left <= 0: + # Command finished successfully + self.reset() + _LOGGER.debug("Voice command finished") + return False + # Triggered without real speech: false start, resume listening + _LOGGER.debug("Discarding false start (no speech detected)") + timeout_seconds_left = self._timeout_seconds_left self.reset() - _LOGGER.debug("Voice command finished") - return False + self._timeout_seconds_left = timeout_seconds_left # else: dead-band activity holds the command open without refreshing return True diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index 461a47e2316c..8d00e32ec910 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -241,6 +241,7 @@ def test_speech_thresholds() -> None: command_seconds=2, speech_seconds=1, silence_seconds=1, + min_command_speech_seconds=0.0, ) # Not high enough probability to trigger command @@ -310,6 +311,98 @@ def test_dead_band_times_out() -> None: assert segmenter.timed_out +def test_false_start_aborts_and_resumes() -> None: + """Test a trigger without confident speech aborts instead of finishing.""" + + segmenter = VoiceCommandSegmenter( + before_command_speech_threshold=0.2, + in_command_speech_threshold=0.5, + in_command_silence_threshold=0.2, + min_command_speech_seconds=0.1, + speech_seconds=0.3, + command_seconds=1.0, + silence_seconds=0.5, + ) + + # A sub-0.5 transient (above entry, below speech) triggers a command... + assert segmenter.process(_ONE_SECOND * 0.3, 0.35) + assert segmenter.in_command + + # ...but with no confident speech, the silence end aborts back to listening + # instead of finishing into near-empty audio. + assert segmenter.process(_ONE_SECOND, 0.0) + assert not segmenter.in_command + + # The real command then arrives and finishes normally + assert segmenter.process(_ONE_SECOND, 1.0) + assert segmenter.in_command + assert not segmenter.process(_ONE_SECOND, 0.0) + assert not segmenter.in_command + + +def test_false_start_timeout() -> None: + """Test a false activation gives up at false_start_timeout, not timeout_seconds.""" + + segmenter = VoiceCommandSegmenter( + before_command_speech_threshold=0.2, + in_command_speech_threshold=0.5, + in_command_silence_threshold=0.2, + min_command_speech_seconds=0.05, + false_start_timeout_seconds=3.0, + timeout_seconds=15.0, + ) + + # A trigger with no confident speech, followed by silence: gives up at 3s, + # well before the 15s command timeout. + assert segmenter.process(_ONE_SECOND, 0.35) + assert segmenter.process(_ONE_SECOND, 0.0) + assert not segmenter.timed_out + + # Crosses false_start_timeout (3s elapsed) without confident speech + assert not segmenter.process(_ONE_SECOND, 0.0) + assert segmenter.timed_out + + +def test_false_start_timeout_not_applied_after_speech() -> None: + """Test confident speech lifts the false-start timeout to the full command timeout.""" + + segmenter = VoiceCommandSegmenter( + in_command_speech_threshold=0.5, + min_command_speech_seconds=0.05, + false_start_timeout_seconds=3.0, + timeout_seconds=15.0, + speech_seconds=0.3, + ) + + # Confident speech early confirms the command... + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) + assert segmenter.in_command + + # ...so a long pause past false_start_timeout does NOT give up early + assert segmenter.process(_ONE_SECOND * 4, 0.3) + assert not segmenter.timed_out + assert segmenter.in_command + + +def test_min_command_speech_disabled() -> None: + """Test min_command_speech_seconds=0 keeps the original finish behavior.""" + + segmenter = VoiceCommandSegmenter( + in_command_speech_threshold=0.5, + in_command_silence_threshold=0.2, + min_command_speech_seconds=0.0, + speech_seconds=0.3, + command_seconds=1.0, + silence_seconds=0.5, + ) + + # Trigger on a sub-0.5 transient and finish on silence (no abort) + assert segmenter.process(_ONE_SECOND * 0.3, 0.35) + assert segmenter.in_command + assert not segmenter.process(_ONE_SECOND, 0.0) + assert not segmenter.in_command + + @pytest.mark.parametrize( ("sensitivity", "expected_threshold"), [