fix: allow voice mode in WSL when PulseAudio bridge is configured
WSL detection was treated as a hard fail, blocking voice mode even when audio worked via PulseAudio bridge. Now PULSE_SERVER env var presence makes WSL a soft notice instead of a blocking warning. Device query failures in WSL with PULSE_SERVER are also treated as non-blocking.
This commit is contained in:
@@ -56,6 +56,134 @@ def mock_sd(monkeypatch):
|
|||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# detect_audio_environment — WSL / SSH / Docker detection
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestDetectAudioEnvironment:
|
||||||
|
def test_clean_environment_is_available(self, monkeypatch):
|
||||||
|
"""No SSH, Docker, or WSL — should be available."""
|
||||||
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
monkeypatch.setattr("tools.voice_mode._import_audio",
|
||||||
|
lambda: (MagicMock(), MagicMock()))
|
||||||
|
|
||||||
|
from tools.voice_mode import detect_audio_environment
|
||||||
|
result = detect_audio_environment()
|
||||||
|
assert result["available"] is True
|
||||||
|
assert result["warnings"] == []
|
||||||
|
|
||||||
|
def test_ssh_blocks_voice(self, monkeypatch):
|
||||||
|
"""SSH environment should block voice mode."""
|
||||||
|
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
|
||||||
|
monkeypatch.setattr("tools.voice_mode._import_audio",
|
||||||
|
lambda: (MagicMock(), MagicMock()))
|
||||||
|
|
||||||
|
from tools.voice_mode import detect_audio_environment
|
||||||
|
result = detect_audio_environment()
|
||||||
|
assert result["available"] is False
|
||||||
|
assert any("SSH" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
def test_wsl_without_pulse_blocks_voice(self, monkeypatch, tmp_path):
|
||||||
|
"""WSL without PULSE_SERVER should block voice mode."""
|
||||||
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
monkeypatch.delenv("PULSE_SERVER", raising=False)
|
||||||
|
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")
|
||||||
|
|
||||||
|
_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()
|
||||||
|
|
||||||
|
assert result["available"] is False
|
||||||
|
assert any("WSL" in w for w in result["warnings"])
|
||||||
|
assert any("PulseAudio" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
def test_wsl_with_pulse_allows_voice(self, monkeypatch, tmp_path):
|
||||||
|
"""WSL with PULSE_SERVER set should NOT block voice mode."""
|
||||||
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
monkeypatch.setenv("PULSE_SERVER", "unix:/mnt/wslg/PulseServer")
|
||||||
|
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")
|
||||||
|
|
||||||
|
_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()
|
||||||
|
|
||||||
|
assert result["available"] is True
|
||||||
|
assert result["warnings"] == []
|
||||||
|
assert any("WSL" in n for n in result.get("notices", []))
|
||||||
|
|
||||||
|
def test_wsl_device_query_fails_with_pulse_continues(self, monkeypatch, tmp_path):
|
||||||
|
"""WSL device query failure should not block if PULSE_SERVER is set."""
|
||||||
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
monkeypatch.setenv("PULSE_SERVER", "unix:/mnt/wslg/PulseServer")
|
||||||
|
|
||||||
|
mock_sd = MagicMock()
|
||||||
|
mock_sd.query_devices.side_effect = Exception("device query failed")
|
||||||
|
monkeypatch.setattr("tools.voice_mode._import_audio",
|
||||||
|
lambda: (mock_sd, MagicMock()))
|
||||||
|
|
||||||
|
proc_version = tmp_path / "proc_version"
|
||||||
|
proc_version.write_text("Linux 5.15.0-microsoft-standard-WSL2")
|
||||||
|
|
||||||
|
_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()
|
||||||
|
|
||||||
|
assert result["available"] is True
|
||||||
|
assert any("device query failed" in n for n in result.get("notices", []))
|
||||||
|
|
||||||
|
def test_device_query_fails_without_pulse_blocks(self, monkeypatch):
|
||||||
|
"""Device query failure without PULSE_SERVER should block."""
|
||||||
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
monkeypatch.delenv("PULSE_SERVER", raising=False)
|
||||||
|
|
||||||
|
mock_sd = MagicMock()
|
||||||
|
mock_sd.query_devices.side_effect = Exception("device query failed")
|
||||||
|
monkeypatch.setattr("tools.voice_mode._import_audio",
|
||||||
|
lambda: (mock_sd, MagicMock()))
|
||||||
|
|
||||||
|
from tools.voice_mode import detect_audio_environment
|
||||||
|
result = detect_audio_environment()
|
||||||
|
|
||||||
|
assert result["available"] is False
|
||||||
|
assert any("PortAudio" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# check_voice_requirements
|
# check_voice_requirements
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ def _audio_available() -> bool:
|
|||||||
def detect_audio_environment() -> dict:
|
def detect_audio_environment() -> dict:
|
||||||
"""Detect if the current environment supports audio I/O.
|
"""Detect if the current environment supports audio I/O.
|
||||||
|
|
||||||
Returns dict with 'available' (bool) and 'warnings' (list of strings).
|
Returns dict with 'available' (bool), 'warnings' (list of hard-fail
|
||||||
|
reasons that block voice mode), and 'notices' (list of informational
|
||||||
|
messages that do NOT block voice mode).
|
||||||
"""
|
"""
|
||||||
warnings = []
|
warnings = [] # hard-fail: these block voice mode
|
||||||
|
notices = [] # informational: logged but don't block
|
||||||
|
|
||||||
# SSH detection
|
# SSH detection
|
||||||
if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):
|
if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):
|
||||||
@@ -63,11 +66,20 @@ def detect_audio_environment() -> dict:
|
|||||||
if os.path.exists('/.dockerenv'):
|
if os.path.exists('/.dockerenv'):
|
||||||
warnings.append("Running inside Docker container -- no audio devices")
|
warnings.append("Running inside Docker container -- no audio devices")
|
||||||
|
|
||||||
# WSL detection
|
# WSL detection — PulseAudio bridge makes audio work in WSL.
|
||||||
|
# Only block if PULSE_SERVER is not configured.
|
||||||
try:
|
try:
|
||||||
with open('/proc/version', 'r') as f:
|
with open('/proc/version', 'r') as f:
|
||||||
if 'microsoft' in f.read().lower():
|
if 'microsoft' in f.read().lower():
|
||||||
warnings.append("Running in WSL -- audio requires PulseAudio bridge to Windows")
|
if os.environ.get('PULSE_SERVER'):
|
||||||
|
notices.append("Running in WSL with PulseAudio bridge")
|
||||||
|
else:
|
||||||
|
warnings.append(
|
||||||
|
"Running in WSL -- audio requires PulseAudio bridge.\n"
|
||||||
|
" 1. Set PULSE_SERVER=unix:/mnt/wslg/PulseServer\n"
|
||||||
|
" 2. Create ~/.asoundrc pointing ALSA at PulseAudio\n"
|
||||||
|
" 3. Verify with: arecord -d 3 /tmp/test.wav && aplay /tmp/test.wav"
|
||||||
|
)
|
||||||
except (FileNotFoundError, PermissionError, OSError):
|
except (FileNotFoundError, PermissionError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -79,7 +91,12 @@ def detect_audio_environment() -> dict:
|
|||||||
if not devices:
|
if not devices:
|
||||||
warnings.append("No audio input/output devices detected")
|
warnings.append("No audio input/output devices detected")
|
||||||
except Exception:
|
except Exception:
|
||||||
warnings.append("Audio subsystem error (PortAudio cannot query devices)")
|
# In WSL with PulseAudio, device queries can fail even though
|
||||||
|
# recording/playback works fine. Don't block if PULSE_SERVER is set.
|
||||||
|
if os.environ.get('PULSE_SERVER'):
|
||||||
|
notices.append("Audio device query failed but PULSE_SERVER is set -- continuing")
|
||||||
|
else:
|
||||||
|
warnings.append("Audio subsystem error (PortAudio cannot query devices)")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
warnings.append("Audio libraries not installed (pip install sounddevice numpy)")
|
warnings.append("Audio libraries not installed (pip install sounddevice numpy)")
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -93,6 +110,7 @@ def detect_audio_environment() -> dict:
|
|||||||
return {
|
return {
|
||||||
"available": len(warnings) == 0,
|
"available": len(warnings) == 0,
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
|
"notices": notices,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -748,6 +766,8 @@ def check_voice_requirements() -> Dict[str, Any]:
|
|||||||
|
|
||||||
for warning in env_check["warnings"]:
|
for warning in env_check["warnings"]:
|
||||||
details_parts.append(f"Environment: {warning}")
|
details_parts.append(f"Environment: {warning}")
|
||||||
|
for notice in env_check.get("notices", []):
|
||||||
|
details_parts.append(f"Environment: {notice}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"available": available,
|
"available": available,
|
||||||
|
|||||||
Reference in New Issue
Block a user