From d4eb6308b1d5d4f0d270ea659740f7a19584dfc6 Mon Sep 17 00:00:00 2001 From: Elliot Slusky <44592435+ElliotSlusky@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:08:24 -0700 Subject: [PATCH] Fix desktop speech dependency setup (#574) --- deploy/windows/README.md | 4 +- deploy/windows/install.ps1 | 10 +-- docs/downloads.md | 2 +- docs/getting-started/installation.md | 5 +- docs/getting-started/windows-native.md | 4 +- frontend/src-tauri/src/lib.rs | 30 +++++-- frontend/src/components/Chat/InputArea.tsx | 14 +++- frontend/src/lib/api.ts | 16 +++- frontend/src/pages/GetStartedPage.tsx | 2 +- pyproject.toml | 7 ++ scripts/quickstart.sh | 2 +- src/openjarvis/cli/doctor_cmd.py | 42 ++++++++++ src/openjarvis/server/api_routes.py | 26 +++++- src/openjarvis/speech/faster_whisper.py | 93 ++++++++++++++++++---- tests/cli/test_doctor_cmd.py | 53 ++++++++++++ tests/server/test_speech_routes.py | 26 ++++++ tests/speech/test_faster_whisper.py | 52 +++++++++++- uv.lock | 14 +++- 18 files changed, 355 insertions(+), 47 deletions(-) diff --git a/deploy/windows/README.md b/deploy/windows/README.md index 2a95a048..80a01cf7 100644 --- a/deploy/windows/README.md +++ b/deploy/windows/README.md @@ -20,7 +20,7 @@ What it does: 4. Installs `uv` (https://astral.sh/uv) if absent. 5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis` (override with `$env:OPENJARVIS_HOME`). -6. Runs `uv sync --extra server` so the FastAPI server entry point is +6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are importable. 7. Optionally prompts to register a scheduled task that auto-starts the server at logon. @@ -105,7 +105,7 @@ To pull the latest: ```powershell cd "$env:LOCALAPPDATA\OpenJarvis\src" git pull --ff-only -uv sync --extra server +uv sync --extra desktop ``` Or re-run the installer with `-Force`: diff --git a/deploy/windows/install.ps1 b/deploy/windows/install.ps1 index ee0a5a7d..5af627cb 100644 --- a/deploy/windows/install.ps1 +++ b/deploy/windows/install.ps1 @@ -16,8 +16,8 @@ 4. Install uv (https://astral.sh/uv) if absent. 5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis (override with $env:OPENJARVIS_HOME). - 6. Run `uv sync --extra server` so the FastAPI server entry point - is importable. + 6. Run `uv sync --extra desktop` so the FastAPI server and speech + backend are importable. 7. Optionally register the scheduled-task service (see deploy/windows/jarvis-service.ps1). @@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) { } # --------------------------------------------------------------------------- -# 6. uv sync --extra server +# 6. uv sync --extra desktop # --------------------------------------------------------------------------- -Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..." +Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..." Push-Location $srcDir try { - & $uvExe sync --extra server + & $uvExe sync --extra desktop if ($LASTEXITCODE -ne 0) { Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above." } diff --git a/docs/downloads.md b/docs/downloads.md index 3952d9ab..ab8279e9 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -109,7 +109,7 @@ If you prefer to run each step yourself: ```bash git clone https://github.com/open-jarvis/OpenJarvis.git cd OpenJarvis - uv sync --extra server + uv sync --extra desktop cd frontend && npm install && cd .. ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 7d702335..d62411eb 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -41,7 +41,7 @@ If you prefer to run each step yourself: ```bash git clone https://github.com/open-jarvis/OpenJarvis.git cd OpenJarvis - uv sync --extra server + uv sync --extra desktop uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml cd frontend && npm install && cd .. ``` @@ -278,6 +278,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight. | Extra | Install Command | Description | |-------|----------------|-------------| +| `desktop` | `uv sync --extra desktop` | Desktop/API server plus local speech input | | `server` | `uv sync --extra server` | OpenAI-compatible API server (`jarvis serve`) | | `dev` | `uv sync --extra dev` | Development and testing tools | | `docs` | `uv sync --extra docs` | Documentation build tools | @@ -285,7 +286,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight. Combine extras: ```bash -uv sync --extra server --extra memory-faiss --extra inference-cloud +uv sync --extra desktop --extra memory-faiss --extra inference-cloud ``` ## Setting Up an Inference Backend diff --git a/docs/getting-started/windows-native.md b/docs/getting-started/windows-native.md index a892320c..585a6a7c 100644 --- a/docs/getting-started/windows-native.md +++ b/docs/getting-started/windows-native.md @@ -8,7 +8,7 @@ avoid a Linux VM; WSL2 remains the smoother experience for most users. ## What you get - A PowerShell installer that probes prerequisites, installs `uv`, - clones the repo, and runs `uv sync --extra server`. + clones the repo, and runs `uv sync --extra desktop`. - An optional Windows scheduled-task service equivalent to the systemd unit and launchd plist. - Loopback default — the service binds `127.0.0.1` so no API key is @@ -38,7 +38,7 @@ The installer will: 4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell installer). 5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`. -6. Run `uv sync --extra server`. +6. Run `uv sync --extra desktop`. 7. Prompt to register the scheduled-task service (skip with `-SkipService`). diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index ebae60e9..37ffe5df 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -682,7 +682,7 @@ fn format_uv_sync_failure( format!( "`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\ Try opening a terminal in that directory and running \ - `uv sync --extra server` manually for the full output.", + `uv sync --extra desktop` manually for the full output.", root.display(), code, uv_sync_stderr_tail(stderr, 800), @@ -1143,7 +1143,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) { sync_cmd .args([ "sync", - "--extra", "server", + "--extra", "desktop", "--extra", "inference-cloud", "--extra", "inference-google", ]) @@ -1664,11 +1664,29 @@ async fn transcribe_audio( .send() .await .map_err(|e| format!("Connection failed: {}", e))?; - let body: serde_json::Value = resp - .json() + let status = resp.status(); + let body = resp + .text() .await .map_err(|e| format!("Invalid response: {}", e))?; - Ok(body) + if !status.is_success() { + let detail = serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value + .get("detail") + .and_then(|detail| detail.as_str()) + .map(str::to_string) + }) + .filter(|detail| !detail.is_empty()) + .unwrap_or(body); + return Err(format!( + "Transcription failed ({}): {}", + status.as_u16(), + detail + )); + } + serde_json::from_str(&body).map_err(|e| format!("Invalid response: {}", e)) } /// Submit savings to Supabase leaderboard. @@ -2556,7 +2574,7 @@ mod tests { assert!(msg.contains("exit 2")); assert!(msg.contains("/home/u/.openjarvis/src")); assert!(msg.contains("failed to resolve numpy==2.1.3")); - assert!(msg.contains("uv sync --extra server")); // actionable next step + assert!(msg.contains("uv sync --extra desktop")); // actionable next step } #[test] diff --git a/frontend/src/components/Chat/InputArea.tsx b/frontend/src/components/Chat/InputArea.tsx index 31fa20f7..fecaacb0 100644 --- a/frontend/src/components/Chat/InputArea.tsx +++ b/frontend/src/components/Chat/InputArea.tsx @@ -97,7 +97,13 @@ export function InputArea() { const setDeepResearch = useAppStore((s) => s.setDeepResearch); const corpusSync = useResearchCorpusSync(deepResearch); - const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech(); + const { + state: speechState, + error: speechError, + available: speechAvailable, + startRecording, + stopRecording, + } = useSpeech(); // Abort in-flight stream when the user switches models mid-generation. // This prevents errors from trying to continue a stream with a stale model. @@ -122,6 +128,12 @@ export function InputArea() { : streamState.isStreaming ? 'streaming' : undefined; + useEffect(() => { + if (speechError) { + toast.error(speechError, { duration: 8000 }); + } + }, [speechError]); + const handleMicClick = useCallback(async () => { if (speechState === 'recording') { try { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 19a8f67d..4a17bff1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -317,8 +317,9 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web audioData: Array.from(new Uint8Array(buffer)), filename, }); - } catch { - // Fall through to fetch + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(msg || 'Transcription failed'); } } const formData = new FormData(); @@ -327,7 +328,16 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web method: 'POST', body: formData, }); - if (!res.ok) throw new Error(`Transcription failed: ${res.status}`); + if (!res.ok) { + let detail = ""; + try { + const body = await res.json(); + detail = typeof body.detail === 'string' ? body.detail : ""; + } catch { + // Keep the status-only message below when the body is not JSON. + } + throw new Error(detail || `Transcription failed: ${res.status}`); + } return res.json(); } diff --git a/frontend/src/pages/GetStartedPage.tsx b/frontend/src/pages/GetStartedPage.tsx index 2468847a..69d3683d 100644 --- a/frontend/src/pages/GetStartedPage.tsx +++ b/frontend/src/pages/GetStartedPage.tsx @@ -417,7 +417,7 @@ function SelfHostedView() {

Launch the API server to get the full UI in your browser:

- +

The chat, dashboard, energy profiling, and cost comparison all run locally on your machine. diff --git a/pyproject.toml b/pyproject.toml index 92504c29..c0c0ee7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,13 @@ server = [ "pydantic>=2.0", "python-multipart>=0.0.9", ] +desktop = [ + "fastapi>=0.110", + "uvicorn>=0.30", + "pydantic>=2.0", + "python-multipart>=0.0.9", + "faster-whisper>=1.0", +] openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"] gpu-metrics = ["pynvml>=12.0"] energy-amd = ["amdsmi>=6.1"] diff --git a/scripts/quickstart.sh b/scripts/quickstart.sh index 7eb0d10a..d48134af 100755 --- a/scripts/quickstart.sh +++ b/scripts/quickstart.sh @@ -148,7 +148,7 @@ fi # ── 7. Install Python dependencies ────────────────────────────────── info "Installing Python dependencies..." -uv sync --extra server --quiet 2>/dev/null || uv sync --extra server +uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop ok "Python dependencies installed" # ── 7b. Build Rust extension ────────────────────────────────────── diff --git a/src/openjarvis/cli/doctor_cmd.py b/src/openjarvis/cli/doctor_cmd.py index 395ad12f..2c40ee61 100644 --- a/src/openjarvis/cli/doctor_cmd.py +++ b/src/openjarvis/cli/doctor_cmd.py @@ -223,6 +223,47 @@ def _check_optional_deps() -> List[CheckResult]: return results +def _check_speech_backend() -> CheckResult: + """Check whether the configured speech backend can load.""" + try: + from openjarvis.speech._discovery import get_speech_backend + + config = _get_config() + backend = get_speech_backend(config) + if backend is None: + return CheckResult( + "Speech backend", + "warn", + "Not configured", + details="Install desktop dependencies with `uv sync --extra desktop`.", + ) + + if backend.health(): + return CheckResult( + "Speech backend", + "ok", + f"{backend.backend_id} ready", + ) + + details = None + last_error = getattr(backend, "last_error", None) + if callable(last_error): + details = last_error() + return CheckResult( + "Speech backend", + "warn", + f"{backend.backend_id} unavailable", + details=details + or "Install desktop dependencies with `uv sync --extra desktop`.", + ) + except Exception as exc: + return CheckResult( + "Speech backend", + "warn", + f"Could not check: {exc}", + ) + + def _check_security_profile() -> CheckResult: """Check if a security profile is configured.""" try: @@ -306,6 +347,7 @@ def _run_all_checks() -> List[CheckResult]: checks.extend(_check_models()) checks.append(_check_default_model()) checks.extend(_check_optional_deps()) + checks.append(_check_speech_backend()) checks.append(_check_nodejs()) checks.append(_check_security_profile()) return checks diff --git a/src/openjarvis/server/api_routes.py b/src/openjarvis/server/api_routes.py index 0344e1bb..3f046346 100644 --- a/src/openjarvis/server/api_routes.py +++ b/src/openjarvis/server/api_routes.py @@ -893,7 +893,15 @@ async def transcribe_speech(request: Request): filename = getattr(audio_file, "filename", "audio.wav") ext = filename.rsplit(".", 1)[-1] if "." in filename else "wav" - result = backend.transcribe(audio_bytes, format=ext, language=language or None) + try: + result = backend.transcribe(audio_bytes, format=ext, language=language or None) + except Exception as exc: + logger.exception("Speech transcription failed") + raise HTTPException( + status_code=500, + detail=f"Speech transcription failed: {exc}", + ) from exc + return { "text": result.text, "language": result.language, @@ -908,9 +916,23 @@ async def speech_health(request: Request): backend = getattr(request.app.state, "speech_backend", None) if backend is None: return {"available": False, "reason": "No speech backend configured"} + try: + available = backend.health() + reason = None + except Exception as exc: + logger.exception("Speech health check failed") + available = False + reason = str(exc) + + if not available and reason is None: + last_error = getattr(backend, "last_error", None) + if callable(last_error): + reason = last_error() + return { - "available": backend.health(), + "available": available, "backend": backend.backend_id, + **({"reason": reason} if reason else {}), } diff --git a/src/openjarvis/speech/faster_whisper.py b/src/openjarvis/speech/faster_whisper.py index a1ef5fcc..e3728ec5 100644 --- a/src/openjarvis/speech/faster_whisper.py +++ b/src/openjarvis/speech/faster_whisper.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import tempfile from typing import List, Optional @@ -13,6 +14,13 @@ try: except ImportError: WhisperModel = None # type: ignore[assignment, misc] +try: + import ctranslate2 +except ImportError: + ctranslate2 = None # type: ignore[assignment] + +logger = logging.getLogger(__name__) + @SpeechRegistry.register("faster-whisper") class FasterWhisperBackend(SpeechBackend): @@ -30,20 +38,60 @@ class FasterWhisperBackend(SpeechBackend): self._device = device self._compute_type = compute_type self._model: Optional[WhisperModel] = None + self._last_error: Optional[str] = None + + def _resolve_compute_type(self) -> str: + """Pick a CTranslate2 compute type supported by the configured device.""" + if ctranslate2 is None: + return self._compute_type + + try: + supported = set(ctranslate2.get_supported_compute_types(self._device)) + except Exception as exc: + logger.debug( + "Could not inspect CTranslate2 compute types for %s: %s", + self._device, + exc, + ) + return self._compute_type + + if self._compute_type in supported: + return self._compute_type + + preferences = ( + ("int8", "float32", "int8_float32", "int16") + if self._compute_type == "float16" + else ("float32", "int8", "int8_float32", "int16") + ) + fallback = next((value for value in preferences if value in supported), None) + if fallback is None: + return self._compute_type + + logger.warning( + "CTranslate2 compute_type=%r is not supported on device=%r; " + "using %r instead", + self._compute_type, + self._device, + fallback, + ) + return fallback def _ensure_model(self) -> WhisperModel: """Lazy-load the Whisper model on first use.""" if self._model is None: if WhisperModel is None: - raise ImportError( + self._last_error = ( "faster-whisper is not installed. " - "Install with: uv sync --extra speech" + "Install with: uv sync --extra desktop" ) + raise ImportError(self._last_error) + compute_type = self._resolve_compute_type() self._model = WhisperModel( self._model_size, device=self._device, - compute_type=self._compute_type, + compute_type=compute_type, ) + self._last_error = None return self._model def transcribe( @@ -54,20 +102,24 @@ class FasterWhisperBackend(SpeechBackend): language: Optional[str] = None, ) -> TranscriptionResult: """Transcribe audio bytes using Faster-Whisper.""" - model = self._ensure_model() + try: + model = self._ensure_model() - # Write audio to a temp file (faster-whisper needs a file path) - suffix = f".{format}" if not format.startswith(".") else format - with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp: - tmp.write(audio) - tmp.flush() + # Write audio to a temp file (faster-whisper needs a file path) + suffix = f".{format}" if not format.startswith(".") else format + with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp: + tmp.write(audio) + tmp.flush() - kwargs = {} - if language: - kwargs["language"] = language + kwargs = {} + if language: + kwargs["language"] = language - segments_iter, info = model.transcribe(tmp.name, **kwargs) - segments_list = list(segments_iter) + segments_iter, info = model.transcribe(tmp.name, **kwargs) + segments_list = list(segments_iter) + except Exception as exc: + self._last_error = str(exc) + raise # Build result text = "".join(seg.text for seg in segments_list).strip() @@ -81,6 +133,7 @@ class FasterWhisperBackend(SpeechBackend): for seg in segments_list ] + self._last_error = None return TranscriptionResult( text=text, language=getattr(info, "language", None), @@ -91,9 +144,17 @@ class FasterWhisperBackend(SpeechBackend): def health(self) -> bool: """Check if model is loaded or loadable.""" - if self._model is not None: + try: + self._ensure_model() return True - return WhisperModel is not None + except Exception as exc: + self._last_error = str(exc) + logger.debug("Faster-Whisper health check failed: %s", exc) + return False + + def last_error(self) -> Optional[str]: + """Return the last model load or transcription error, if any.""" + return self._last_error def supported_formats(self) -> List[str]: """Supported audio formats (same as ffmpeg/Whisper).""" diff --git a/tests/cli/test_doctor_cmd.py b/tests/cli/test_doctor_cmd.py index 2c06c4f3..dcc26941 100644 --- a/tests/cli/test_doctor_cmd.py +++ b/tests/cli/test_doctor_cmd.py @@ -10,10 +10,12 @@ from click.testing import CliRunner from openjarvis.cli import cli from openjarvis.cli.doctor_cmd import ( + CheckResult, _check_config_exists, _check_default_model, _check_nodejs, _check_python_version, + _check_speech_backend, ) @@ -39,6 +41,10 @@ class TestDoctorRuns: ), patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]), patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]), + patch( + "openjarvis.cli.doctor_cmd._check_speech_backend", + return_value=CheckResult("Speech backend", "ok", "mock ready"), + ), ): result = CliRunner().invoke(cli, ["doctor"]) assert result.exit_code == 0 @@ -59,6 +65,10 @@ class TestDoctorJsonOutput: ), patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]), patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]), + patch( + "openjarvis.cli.doctor_cmd._check_speech_backend", + return_value=CheckResult("Speech backend", "ok", "mock ready"), + ), ): result = CliRunner().invoke(cli, ["doctor", "--json"]) assert result.exit_code == 0 @@ -142,6 +152,49 @@ class TestCheckDefaultModel: assert "auto" in result.message.lower() +class TestCheckSpeechBackend: + def test_check_speech_backend_ready(self) -> None: + backend = MagicMock() + backend.backend_id = "faster-whisper" + backend.health.return_value = True + + with patch( + "openjarvis.speech._discovery.get_speech_backend", + return_value=backend, + ): + result = _check_speech_backend() + + assert result.status == "ok" + assert "faster-whisper" in result.message + + def test_check_speech_backend_reports_load_error(self) -> None: + backend = MagicMock() + backend.backend_id = "faster-whisper" + backend.health.return_value = False + backend.last_error.return_value = "missing cublas64_12.dll" + + with patch( + "openjarvis.speech._discovery.get_speech_backend", + return_value=backend, + ): + result = _check_speech_backend() + + assert result.status == "warn" + assert "faster-whisper unavailable" in result.message + assert result.details == "missing cublas64_12.dll" + + def test_check_speech_backend_missing_uses_desktop_hint(self) -> None: + with patch( + "openjarvis.speech._discovery.get_speech_backend", + return_value=None, + ): + result = _check_speech_backend() + + assert result.status == "warn" + assert result.details is not None + assert "uv sync --extra desktop" in result.details + + class TestCheckNodejs: def test_check_nodejs_found(self) -> None: """Node.js check reports version when node is available.""" diff --git a/tests/server/test_speech_routes.py b/tests/server/test_speech_routes.py index f4dfd00a..8e0dd311 100644 --- a/tests/server/test_speech_routes.py +++ b/tests/server/test_speech_routes.py @@ -56,6 +56,18 @@ def test_transcribe_endpoint(client, mock_speech_backend): assert data["duration_seconds"] == 1.5 +def test_transcribe_endpoint_surfaces_backend_error(client, mock_speech_backend): + mock_speech_backend.transcribe.side_effect = RuntimeError("missing cublas64_12.dll") + + response = client.post( + "/v1/speech/transcribe", + files={"file": ("test.wav", b"fake audio data", "audio/wav")}, + ) + + assert response.status_code == 500 + assert "missing cublas64_12.dll" in response.json()["detail"] + + def test_transcribe_no_file(client): response = client.post("/v1/speech/transcribe") assert response.status_code == 400 or response.status_code == 422 @@ -69,6 +81,20 @@ def test_health_endpoint(client): assert data["backend"] == "mock" +def test_health_endpoint_includes_unavailable_reason(client, mock_speech_backend): + mock_speech_backend.health.return_value = False + mock_speech_backend.last_error.return_value = ( + "Install with: uv sync --extra desktop" + ) + + response = client.get("/v1/speech/health") + + assert response.status_code == 200 + data = response.json() + assert data["available"] is False + assert data["reason"] == "Install with: uv sync --extra desktop" + + def test_health_no_backend(): from fastapi import FastAPI from fastapi.testclient import TestClient diff --git a/tests/speech/test_faster_whisper.py b/tests/speech/test_faster_whisper.py index 4a463671..ea5bfa6d 100644 --- a/tests/speech/test_faster_whisper.py +++ b/tests/speech/test_faster_whisper.py @@ -53,17 +53,61 @@ def test_faster_whisper_transcribe(): assert result.duration_seconds == 1.5 +def test_faster_whisper_falls_back_from_unsupported_float16(): + mock_model = MagicMock() + + with ( + patch( + "openjarvis.speech.faster_whisper.WhisperModel", + return_value=mock_model, + ) as mock_whisper, + patch( + "openjarvis.speech.faster_whisper.ctranslate2", + MagicMock( + get_supported_compute_types=MagicMock(return_value={"float32", "int8"}) + ), + ), + ): + backend = FasterWhisperBackend( + model_size="base", + device="cpu", + compute_type="float16", + ) + assert backend._ensure_model() is mock_model + + mock_whisper.assert_called_once_with("base", device="cpu", compute_type="int8") + + +def test_faster_whisper_missing_dependency_hint_uses_desktop_extra(): + with patch("openjarvis.speech.faster_whisper.WhisperModel", new=None): + backend = FasterWhisperBackend() + + with pytest.raises(ImportError) as excinfo: + backend._ensure_model() + + assert "uv sync --extra desktop" in str(excinfo.value) + assert "uv sync --extra speech" not in str(excinfo.value) + + def test_faster_whisper_health_no_model(): """Health returns False before model is loaded.""" with patch( "openjarvis.speech.faster_whisper.WhisperModel", new=None, ): - from openjarvis.speech.faster_whisper import FasterWhisperBackend - - backend = FasterWhisperBackend.__new__(FasterWhisperBackend) - backend._model = None + backend = FasterWhisperBackend() assert backend.health() is False + assert "uv sync --extra desktop" in (backend.last_error() or "") + + +def test_faster_whisper_health_captures_load_error(): + with patch( + "openjarvis.speech.faster_whisper.WhisperModel", + side_effect=RuntimeError("missing cublas64_12.dll"), + ): + backend = FasterWhisperBackend() + assert backend.health() is False + assert "missing cublas64_12.dll" in (backend.last_error() or "") def test_faster_whisper_supported_formats(): diff --git a/uv.lock b/uv.lock index 7b13cff4..2d90b26b 100644 --- a/uv.lock +++ b/uv.lock @@ -5065,6 +5065,13 @@ docs = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, ] +desktop = [ + { name = "fastapi" }, + { name = "faster-whisper" }, + { name = "pydantic" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] energy-all = [ { name = "amdsmi" }, { name = "nvidia-ml-py" }, @@ -5200,7 +5207,9 @@ requires-dist = [ { name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0" }, { name = "dspy", marker = "extra == 'learning-dspy'", specifier = ">=2.6" }, { name = "faiss-cpu", marker = "extra == 'memory-faiss'", specifier = ">=1.7" }, + { name = "fastapi", marker = "extra == 'desktop'", specifier = ">=0.110" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110" }, + { name = "faster-whisper", marker = "extra == 'desktop'", specifier = ">=1.0" }, { name = "faster-whisper", marker = "extra == 'speech'", specifier = ">=1.0" }, { name = "gepa", marker = "extra == 'learning-gepa'", specifier = ">=0.1" }, { name = "google-api-python-client", marker = "extra == 'channel-gmail'", specifier = ">=2.0" }, @@ -5237,6 +5246,7 @@ requires-dist = [ { name = "posthog", specifier = ">=3.0" }, { name = "praw", marker = "extra == 'channel-reddit'", specifier = ">=7.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "pydantic", marker = "extra == 'desktop'", specifier = ">=2.0" }, { name = "pydantic", marker = "extra == 'server'", specifier = ">=2.0" }, { name = "pygemma", marker = "extra == 'inference-gemma'", specifier = ">=0.1.3" }, { name = "pymessenger", marker = "extra == 'channel-messenger'", specifier = ">=0.0.7" }, @@ -5244,6 +5254,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5" }, + { name = "python-multipart", marker = "extra == 'desktop'", specifier = ">=0.0.9" }, { name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.9" }, { name = "python-telegram-bot", specifier = ">=22.6" }, { name = "python-telegram-bot", marker = "extra == 'channel-telegram'", specifier = ">=21.0" }, @@ -5264,6 +5275,7 @@ requires-dist = [ { name = "transformers", marker = "extra == 'orchestrator-training'", specifier = ">=4.40" }, { name = "twilio", marker = "extra == 'channel-twilio'", specifier = ">=9.0" }, { name = "twitchio", marker = "extra == 'channel-twitch'", specifier = ">=2.6" }, + { name = "uvicorn", marker = "extra == 'desktop'", specifier = ">=0.30" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30" }, { name = "viberbot", marker = "extra == 'channel-viber'", specifier = ">=1.0" }, { name = "vllm", marker = "extra == 'inference-vllm'", specifier = ">=0.16.0" }, @@ -5274,7 +5286,7 @@ requires-dist = [ { name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" }, { name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" }, ] -provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"] +provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "desktop", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"] [package.metadata.requires-dev] dev = [{ name = "maturin", specifier = ">=1.12.6" }]