浏览代码

Bug fixes

Efren Yevale Varela 1 月之前
父节点
当前提交
2d02545f05

+ 1 - 1
config.example.toml

@@ -53,7 +53,7 @@ ring_size    = 200                    # recent events replayed to a newly attach
 control_socket        = ""            # control socket (default $XDG_RUNTIME_DIR/stt-assistant.sock)
 stall_timeout_ms      = 3000          # no heartbeat for this long -> STT considered offline
 worker_stall_ms       = 15000         # heartbeats arriving but a transcription runs this long -> degraded
-command_arm_timeout_s = 10            # disarm a command if no turn is captured in this time
+command_arm_timeout_s = 10            # disarm only if armed with NOTHING captured for this long
 chat_idle_timeout_s   = 300           # leave chat mode after this much silence
 turn_gap_s            = 1.5           # quiet gap that ends a turn
 require_viewer        = true          # refuse to execute commands with no TUI attached

+ 9 - 6
src/stt_daemon/assistant/service.py

@@ -391,13 +391,16 @@ class AssistantService:
 
     def _check_timeouts(self, now: float) -> None:
         if self.modes.mode is Mode.COMMAND_ARMED:
-            if (
-                self.arm_ts is not None
-                and now - self.arm_ts > self.cfg.assistant.command_arm_timeout_s
-            ):
-                self._disarm("arm_timeout")
-            elif not self.stt_ok():
+            if not self.stt_ok():
                 self._disarm("stt_stall")
+            elif not self.speech_open and not self.command_text:
+                # Guard against an accidental hotkey: disarm only when armed and
+                # nothing was captured. An in-progress utterance (speech still open)
+                # or a captured command waits for the user's execute/cancel instead
+                # of being silently discarded.
+                last_activity = max(self.arm_ts or 0.0, self._last_activity_ts)
+                if now - last_activity > self.cfg.assistant.command_arm_timeout_s:
+                    self._disarm("arm_timeout")
         elif self.modes.mode is Mode.CHAT:
             if now - self._last_chat_activity > self.cfg.assistant.chat_idle_timeout_s:
                 self._exit_chat("idle_timeout")

+ 15 - 2
src/stt_daemon/daemon.py

@@ -205,8 +205,16 @@ class Daemon:
                 if not was_speaking and self.segmenter.speaking:
                     self._publish(events.SPEECH_START)
 
-                if utt is not None:
-                    self._on_segment_finalized(utt)
+                if was_speaking and not self.segmenter.speaking:
+                    # A turn ended. It may have been discarded (blip / too quiet),
+                    # in which case there is no utterance - but speech_stop must
+                    # still be published or consumers latch `speech_open` true.
+                    if utt is not None:
+                        self._on_segment_finalized(utt)
+                    else:
+                        self._publish(
+                            events.SPEECH_STOP, seg_id=None, end_reason="discarded"
+                        )
 
                 now = time.time()
                 if now >= next_heartbeat:
@@ -215,9 +223,14 @@ class Daemon:
 
             # Shutdown: flush any in-progress utterance
             if self.segmenter:
+                was_speaking = self.segmenter.speaking
                 utt = self.segmenter.flush()
                 if utt is not None:
                     self._on_segment_finalized(utt)
+                elif was_speaking:
+                    self._publish(
+                        events.SPEECH_STOP, seg_id=None, end_reason="discarded"
+                    )
             self._publish_heartbeat()
 
         except Exception as e:

+ 10 - 0
src/stt_daemon/segmenter.py

@@ -1,12 +1,15 @@
 """Voice activity detection and utterance segmentation with pre-roll buffer."""
 
 import collections
+import logging
 import time
 from dataclasses import dataclass
 
 import numpy as np
 import webrtcvad
 
+logger = logging.getLogger(__name__)
+
 
 END_OF_TURN = "end_of_turn"
 PAUSE = "pause"
