diff --git a/cli.py b/cli.py index 79961c3e21f..3cc4116b49e 100644 --- a/cli.py +++ b/cli.py @@ -11826,14 +11826,37 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): threading.Thread(target=_refresh_level, daemon=True).start() def _voice_stt_model(self) -> Optional[str]: - """STT model override from config, or None for the provider default.""" + """STT model override from config, or None for the provider default. + + For the local provider, prefer stt.local.model (default ``base``) so the + CLI passes a real model name into the local STT backend. + """ try: from hermes_cli.config import load_config stt_config = load_config().get("stt", {}) - return stt_config.get("model") if isinstance(stt_config, dict) else None + if not isinstance(stt_config, dict): + return None + provider = str(stt_config.get("provider") or "").strip().lower() + if provider == "local": + local_config = stt_config.get("local") or {} + if not isinstance(local_config, dict): + local_config = {} + return local_config.get("model") or "base" + return stt_config.get("model") except Exception: return None + def _voice_stt_provider(self) -> str: + """Configured STT provider name (lowercased), or empty string.""" + try: + from hermes_cli.config import load_config + stt_config = load_config().get("stt", {}) + if not isinstance(stt_config, dict): + return "" + return str(stt_config.get("provider") or "").strip().lower() + except Exception: + return "" + def _voice_restart_recording_async(self) -> None: """Restart continuous-mode recording off-thread (start() can block).""" def _restart_recording(): @@ -11880,10 +11903,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # _voice_processing is already True (set atomically above) if hasattr(self, '_app') and self._app: self._app.invalidate() - _cprint(f"{_DIM}Transcribing...{_RST}") + + stt_model = self._voice_stt_model() + if self._voice_stt_provider() == "local": + _cprint( + f"{_DIM}Preparing local STT model '{stt_model}' " + f"(first use may download it from Hugging Face)...{_RST}" + ) + else: + _cprint(f"{_DIM}Transcribing...{_RST}") from tools.voice_mode import transcribe_recording - result = transcribe_recording(wav_path, model=self._voice_stt_model()) + result = transcribe_recording(wav_path, model=stt_model) if result.get("success") and result.get("transcript", "").strip(): transcript = result["transcript"].strip() diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py index 7decad01ccd..5bc6ff462dd 100644 --- a/tests/tools/test_voice_cli_integration.py +++ b/tests/tools/test_voice_cli_integration.py @@ -1299,6 +1299,56 @@ class TestVoiceStopAndTranscribeReal: cli._voice_stop_and_transcribe() cli._voice_start_recording.assert_not_called() + @pytest.mark.parametrize( + ("stt_config", "expected_model"), + [ + ({"provider": "local", "model": "whisper-1", "local": {"model": "small"}}, "small"), + ({"provider": "local", "local": {"model": "tiny"}}, "tiny"), + ({"provider": "local", "model": "whisper-1"}, "base"), + ], + ) + def test_local_stt_shows_model_download_status(self, stt_config, expected_model): + recorder = MagicMock() + recorder.stop.return_value = "/tmp/test.wav" + cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder) + + with patch("cli._cprint") as mock_print, \ + patch("cli.os.path.isfile", return_value=False), \ + patch("hermes_cli.config.load_config", return_value={"stt": stt_config}), \ + patch("tools.voice_mode.transcribe_recording", + return_value={"success": True, "transcript": "hello"}) as mock_transcribe, \ + patch("tools.voice_mode.play_beep"): + cli._voice_stop_and_transcribe() + + messages = [call.args[0] for call in mock_print.call_args_list] + assert any( + f"local STT model '{expected_model}'" in message + and "first use may download it from Hugging Face" in message + for message in messages + ) + mock_transcribe.assert_called_once_with("/tmp/test.wav", model=expected_model) + + def test_non_local_stt_keeps_generic_transcribing_status(self): + recorder = MagicMock() + recorder.stop.return_value = "/tmp/test.wav" + cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder) + + with patch("cli._cprint") as mock_print, \ + patch("cli.os.path.isfile", return_value=False), \ + patch( + "hermes_cli.config.load_config", + return_value={"stt": {"provider": "openai", "model": "whisper-1"}}, + ), \ + patch("tools.voice_mode.transcribe_recording", + return_value={"success": True, "transcript": "hello"}) as mock_transcribe, \ + patch("tools.voice_mode.play_beep"): + cli._voice_stop_and_transcribe() + + messages = [call.args[0] for call in mock_print.call_args_list] + assert any("Transcribing..." in message for message in messages) + assert all("Hugging Face" not in message for message in messages) + mock_transcribe.assert_called_once_with("/tmp/test.wav", model="whisper-1") + @patch("cli._cprint") @patch("cli.os.unlink") @patch("cli.os.path.isfile", return_value=True)