Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 108 additions & 34 deletions hermes_cli/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ def _play_beep(frequency: int, count: int = 1) -> None:
# ── Continuous (VAD) state ───────────────────────────────────────────
_continuous_lock = threading.Lock()
_continuous_active = False
_continuous_stopping = False
_continuous_auto_restart: bool = True
_continuous_recorder: Any = None

# ── TTS-vs-STT feedback guard ────────────────────────────────────────
Expand Down Expand Up @@ -370,32 +372,39 @@ def start_continuous(
on_silent_limit: Optional[Callable[[], None]] = None,
silence_threshold: int = 200,
silence_duration: float = 3.0,
auto_restart: bool = True,
) -> None:
"""Start a VAD-driven continuous recording loop.

The loop calls ``on_transcript(text)`` each time speech is detected and
transcribed successfully, then auto-restarts. After
``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
picked up at all) the loop stops itself and calls ``on_silent_limit``
so the UI can reflect "voice off". Idempotent — calling while already
active is a no-op.
transcribed successfully. If ``auto_restart`` is True, it auto-restarts
for the next turn and resets the no-speech counter for that loop. If
``auto_restart`` is False, the first silence-triggered transcription ends
the loop and reports ``"idle"``; no-speech counts are retained across
starts so a push-to-talk caller can still enforce the three-strikes guard.
After ``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
picked up at all) the loop stops itself and calls ``on_silent_limit`` so the
UI can reflect "voice off". Idempotent — calling while already active is a
no-op.

``on_status`` is called with ``"listening"`` / ``"transcribing"`` /
``"idle"`` so the UI can show a live indicator.
"""
global _continuous_active, _continuous_recorder
global _continuous_active, _continuous_recorder, _continuous_auto_restart
global _continuous_on_transcript, _continuous_on_status, _continuous_on_silent_limit
global _continuous_no_speech_count

with _continuous_lock:
if _continuous_active:
if _continuous_active or _continuous_stopping:
_debug("start_continuous: already active — no-op")
return
Comment thread
OutThisLife marked this conversation as resolved.
Resolved
_continuous_active = True
_continuous_auto_restart = auto_restart
_continuous_on_transcript = on_transcript
_continuous_on_status = on_status
_continuous_on_silent_limit = on_silent_limit
_continuous_no_speech_count = 0
if auto_restart:
_continuous_no_speech_count = 0

