mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 02:24:46 -05:00
Timeout early without confident speech
This commit is contained in:
@@ -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"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user