@@ -218,6 +221,13 @@ class FrameSegmenter:
         # Drop segments too quiet to be real speech (noise that tripped the VAD).
         rms = float(np.sqrt(np.mean(audio**2)))
         if rms < self.rms_floor:
+            logger.warning(
+                "discarding %.1fs utterance: RMS %.4f below rms_floor %.4f "
+                "(raise [audio].gain or lower [vad].rms_floor)",
+                len(audio) / self.sample_rate,
+                rms,
+                self.rms_floor,
+            )
             return None
 
         return Utterance(

+ 14 - 7
src/stt_daemon/tui/app.py

@@ -42,7 +42,7 @@ class AssistantTUI(App):
         self.client = ControlClient(path)
         self._events: queue.Queue = queue.Queue()
         self._seen_transcripts = 0
-        self._seen_chat = 0
+        self._seen_lower = 0
 
     def compose(self) -> ComposeResult:
         yield Static(id="status")
@@ -52,7 +52,13 @@ class AssistantTUI(App):
         yield Input(placeholder="edit the command, then press Enter", id="edit")
         yield Footer()
 
+    def _set_titles(self) -> None:
+        self.query_one("#transcript", RichLog).border_title = "transcripts"
+        self.query_one("#command", Static).border_title = "command buffer"
+        self.query_one("#chat", RichLog).border_title = "conversation / command output"
+
     def on_mount(self) -> None:
+        self._set_titles()
         try:
             self.client.connect()
             self.client.subscribe(viewer=True)
@@ -74,9 +80,10 @@ class AssistantTUI(App):
     def _render(self) -> None:
         state = self.state
         health = "ok" if state.health_ok() else "DOWN"
+        speaking = "●" if state.speech_open else "○"
         status = (
             f"[{state.mode_style()} bold]{state.mode}[/]  "
-            f"stt:{health}  worker_stall:{state.worker_stall_s:.1f}s  "
+            f"stt:{health}  speech:{speaking}  worker_stall:{state.worker_stall_s:.1f}s  "
             f"backlog:{state.queue_depth}  phase:{state.phase}"
         )
         self.query_one("#status", Static).update(status)
@@ -92,11 +99,11 @@ class AssistantTUI(App):
             transcript_log.write(lines[self._seen_transcripts])
             self._seen_transcripts += 1
 
-        chat_log = self.query_one("#chat", RichLog)
-        chat_lines = state.chat_lines()
-        while self._seen_chat < len(chat_lines):
-            chat_log.write(chat_lines[self._seen_chat])
-            self._seen_chat += 1
+        lower_log = self.query_one("#chat", RichLog)
+        lower_lines = state.lower_lines()
+        while self._seen_lower < len(lower_lines):
+            lower_log.write(lower_lines[self._seen_lower])
+            self._seen_lower += 1
 
     def _send(self, type_: str, **fields) -> None:
         try:

+ 24 - 0
src/stt_daemon/tui/state.py

@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
 
 MAX_TRANSCRIPTS = 200
 MAX_CHAT = 200
+MAX_LOWER = 500
 
 MODE_STYLES = {
     "idle": "green",
@@ -24,6 +25,7 @@ class ViewState:
     mode: str = "idle"
     stt_ok: bool = True
     device_ok: bool = True
+    speech_open: bool = False
     worker_stall_s: float = 0.0
     queue_depth: int = 0
     ready: bool = False
@@ -33,6 +35,7 @@ class ViewState:
     in_flight: bool = False
     transcripts: list[dict] = field(default_factory=list)
     chat: list[dict] = field(default_factory=list)
+    lower: list[str] = field(default_factory=list)
     tool: dict | None = None
     tool_result: dict | None = None
     last_error: dict | None = None
@@ -49,6 +52,12 @@ class ViewState:
             self.phase = event.get("phase", self.phase)
             self.command_text = event.get("command_text", self.command_text)
             self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
+            if "speech_open" in event:
+                self.speech_open = bool(event["speech_open"])
+        elif etype == "speech_start":
+            self.speech_open = True
+        elif etype == "speech_stop":
+            self.speech_open = False
         elif etype == "heartbeat":
             # The heartbeat re-states the mode: a missed `mode` event self-corrects.
             self.mode = event.get("mode", self.mode)
@@ -69,12 +78,27 @@ class ViewState:
         elif etype == "chat_message":
             self.chat.append(event)
             del self.chat[:-MAX_CHAT]
+            self.append_lower(f"{event.get('role', '?')}: {event.get('text', '')}")
         elif etype == "tool_call":
             self.tool = event
+            args = event.get("args") or {}
+            suffix = f" {args}" if args else ""
+            self.append_lower(f"-> tool {event.get('tool')}{suffix}")
         elif etype == "tool_result":
             self.tool_result = event
+            verdict = "ok" if event.get("ok") else "failed"
+            self.append_lower(f"<- {verdict}: {event.get('result', '')}")
         elif etype == "error":
             self.last_error = event
+            self.append_lower(f"! {event.get('code')}: {event.get('message')}")
+
+    def append_lower(self, line: str) -> None:
+        """Append one line to the lower pane log (chat turns, tool calls, results, errors)."""
+        self.lower.append(line)
+        del self.lower[:-MAX_LOWER]
+
+    def lower_lines(self) -> list[str]:
+        return list(self.lower)
 
     def health_ok(self) -> bool:
         """False when the header should go red."""

+ 40 - 0
tests/test_assistant_service.py

@@ -92,6 +92,19 @@ def test_gated_segment_done_clears_readiness():
     assert service.ready() is True
 
 
+def test_speech_stop_without_seg_id_clears_readiness():
+    """A discarded turn closes with speech_stop but no seg_id; readiness must recover."""
+    service, _, clock = _service()
+
+    service.handle_event(_ev(events.SPEECH_START, clock))
+    assert service.ready() is False
+
+    service.handle_event(_ev(events.SPEECH_STOP, clock))  # discarded: no seg_id
+    assert service.speech_open is False
+    assert service.awaited_seg_id is None
+    assert service.ready() is True
+
+
 def test_status_resets_stale_speech_state_on_reconnect():
     service, _, clock = _service()
 
@@ -142,6 +155,33 @@ def test_arm_timeout_disarms_and_emits_error():
     assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
 
 
+def test_long_speech_does_not_disarm():
+    service, emitted, clock = _service()
+    service.arm()
+    service.handle_event(_ev(events.SPEECH_START, clock))
+
+    clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
+    service.tick()
+
+    assert service.modes.mode.value == "command_armed"
+    assert not any(e["type"] == "error" for e in emitted)
+
+
+def test_captured_command_survives_arm_timeout():
+    service, emitted, clock = _service()
+    service.arm()
+    service.handle_event(_transcript(clock, "what time is it"))
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
+
+    clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
+    service.tick()
+
+    assert service.modes.mode.value == "command_armed"
+    assert service.command_text == "what time is it"
+    assert not any(e["type"] == "error" for e in emitted)
+
+
 def test_stt_stall_cancels_armed_command():
     service, emitted, clock = _service()
     service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))

