mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
Fix desktop speech dependency setup (#574)
This commit is contained in:
@@ -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`:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 ..
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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::<serde_json::Value>(&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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+13
-3
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ function SelfHostedView() {
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Launch the API server to get the full UI in your browser:
|
||||
</p>
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra server\njarvis serve --port 8000"} />
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra desktop\njarvis serve --port 8000"} />
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
The chat, dashboard, energy profiling, and cost comparison all run
|
||||
locally on your machine.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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" }]
|
||||
|
||||
Reference in New Issue
Block a user