if _continuous_recorder is None:
_continuous_recorder = create_audio_recorder()
Expand Down Expand Up @@ -429,14 +438,14 @@ def start_continuous(
pass


def stop_continuous() -> None:
def stop_continuous(force_transcribe: bool = False) -> None:
"""Stop the active continuous loop and release the microphone.

Idempotent — calling while not active is a no-op. Any in-flight
transcription completes but its result is discarded (the callback
checks ``_continuous_active`` before firing).
Idempotent — calling while not active is a no-op. If force_transcribe
is True, the current buffer is transcribed before stopping. Otherwise
the buffer is discarded.
Comment thread
OutThisLife marked this conversation as resolved.
Resolved
"""
global _continuous_active, _continuous_on_transcript
global _continuous_active, _continuous_on_transcript, _continuous_stopping
global _continuous_on_status, _continuous_on_silent_limit
global _continuous_recorder, _continuous_no_speech_count

Expand All @@ -446,18 +455,67 @@ def stop_continuous() -> None:
_continuous_active = False
rec = _continuous_recorder
on_status = _continuous_on_status
on_transcript = _continuous_on_transcript
_continuous_stopping = rec is not None
_continuous_on_transcript = None
_continuous_on_status = None
_continuous_on_silent_limit = None
_continuous_no_speech_count = 0
Comment thread
OutThisLife marked this conversation as resolved.
Resolved

if rec is not None:
try:
# cancel() (not stop()) discards buffered frames — the loop
# is over, we don't want to transcribe a half-captured turn.
rec.cancel()
except Exception as e:
logger.warning("failed to cancel recorder: %s", e)
if force_transcribe and on_transcript:
if on_status:
try:
on_status("transcribing")
except Exception:
pass
try:
wav_path = rec.stop()
except Exception as e:
logger.warning("failed to stop recorder: %s", e)
try:
rec.cancel()
except Exception as cancel_error:
logger.warning("failed to cancel recorder: %s", cancel_error)
wav_path = None
Comment thread
OutThisLife marked this conversation as resolved.
Resolved

def _transcribe_and_cleanup():
try:
if wav_path:
try:
result = transcribe_recording(wav_path)
if result.get("success"):
text = (result.get("transcript") or "").strip()
if text and not is_whisper_hallucination(text):
on_transcript(text)
finally:
if os.path.isfile(wav_path):
os.unlink(wav_path)
except Exception as e:
logger.warning("failed to stop/transcribe recorder: %s", e)
finally:
_play_beep(frequency=660, count=2)
global _continuous_stopping
with _continuous_lock:
_continuous_stopping = False
if on_status:
try:
on_status("idle")
except Exception:
pass

threading.Thread(target=_transcribe_and_cleanup, daemon=True).start()
return
else:
try:
# cancel() (not stop()) discards buffered frames — the loop
# is over, we don't want to transcribe a half-captured turn.
rec.cancel()
except Exception as e:
logger.warning("failed to cancel recorder: %s", e)

with _continuous_lock:
_continuous_stopping = False

# Audible "recording stopped" cue (CLI parity: same 660 Hz × 2 the
# silence-auto-stop path plays).
Expand Down Expand Up @@ -603,23 +661,39 @@ def _continuous_on_silence() -> None:
_debug("_continuous_on_silence: stopped while waiting for TTS")
return

# Restart for the next turn.
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
_play_beep(frequency=880, count=1)
try:
rec.start(on_silence_stop=_continuous_on_silence)
except Exception as e:
logger.error("failed to restart continuous recording: %s", e)
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
if _continuous_auto_restart:
# Restart for the next turn.
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
_play_beep(frequency=880, count=1)
try:
Comment thread
OutThisLife marked this conversation as resolved.
Resolved
rec.start(on_silence_stop=_continuous_on_silence)
except Exception as e:
logger.error("failed to restart continuous recording: %s", e)
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
with _continuous_lock:
_continuous_active = False
if on_status:
try:
on_status("idle")
except Exception:
pass
return

if on_status:
try:
on_status("listening")
except Exception:
pass
else:
# Do not auto-restart. Clean up state and notify idle.
_debug("_continuous_on_silence: auto_restart=False, stopping loop")
with _continuous_lock:
_continuous_active = False
return

if on_status:
try:
on_status("listening")
except Exception:
pass
if on_status:
try:
on_status("idle")
except Exception:
pass


# ── TTS API ──────────────────────────────────────────────────────────
Expand Down
139 changes: 139 additions & 0 deletions tests/hermes_cli/test_voice_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ def test_not_active_by_default(self, monkeypatch):

# Isolate from any state left behind by other tests in the session.
monkeypatch.setattr(voice, "_continuous_active", False)
monkeypatch.setattr(voice, "_continuous_stopping", False, raising=False)
monkeypatch.setattr(voice, "_continuous_recorder", None)

assert voice.is_continuous_active() is False
Expand Down Expand Up @@ -368,6 +369,8 @@ def fake_recorder(self, monkeypatch):
monkeypatch.setattr(voice, "_continuous_on_transcript", None)
monkeypatch.setattr(voice, "_continuous_on_status", None)
monkeypatch.setattr(voice, "_continuous_on_silent_limit", None)
monkeypatch.setattr(voice, "_continuous_auto_restart", True, raising=False)
monkeypatch.setattr(voice, "_play_beep", lambda *_, **__: None)

class FakeRecorder:
_silence_threshold = 200
Expand All @@ -381,13 +384,20 @@ def __init__(self):
self.cancelled = 0
# Preset WAV path returned by stop()
self.next_stop_wav = "/tmp/fake.wav"
self.fail_stop = False
self.fail_next_start = False

def start(self, on_silence_stop=None):
if self.fail_next_start:
self.fail_next_start = False
raise RuntimeError("boom")
self.start_calls += 1
self.last_callback = on_silence_stop
self.is_recording = True

def stop(self):
if self.fail_stop:
raise RuntimeError("stop failed")
self.stopped += 1
self.is_recording = False
return self.next_stop_wav
Expand Down Expand Up @@ -433,6 +443,135 @@ def test_loop_auto_restarts_after_transcript(self, fake_recorder, monkeypatch):

voice.stop_continuous()

def test_auto_restart_false_stops_after_first_transcript(self, fake_recorder, monkeypatch):
import hermes_cli.voice as voice

monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": "single shot"},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)

transcripts = []
statuses = []

voice.start_continuous(
on_transcript=lambda t: transcripts.append(t),
on_status=lambda s: statuses.append(s),
auto_restart=False,
)
fake_recorder.last_callback()

assert transcripts == ["single shot"]
assert fake_recorder.start_calls == 1
assert statuses == ["listening", "transcribing", "idle"]
assert voice.is_continuous_active() is False

def test_auto_restart_false_retains_silent_strikes_across_starts(
self, fake_recorder, monkeypatch
):
import hermes_cli.voice as voice

monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": ""},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)

silent_limit_fired = []

for _ in range(3):
voice.start_continuous(
on_transcript=lambda _t: None,
on_silent_limit=lambda: silent_limit_fired.append(True),
auto_restart=False,
)
fake_recorder.last_callback()

assert silent_limit_fired == [True]
assert voice.is_continuous_active() is False
assert fake_recorder.start_calls == 3

def test_force_transcribe_stop_delivers_current_buffer(self, fake_recorder, monkeypatch):
import hermes_cli.voice as voice

class ImmediateThread:
def __init__(self, target, daemon=False):
self.target = target

def start(self):
self.target()

monkeypatch.setattr(voice.threading, "Thread", ImmediateThread)
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": "manual stop"},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)

transcripts = []
statuses = []

voice.start_continuous(
on_transcript=lambda t: transcripts.append(t),
on_status=lambda s: statuses.append(s),
)
voice.stop_continuous(force_transcribe=True)

assert fake_recorder.stopped == 1
assert transcripts == ["manual stop"]
assert statuses == ["listening", "transcribing", "idle"]
assert voice.is_continuous_active() is False

def test_force_transcribe_stop_failure_cancels_and_clears_stopping(
self, fake_recorder, monkeypatch
):
import hermes_cli.voice as voice

class ImmediateThread:
def __init__(self, target, daemon=False):
self.target = target

def start(self):
self.target()

monkeypatch.setattr(voice.threading, "Thread", ImmediateThread)
fake_recorder.fail_stop = True

statuses = []
voice.start_continuous(
on_transcript=lambda _t: None,
on_status=lambda s: statuses.append(s),
)
voice.stop_continuous(force_transcribe=True)

assert fake_recorder.cancelled == 1
assert statuses == ["listening", "transcribing", "idle"]
assert voice.is_continuous_active() is False
assert voice._continuous_stopping is False

def test_restart_failure_reports_idle(self, fake_recorder, monkeypatch):
import hermes_cli.voice as voice

monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": "hello world"},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)

statuses = []
voice.start_continuous(on_transcript=lambda _t: None, on_status=statuses.append)

fake_recorder.fail_next_start = True
fake_recorder.last_callback()

assert statuses == ["listening", "transcribing", "idle"]
assert voice.is_continuous_active() is False

def test_silent_limit_halts_loop_after_three_strikes(self, fake_recorder, monkeypatch):
import hermes_cli.voice as voice

Expand Down
Loading
Loading