+ 31 - 0
tests/test_daemon_events.py

@@ -300,6 +300,37 @@ def test_stalled_capture_stops_heartbeats(sock_path):
     daemon.stop()
 
 
+def test_discarded_segment_still_emits_speech_stop(sock_path):
+    """A turn that is discarded (blip / too quiet) must still emit speech_stop.
+
+    Without it, consumers that track speech_open from speech_start/speech_stop
+    latch true forever, which blocks readiness and chat dispatch.
+    """
+    cfg = _base_config(sock_path)
+    cfg.vad.min_utterance_ms = 300  # a 1-frame blip is discarded
+    frames = [_frame() for _ in range(4)]
+    vad = FakeVad([True, False, False, False])  # confirm, then silence -> discard
+    daemon, collector, subscriber, _ = _start_daemon(
+        cfg, FakeCapture(frames), vad, FakeTranscriber()
+    )
+
+    collector.wait_for(lambda e: any(x["type"] == events.SPEECH_STOP for x in e))
+    time.sleep(0.2)
+
+    starts = collector.of(events.SPEECH_START)
+    stops = collector.of(events.SPEECH_STOP)
+
+    assert len(starts) == 1
+    assert len(stops) == 1
+    assert stops[0]["seg_id"] is None  # discarded: no segment assigned
+    assert stops[0]["end_reason"] == "discarded"
+    assert collector.of(events.SEGMENT_DONE) == []
+    assert collector.of(events.TRANSCRIPT) == []
+
+    subscriber.close()
+    daemon.stop()
+
+
 def test_inbound_data_on_subscriber_socket_is_ignored(sock_path):
     cfg = _base_config(sock_path)
     cfg.events.heartbeat_ms = 40

