[voice_assistant] Make TTS playback start timeout configurable (#18707)

Co-authored-by: n-IA-hane <n-IA-hane@users.noreply.github.com>
This commit is contained in:
n-IA-hane
2026-10-13 08:13:26 -10:00
committed by GitHub
co-authored by n-IA-hane
parent 5acbd44067
commit d27ec8ec49
9 changed files with 100 additions and 2 deletions
+15 -1
View File
@@ -1,4 +1,4 @@
from esphome import automation
from esphome import automation, core
import esphome.codegen as cg
from esphome.components import media_player, micro_wake_word, microphone, speaker
import esphome.config_validation as cv
@@ -47,6 +47,9 @@ CONF_MICRO_WAKE_WORD = "micro_wake_word"
CONF_WAKE_WORD = "wake_word"
CONF_CONVERSATION_TIMEOUT = "conversation_timeout"
CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout"
# Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h.
TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000)
CONF_ON_TIMER_STARTED = "on_timer_started"
CONF_ON_TIMER_UPDATED = "on_timer_updated"
@@ -110,6 +113,13 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_CONVERSATION_TIMEOUT, default="300s"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TTS_PLAYBACK_START_TIMEOUT,
default=TTS_PLAYBACK_START_TIMEOUT_DEFAULT,
): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=core.TimePeriod(milliseconds=1)),
),
cv.Optional(CONF_VOLUME_MULTIPLIER, default=1.0): cv.float_range(
min=0.0, min_included=False
),
@@ -218,6 +228,10 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN]))
cg.add(var.set_volume_multiplier(config[CONF_VOLUME_MULTIPLIER]))
cg.add(var.set_conversation_timeout(config[CONF_CONVERSATION_TIMEOUT]))
if (
timeout := config[CONF_TTS_PLAYBACK_START_TIMEOUT]
) != TTS_PLAYBACK_START_TIMEOUT_DEFAULT:
cg.add(var.set_tts_playback_start_timeout(timeout))
if CONF_ON_LISTENING in config:
await automation.build_automation(
@@ -760,7 +760,7 @@ void VoiceAssistant::signal_stop_() {
}
void VoiceAssistant::start_playback_timeout_() {
this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() {
this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() {
this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID);
this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED);
@@ -193,6 +193,7 @@ class VoiceAssistant final : public Component {
void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; }
void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; }
void set_conversation_timeout(uint32_t conversation_timeout) { this->conversation_timeout_ = conversation_timeout; }
void set_tts_playback_start_timeout(uint32_t timeout) { this->tts_playback_start_timeout_ = timeout; }
void reset_conversation_id();
Trigger<> *get_intent_end_trigger() { return &this->intent_end_trigger_; }
@@ -318,6 +319,8 @@ class VoiceAssistant final : public Component {
float volume_multiplier_;
uint32_t conversation_timeout_;
// Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py.
uint32_t tts_playback_start_timeout_{2000};
Configuration config_{};
@@ -0,0 +1,44 @@
"""Test playback timeout C++ generation."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"])
def test_playback_timeout_codegen(
timeout: str | None,
tmp_path: Path,
generate_main: Callable[[Path], str],
) -> None:
config = tmp_path / "voice_assistant.yaml"
config.write_text(
"""esphome:
name: test-voice-assistant
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: test-network
api:
i2s_audio:
i2s_lrclk_pin: GPIO25
i2s_bclk_pin: GPIO26
microphone:
- platform: i2s_audio
id: test_mic
adc_type: external
i2s_din_pin: GPIO34
voice_assistant:
microphone: test_mic
"""
+ (f" tts_playback_start_timeout: {timeout}\n" if timeout else "")
)
main = generate_main(config)
if timeout in (None, "2s", "2000ms"):
assert "set_tts_playback_start_timeout(" not in main
else:
milliseconds = 1 if timeout == "1ms" else 10000
assert f"set_tts_playback_start_timeout({milliseconds});" in main
@@ -55,6 +55,7 @@ voice_assistant:
speaker: va_speaker_id
micro_wake_word: mww_id
conversation_timeout: 60s
tts_playback_start_timeout: 10s
on_listening:
- logger.log: "Voice assistant microphone listening"
on_start:
@@ -36,6 +36,7 @@ voice_assistant:
channels: 0
speaker: va_speaker_id
conversation_timeout: 60s
tts_playback_start_timeout: 10s
on_listening:
- logger.log: "Voice assistant microphone listening"
on_start:
@@ -0,0 +1,35 @@
"""Validate Voice Assistant playback timeout configuration."""
import pytest
from esphome.components import voice_assistant
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE
@pytest.mark.parametrize(
"value",
["0s", "0ms", {"seconds": 0}, "-1s", "500us", "1ns", "500ns", {"nanoseconds": 1}],
)
def test_playback_timeout_rejects_invalid_duration(value: str | dict[str, int]) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
with pytest.raises(cv.Invalid):
voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value})
@pytest.mark.parametrize(
"value, milliseconds", [("1ms", 1), ("2s", 2000), ("10s", 10000)]
)
def test_playback_timeout_accepts_positive_milliseconds(
value: str, milliseconds: int
) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value})
assert config["tts_playback_start_timeout"].total_milliseconds == milliseconds
def test_playback_timeout_default_is_two_seconds() -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = voice_assistant.CONFIG_SCHEMA({})
assert config["tts_playback_start_timeout"].total_milliseconds == 2000