diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index e270973e1d..50c9e58918 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -10,18 +10,6 @@ from unittest.mock import MagicMock, patch import pytest -def _non_wsl_proc_version(real_open): - """Return an open() shim that makes host WSL detection deterministic.""" - def _fake_open(file, *args, **kwargs): - if file == "/proc/version": - from io import StringIO - - return StringIO("Linux test-kernel") - return real_open(file, *args, **kwargs) - - return _fake_open - - # ============================================================================ # Fixtures # ============================================================================ @@ -162,7 +150,7 @@ class TestDetectAudioEnvironment: monkeypatch.setattr("hermes_constants.is_container", lambda: False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open)) + monkeypatch.setattr("tools.voice_mode.is_wsl", lambda: False) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() @@ -190,7 +178,7 @@ class TestDetectAudioEnvironment: monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open)) + monkeypatch.setattr("tools.voice_mode.is_wsl", lambda: False) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() @@ -198,7 +186,7 @@ class TestDetectAudioEnvironment: assert result["warnings"] == [] assert any("SSH" in n for n in result.get("notices", [])) - def test_wsl_without_pulse_blocks_voice(self, monkeypatch, tmp_path): + def test_wsl_without_pulse_blocks_voice(self, monkeypatch): """WSL without PULSE_SERVER should block voice mode.""" monkeypatch.delenv("SSH_CLIENT", raising=False) monkeypatch.delenv("SSH_TTY", raising=False) @@ -208,18 +196,10 @@ class TestDetectAudioEnvironment: monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - proc_version = tmp_path / "proc_version" - proc_version.write_text("Linux 5.15.0-microsoft-standard-WSL2") + monkeypatch.setattr("tools.voice_mode.is_wsl", lambda: True) - _real_open = open - def _fake_open(f, *a, **kw): - if f == "/proc/version": - return _real_open(str(proc_version), *a, **kw) - return _real_open(f, *a, **kw) - - with patch("builtins.open", side_effect=_fake_open): - from tools.voice_mode import detect_audio_environment - result = detect_audio_environment() + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() assert result["available"] is False assert any("WSL" in w for w in result["warnings"]) @@ -1445,7 +1425,7 @@ class TestWSL2PowerShellFallback: m.wait = MagicMock(return_value=m.returncode) return m - with patch("tools.voice_mode._is_wsl2_env", return_value=True), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("tools.voice_mode._import_audio", side_effect=ImportError), \ patch("tools.voice_mode.shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("powershell.exe", "ffmpeg", "ffplay", "sh") else (x if x.startswith("/") else None)), \ @@ -1491,13 +1471,7 @@ class TestWSL2PowerShellFallback: return f"C:\\Temp\\{wsl_path.split('/')[-1]}\n".encode() return b"" - def _fake_open(path, *args, **kwargs): - if str(path) == "/proc/version": - import io - return io.StringIO("Linux Microsoft WSL2") - return open(path, *args, **kwargs) - - with patch("builtins.open", side_effect=_fake_open), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("powershell.exe", "ffmpeg", "ffplay") else None), \ patch("subprocess.check_output", side_effect=_capture_check_output), \ patch("subprocess.Popen", return_value=MagicMock(returncode=0, wait=lambda **k: 0)), \ @@ -1531,13 +1505,7 @@ class TestWSL2PowerShellFallback: m.wait.return_value = 0 return m - def _fake_open(path, *args, **kwargs): - if str(path) == "/proc/version": - import io - return io.StringIO("Linux version 5.15.0-generic #72-Ubuntu") - return open(path, *args, **kwargs) - - with patch("builtins.open", side_effect=_fake_open), \ + with patch("tools.voice_mode.is_wsl", return_value=False), \ patch("tools.voice_mode._import_audio", side_effect=ImportError), \ patch("shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("ffplay", "aplay") else None), \ patch("subprocess.Popen", side_effect=_capture_popen), \ @@ -1558,12 +1526,6 @@ class TestWSLAudioEnvironmentGate: not be hard-blocked, but the recording/STT PulseAudio-bridge guidance must still be surfaced (as a non-blocking notice).""" - def _fake_open_wsl(self, path, *args, **kwargs): - if str(path) == "/proc/version": - import io - return io.StringIO("Linux version 5.15 Microsoft Standard WSL2") - return open(path, *args, **kwargs) - def test_wsl_no_pulse_but_powershell_available_not_hard_blocked(self, monkeypatch): from unittest.mock import patch from tools import voice_mode as vm @@ -1574,7 +1536,7 @@ class TestWSLAudioEnvironmentGate: monkeypatch.delenv(_ssh_var, raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - with patch("builtins.open", side_effect=self._fake_open_wsl), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("tools.voice_mode._wsl_powershell_tts_available", return_value=True), \ patch("tools.voice_mode._pulse_socket_reachable", return_value=False), \ patch("hermes_constants.is_container", return_value=False): @@ -1601,7 +1563,7 @@ class TestWSLAudioEnvironmentGate: monkeypatch.delenv(_ssh_var, raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - with patch("builtins.open", side_effect=self._fake_open_wsl), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("tools.voice_mode._wsl_powershell_tts_available", return_value=False), \ patch("tools.voice_mode._pulse_socket_reachable", return_value=False), \ patch("hermes_constants.is_container", return_value=False): @@ -1622,7 +1584,7 @@ class TestWSLAudioEnvironmentGate: monkeypatch.delenv(_ssh_var, raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - with patch("builtins.open", side_effect=self._fake_open_wsl), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("hermes_constants.is_container", return_value=False): result = vm.detect_audio_environment() diff --git a/tests/tools/test_voice_wsl_pipewire.py b/tests/tools/test_voice_wsl_pipewire.py index 395d401fbf..cc832582af 100644 --- a/tests/tools/test_voice_wsl_pipewire.py +++ b/tests/tools/test_voice_wsl_pipewire.py @@ -4,22 +4,15 @@ detect_audio_environment() honors forwarded audio (has_forwarded_audio = PULSE_SERVER or PIPEWIRE_REMOTE or a reachable socket) in the SSH and container blocks, but the WSL block previously checked only PULSE_SERVER — so a WSL user with PipeWire forwarding (PIPEWIRE_REMOTE) was wrongly blocked from voice mode. -These tests mock /proc/version so they reproduce the WSL path on any host. +These tests patch the voice module's WSL predicate so they reproduce the WSL path on any host. """ -import builtins -import io from unittest.mock import MagicMock -WSL = "Linux version 5.15.0-microsoft-standard-WSL2 (oe-user@oe-host)" +def _force_wsl(monkeypatch): + import tools.voice_mode as voice_mode -def _force_wsl(monkeypatch, content=WSL): - real_open = builtins.open - def fake_open(file, *a, **k): - if str(file) == "/proc/version": - return io.StringIO(content) - return real_open(file, *a, **k) - monkeypatch.setattr(builtins, "open", fake_open) + monkeypatch.setattr(voice_mode, "is_wsl", lambda: True) def _base(monkeypatch): diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 62690dade2..ff5814b013 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -23,8 +23,9 @@ from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) -from tools.voice_mode_transcript import _voice_config, is_voice_stop_phrase, is_whisper_hallucination from hermes_constants import is_termux as _is_termux_environment +from hermes_platform.host.runtime import is_wsl +from tools.voice_mode_transcript import _voice_config, is_voice_stop_phrase, is_whisper_hallucination # ── Recording parameters ── SAMPLE_RATE = 16000 # Whisper native rate @@ -310,7 +311,7 @@ def detect_audio_environment() -> dict: # WSL: the PowerShell/Media.SoundPlayer fallback only covers OUTPUT, so when # it is all that's available downgrade to a notice (recording guidance stays # visible, TTS-only usage isn't blocked). - if _is_wsl2_env(): + if is_wsl(): if has_forwarded_audio: notices.append("Running in WSL with a reachable PulseAudio/PipeWire sound server") elif _wsl_powershell_tts_available(): @@ -961,20 +962,10 @@ def stop_playback() -> None: sd.stop() -def _is_wsl2_env() -> bool: - """True inside WSL (Microsoft kernel signature in /proc/version); False on any error. - Module-level so tests can patch it instead of ``builtins.open``.""" - try: - with open("/proc/version", encoding="utf-8", errors="replace") as _fv: - return "microsoft" in _fv.read().lower() - except OSError: - return False - - def _wsl_powershell_tts_available() -> bool: """WSL2 PowerShell TTS fallback usable. OUTPUT only (Media.SoundPlayer on the host) — recording still needs a PulseAudio bridge, so callers keep surfacing that guidance.""" - return bool(_is_wsl2_env() and shutil.which("powershell.exe") and shutil.which("ffmpeg")) + return bool(is_wsl() and shutil.which("powershell.exe") and shutil.which("ffmpeg")) def play_audio_file(file_path: str) -> bool: @@ -1000,7 +991,7 @@ def _play_wav_via_sounddevice(file_path: str) -> bool: # ~100 ms to stabilise and the small default blocksize worsens # clock-adjustment jitter (microsoft/wslg#1257). blocksize = 0 # default (auto) - if _is_wsl2_env(): + if is_wsl(): fade_samples = int(0.1 * sample_rate) audio_float = audio_data.astype(np.float64) audio_float[:fade_samples] *= np.linspace(0.0, 1.0, fade_samples, dtype=np.float64) @@ -1023,7 +1014,7 @@ def _wsl_powershell_player_cmd(file_path: str) -> Optional[List[str]]: ffplay/aplay have no device, but Media.SoundPlayer on the host does: convert to a uniquely-named WAV in Windows %TEMP% (concurrent TTS must not collide), play, always delete, and re-raise the ORIGINAL exit status past the cleanup (rm -f exits 0).""" - if not (shutil.which("powershell.exe") and shutil.which("ffmpeg") and _is_wsl2_env()): + if not (shutil.which("powershell.exe") and shutil.which("ffmpeg") and is_wsl()): return None try: import uuid