+ 56 - 0
tests/test_tui_app.py

@@ -39,6 +39,62 @@ def server():
     shutil.rmtree(directory, ignore_errors=True)
 
 
+def test_enter_sends_execute_and_e_opens_editor(server):
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+
+            await pilot.press("enter")
+            await pilot.pause()
+            await pilot.press("a")
+            await pilot.pause()
+            await pilot.press("escape")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert ("command_execute", {}) in sent
+    assert ("command_start", {}) in sent
+    assert ("command_cancel", {}) in sent
+
+
+def test_tui_renders_command_output(server):
+    """A tool result must actually reach a widget - otherwise 'nothing happens'."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.state.ingest(
+                {
+                    "type": "tool_result",
+                    "tool": "get_datetime",
+                    "ok": True,
+                    "result": "2026-09-21T01:00:00",
+                }
+            )
+            app._render()
+            await pilot.pause()
+            log = app.query_one("#chat", RichLog)
+            return list(log.lines)
+
+    lines = asyncio.run(scenario())
+    assert lines, "command output was not rendered anywhere"
+
+
 def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
     pytest.importorskip("textual")
     from stt_daemon.tui.app import AssistantTUI

+ 43 - 0
tests/test_tui_state.py

@@ -81,6 +81,49 @@ def test_transcripts_are_bounded():
     assert state.transcript_lines()[-1].startswith("[en]")
 
 
+def test_command_output_is_collected_for_display():
+    state = ViewState()
+    state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
+    state.ingest(
+        {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21T01:00:00"}
+    )
+    state.ingest({"type": "error", "code": "not_ready", "message": "command is not ready"})
+
+    lines = state.lower_lines()
+    assert any("tool get_datetime" in line for line in lines)
+    assert any("ok:" in line and "2026-09-21" in line for line in lines)
+    assert any("not_ready" in line for line in lines)
+
+
+def test_chat_turns_also_appear_in_lower_log():
+    state = ViewState()
+    state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
+    assert state.lower_lines() == ["user: hello"]
+
+
+def test_lower_log_is_bounded():
+    from stt_daemon.tui.state import MAX_LOWER
+
+    state = ViewState()
+    for i in range(MAX_LOWER + 20):
+        state.ingest({"type": "error", "code": f"e{i}", "message": "m"})
+    assert len(state.lower_lines()) == MAX_LOWER
+
+
+def test_speech_activity_is_tracked():
+    state = ViewState()
+    assert state.speech_open is False
+
+    state.ingest({"type": "speech_start"})
+    assert state.speech_open is True
+
+    state.ingest({"type": "speech_stop"})
+    assert state.speech_open is False
+
+    state.ingest({"type": "status", "speech_open": True})
+    assert state.speech_open is True
+
+
 def test_chat_and_tool_events_recorded():
     state = ViewState()
     state.ingest({"type": "chat_message", "role": "user", "text": "hi"})