Start listening for expected follow-ups

Propagate the master's follow-up decision to slave devices and skip wake-word detection for one turn after response playback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
JavisandCopilot committed 2026-10-10 12:51:36 +02:00
1 parent b921938970
commit 74bc1be2d6
7 files changed
+101 -24

No files matched your search

+2
View File
@@ -27,6 +27,8 @@ screen (GUI mode) and played back through the device's speakers.
4. **Turns** — After the wake word, the utterance is recorded locally
(Silero VAD or RMS end-pointing), uploaded as WAV to `POST /api/v1/turn`,
and the master's reply text + synthesized speech are returned and played.
When the master marks a reply as expecting a follow-up, the slave starts
listening immediately after playback without requiring another wake word.
If the master's turn fails it returns its spoken error phrase instead, so a
screenless satellite still gets audible feedback; connection problems play
a low falling tone.
@@ -37,6 +37,7 @@ class TurnResult:
audio_wav: Optional[bytes] # decoded WAV bytes (16-bit PCM)
sample_rate: Optional[int]
error: Optional[str] = None # master-side failure; reply_text is the spoken error phrase
expects_followup: bool = False
def _http_detail(r: httpx.Response) -> str:
@@ -263,6 +264,7 @@ class MasterClient:
audio_wav=base64.b64decode(audio_b64) if audio_b64 else None,
sample_rate=data.get("sample_rate"),
error=data.get("error") or None,
expects_followup=bool(data.get("expects_followup", False)),
)
async def check_health(self, host: str, port: int) -> bool:
+29 -24
View File
@@ -72,8 +72,9 @@ class AudioEngine:
self.wake: Optional[WakeWordDetector] = None
self.vad = None
self._config_task: Optional[asyncio.Task] = None
# Set when last response ended with '?'; next recording will use 3x silence timeout
self._last_response_is_question: bool = False
# Set by the master when the reply expects an answer. The next loop
# skips wake-word detection and starts recording after playback ends.
self._followup_pending: bool = False
# UI callbacks (assigned by the app layer).
self.on_state: Callable[[str], None] = lambda s: None
@@ -203,21 +204,27 @@ class AudioEngine:
await self._wait_unmuted_or_stop()
continue
try:
woke = await self._wait_for_wake()
if not woke:
if self._stop.is_set() or self.muted:
followup = self._followup_pending
if followup:
self._followup_pending = False
log.info("follow-up mode: skipping wake word")
spawn(self._play_chime([880.0, 1320.0], 0.06, 0.18))
else:
woke = await self._wait_for_wake()
if not woke:
if self._stop.is_set() or self.muted:
continue
# The mic yielded nothing for _WAKE_STALE_S: USB device
# unplugged / driver stalled. Reopen instead of looping forever.
stale_exits += 1
if stale_exits >= _MIC_RESTART_AFTER_STALE and self.mic is not None:
log.warning("mic silent for too long — reopening device")
self.on_error("Mikrofon liefert keine Daten — Gerät wird neu geöffnet …")
await self.mic.restart()
stale_exits = 0
continue
# The mic yielded nothing for _WAKE_STALE_S: USB device
# unplugged / driver stalled. Reopen instead of looping forever.
stale_exits += 1
if stale_exits >= _MIC_RESTART_AFTER_STALE and self.mic is not None:
log.warning("mic silent for too long — reopening device")
self.on_error("Mikrofon liefert keine Daten — Gerät wird neu geöffnet …")
await self.mic.restart()
stale_exits = 0
continue
stale_exits = 0
await self._handle_turn()
await self._handle_turn(is_followup=followup)
consecutive_errors = 0
except asyncio.CancelledError:
raise
@@ -282,16 +289,17 @@ class AudioEngine:
"""Low descending two-tone: "something went wrong"."""
await self._play_chime([440.0, 330.0], 0.12, 0.2)
async def _handle_turn(self) -> None:
async def _handle_turn(self, *, is_followup: bool = False) -> None:
assert self.mic is not None
self.on_state("listening")
# If last response was a question, triple the listening timeout
silence_override = None
if self._last_response_is_question:
if is_followup:
base_silence = self.cfg.wake_word.silence_ms
silence_override = base_silence * 3
log.debug(f"last response was a question; extending silence timeout to {silence_override}ms (was {base_silence}ms)")
self._last_response_is_question = False # reset flag for next turn
log.debug(
f"follow-up recording: extending silence timeout to "
f"{silence_override}ms (was {base_silence}ms)"
)
audio = await record_utterance(
self.cfg.sample_rate, self.cfg.wake_word.silence_ms, self.mic, self.vad,
silence_ms_override=silence_override,
@@ -328,10 +336,6 @@ class AudioEngine:
if result.user_text or result.reply_text:
self.on_turn(result.user_text, result.reply_text)
# Check if response ended with '?' to trigger extended listening on next turn
if result.reply_text.rstrip().endswith("?"):
log.debug("assistant response ends with '?'; will extend listening timeout on next turn")
self._last_response_is_question = True
if result.audio_wav and result.sample_rate:
self.on_state("speaking")
@@ -346,6 +350,7 @@ class AudioEngine:
cancel_event=self._stop)
except Exception as e: # noqa: BLE001
log.warning(f"reply playback failed: {e}")
self._followup_pending = result.expects_followup and not bool(result.error)
self.on_state("idle")
# ---------------- config refresh ----------------