Timeout early without confident speech

This commit is contained in:
Michael Hansen
2026-06-29 16:23:18 -05:00
parent 66e8c50377
commit f37023a316
2 changed files with 125 additions and 6 deletions
@@ -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
@@ -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"),
[