Skip to content

test: add evaluation coverage and pre-commit tooling - #1033

Open
Alexendros wants to merge 3 commits into
jamiepine:mainfrom
Alexendros:fix/whisper-chunking-vram-tada
Open

test: add evaluation coverage and pre-commit tooling#1033
Alexendros wants to merge 3 commits into
jamiepine:mainfrom
Alexendros:fix/whisper-chunking-vram-tada

Conversation

@Alexendros

@Alexendros Alexendros commented Aug 10, 2026

Copy link
Copy Markdown

Summary

  • implement real post-generation unloading for TTS, STT, and LLM backends, including CUDA/MPS cache cleanup
  • make the MCP endpoint accept both /mcp and /mcp/ without redirecting POST requests
  • add CI-safe regression coverage, opt-in Whisper/TTS GPU evaluation, backend/dictation harnesses, and evaluation documentation
  • add Husky + lint-staged + Prettier pre-commit checks
  • disable WebKitGTK DMA-BUF rendering by default on Linux while respecting explicit user overrides

Verification

  • bun run typecheck
  • bunx lint-staged
  • python3 -m pytest backend/tests/test_fixture_contract.py -q ✅ (2 passed)
  • Ruff checks for new tests ✅
  • Python compilation ✅
  • Bash syntax checks ✅
  • scripts/test_backend_detection.sh --case B/C
  • scripts/test_dictate_e2e.sh ✅ (24 passed)

The full backend suite and real GPU WER/VRAM thresholds require the project backend environment and local model/GPU dependencies; those are intentionally not downloaded by this PR.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Whisper transcription for long audio by processing recordings in 30-second segments.
    • MCP connections now work with both /mcp and /mcp/ endpoint formats.
    • Reduced GPU memory retention by unloading models after generation.
    • Improved long TADA speech generation by applying safer audio chunk limits.
    • Improved Linux application compatibility by setting a safer WebKit rendering configuration when needed.
  • Documentation

    • Added evaluation and setup guidance for transcription, dictation, MCP, GPU memory, and backend detection.

Alexendros and others added 3 commits August 10, 2026 03:37
…ption

Whisper's encoder only accepts 30s of audio per pass (3000 mel frames).
Feeding longer audio in a single processor+generate call silently
transcribed just the first 30 seconds. Repro: a 6:43 FLAC produced only
the first two sentences.

- Split input PCM into 30s chunks at 16kHz and join decoded segments
- Force return_timestamps=True: without it Whisper intermittently emits
  an early <|endoftext|> after the first sentence of a window,
  truncating multi-sentence chunks (observed on medium and turbo)
- Load model weights in bfloat16 to halve VRAM on modern GPUs
  (avoids float16 c10::Half dtype bug on RTX 50-series)
- Move inputs to model dtype to prevent float/bias dtype mismatch

Tests: backend/tests/test_whisper_chunking.py covers single-chunk audio,
multi-chunk joining, the timestamps flag and language forcing (mocked,
no GPU needed).
Two GPU-stability fixes on 8 GB cards (e.g. RTX 5060 Laptop):

- After each generation, unload_all_models() (TTS+STT+LLM + empty CUDA
  cache) instead of only the engine used. Whisper stayed resident and
  chained tada generations died with CUDA OOM (7.4/7.5 GiB in use).
- tada degenerates on long inputs (produces 1s of audio or hallucinated
  text). GenerationRequest defaults max_chunk_chars=800, so
  effective_max_chunk_chars() caps tada at 250 chars per chunk unless
  the caller explicitly requests smaller.

Repro: 650-char Spanish paragraph via /speak engine=tada returned a
1.0s clip containing only 'y'; with the cap it yields full 33s speech.

Tests: backend/tests/test_tada_chunk_cap.py covers the cap, the 800
default bypass and per-engine passthrough.
Add regression coverage for model unloading and MCP mount paths, opt-in GPU/E2E evaluation harnesses, and Husky pre-commit checks for the desktop repository.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request adds backend model cleanup, TADA chunk limits, Whisper long-audio chunking, MCP slash compatibility, test fixtures, evaluation harnesses, developer tooling, and Linux WebKit initialization.

Changes

Backend reliability and evaluation

Layer / File(s) Summary
Developer validation workflow
.husky/pre-commit, .lintstagedrc, .prettierrc, package.json, justfile, backend/tests/conftest.py, backend/tests/fixtures/*, backend/tests/test_fixture_contract.py, CHANGELOG.md
Added formatting, pre-commit, pytest, fixture-generation, and cross-platform evaluation commands.
Evaluation harnesses and platform setup
scripts/*, docs/evaluation/*, tauri/src-tauri/src/main.rs, backend/tests/fixtures/README.md
Added backend-detection and dictation harnesses, evaluation documentation, fixture guidance, and Linux WebKit startup initialization.
MCP mount slash compatibility
.mcp.json, backend/app.py, backend/mcp_server/*, backend/tests/test_mcp_mount_slashes.py, docs/evaluation/mcp-trailing-slash.md
Added root-path rewriting for mounted MCP requests and updated MCP endpoint examples and regression tests.
Generation limits and model cleanup
backend/backends/__init__.py, backend/services/generation.py, backend/tests/test_generation_unload.py, backend/tests/test_tada_chunk_cap.py, backend/tests/test_tts_vram_churn.py, docs/evaluation/{tada-chunk-cap,vram-unload}.md
Added resilient backend unloading, TADA chunk-size resolution, generation cleanup, and lifecycle regression and GPU tests.
Whisper long-audio processing
backend/backends/pytorch_backend.py, backend/tests/test_whisper_chunking.py, backend/tests/test_whisper_long_audio_e2e.py, docs/evaluation/whisper-chunking.md
Changed Whisper loading and transcription to use bfloat16 and 30-second timestamped chunks, with unit and GPU test coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the added evaluation coverage and pre-commit tooling, which are major parts of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/backends/pytorch_backend.py`:
- Around line 376-403: Serialize model lifecycle operations with transcription
by adding or reusing one shared guard around model loading, unloading, and the
entire chunk-inference loop. Update the transcription path to retain stable
model and processor references while the guard is held, and make unload_model()
wait for active transcriptions to complete before clearing them.
- Around line 302-305: Update _load_model_sync to query _get_device before
loading Whisper and resolve a supported torch dtype per device instead of
hard-coding torch.bfloat16. Add a dedicated device-specific dtype resolver with
a tested Whisper fallback, ensuring DirectML, XPU, CPU, and fallback CPU avoid
unsupported bfloat16 while preserving bfloat16 only where supported.

In `@backend/mcp_server/server.py`:
- Around line 41-47: Update the bare-mount normalization in the HTTP scope
handling to rewrite only scope["path"] to "/" while preserving the original
scope["raw_path"] value. Remove the raw_path assignment from this block and
leave the surrounding scope-copy behavior unchanged.

In `@backend/services/generation.py`:
- Around line 331-339: Move TTS model cleanup out of the setup `finally` and
into a `finally` enclosing synchronous inference and WAV encoding, so cleanup
occurs only after `generate_chunked()` completes. Reuse the local `tts_model`
instance for the loaded check and unload operation, while keeping
`bg_db.close()` in the existing setup cleanup. Add a regression test covering
the synchronous persist=false speech path and confirming inference runs before
unloading.

In `@backend/tests/conftest.py`:
- Around line 49-56: Update the _read helper to catch FileNotFoundError and
subprocess.CalledProcessError from subprocess.run, then call pytest.skip with a
clear message when nvidia-smi is unavailable or fails. Preserve the existing
parsing and integer return behavior when the command succeeds.
- Around line 117-127: Update the exception handling around _wait_for_health in
live_backend so that failures after the backend has been spawned are reported as
test failures rather than skipped. Preserve pytest.skip only for the
subprocess.Popen precondition failure, while retaining the existing process
cleanup and server-log-tail diagnostics for health-check errors.

In `@backend/tests/fixtures/generate_fixtures.sh`:
- Around line 73-80: Update the fixture generation flow around the long `ffmpeg`
invocation for `cv_6m43s.wav` to validate the generated WAV duration is at least
403 seconds after encoding. Make the script exit nonzero when the duration is
shorter, using the repository’s available media-inspection tooling, while
preserving the existing 10-second and 30-second fixture generation.

In `@docs/evaluation/whisper-chunking.md`:
- Around line 30-32: Update the WER verification description in
whisper-chunking.md to accurately describe
backend/tests/test_whisper_long_audio_e2e.py as a transcription smoke test,
unless the test is enhanced with reference text and an explicit WER threshold.
Do not claim WER quality-matrix validation while the test only checks non-empty
transcription output and audio duration.

In `@justfile`:
- Around line 337-352: Update the initial pytest marker expression in the
eval-local recipe to exclude gpu tests alongside e2e and slow tests. Keep the
separate GPU=1 conditional suite unchanged so GPU tests run only through that
gated path and are not executed twice.

In `@scripts/test_dictate_e2e.sh`:
- Around line 157-172: Update the test harness around run_dictate to create and
expose a controlled pw-record stub before the host binaries in PATH, matching
the existing arecord stub’s SIGTERM handling and WAV-output behavior. Adjust
recorder-related assertions to accept whichever recorder, arecord or pw-record,
the installed dictate script selects while preserving controlled failure and
output checks.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 07285b11-63ae-482a-bd24-429e834546e4

📥 Commits

Reviewing files that changed from the base of the PR and between 51f49de and 5b1e962.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (33)
  • .husky/pre-commit
  • .lintstagedrc
  • .mcp.json
  • .prettierrc
  • CHANGELOG.md
  • backend/app.py
  • backend/backends/__init__.py
  • backend/backends/pytorch_backend.py
  • backend/mcp_server/README.md
  • backend/mcp_server/server.py
  • backend/services/generation.py
  • backend/tests/conftest.py
  • backend/tests/fixtures/README.md
  • backend/tests/fixtures/generate_fixtures.sh
  • backend/tests/test_fixture_contract.py
  • backend/tests/test_generation_unload.py
  • backend/tests/test_mcp_mount_slashes.py
  • backend/tests/test_tada_chunk_cap.py
  • backend/tests/test_tts_vram_churn.py
  • backend/tests/test_whisper_chunking.py
  • backend/tests/test_whisper_long_audio_e2e.py
  • docs/evaluation/TEMPLATE.md
  • docs/evaluation/appimage-backend-detection.md
  • docs/evaluation/local-setup.md
  • docs/evaluation/mcp-trailing-slash.md
  • docs/evaluation/tada-chunk-cap.md
  • docs/evaluation/vram-unload.md
  • docs/evaluation/whisper-chunking.md
  • justfile
  • package.json
  • scripts/test_backend_detection.sh
  • scripts/test_dictate_e2e.sh
  • tauri/src-tauri/src/main.rs

Comment on lines +302 to +305
self.model = WhisperForConditionalGeneration.from_pretrained(
model_name,
torch_dtype=torch.bfloat16,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

According to the official PyTorch and torch-directml documentation for the versions used by this repository, which of CUDA, MPS, DirectML, CPU, and XPU support loading and running Whisper in torch.bfloat16?

💡 Result:

Support for loading and running the Whisper model in torch.bfloat16 varies by device, depending on hardware capabilities and PyTorch backend implementation. CUDA: Support depends on the underlying GPU architecture. NVIDIA GPUs with Compute Capability 8.0 or higher (e.g., Ampere architecture and newer) support bfloat16 natively. While PyTorch's torch.bfloat16 can be used on these devices, Whisper's specific implementation (as provided by openai-whisper) is primarily optimized for float16 inference [1], and using bfloat16 may require manual conversion and handling of input features to match the model's dtype [2]. CPU: PyTorch supports bfloat16 on CPUs, particularly on Intel Xeon processors with AVX-512_BF16 or AMX support [3]. There is community-driven support to enable Whisper with bfloat16 on Intel hardware [4]. MPS (Metal Performance Shaders): PyTorch support for bfloat16 on MPS is limited and generally requires newer macOS versions (e.g., macOS 14.0+) [5]. While some operations might function, it is not broadly supported for full model execution for all architectures. DirectML: The torch-directml backend does not natively support bfloat16 for most operations [6][7]. It is primarily focused on supporting standard operators in float32 and float16 (where available), and users frequently encounter issues when attempting to use specialized dtypes or autocasting with this backend [8][6]. XPU: PyTorch provides explicit support for bfloat16 on XPU devices [9], leveraging Intel's hardware acceleration for these types [3]. Summary Table Device | bfloat16 Support | Notes --- | --- | --- CUDA | Yes | Requires Ampere (CC 8.0) or newer. CPU | Yes | Requires instruction set support (e.g., AVX-512_BF16/AMX). MPS | Limited | Requires macOS 14.0+; support is platform-dependent. DirectML | No | Generally not supported by the current torch-directml implementation. XPU | Yes | Supported via Intel extensions/PyTorch XPU. For all devices, note that Whisper's default inference code is designed for float32 or float16; running in bfloat16 often necessitates manual casting of the model and all input tensors to ensure compatibility [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^backend/backends/pytorch_backend\.py$|requirements|pyproject|setup|poetry|environment)' || true

echo
echo "== pytorch_backend outline =="
ast-grep outline backend/backends/pytorch_backend.py --view compact || true

echo
echo "== relevant lines 260-330 =="
sed -n '260,330p' backend/backends/pytorch_backend.py | nl -ba -v260

echo
echo "== device/get dtype references =="
rg -n "from_pretrained|torch_dtype|_get_device|get_device|device|bfloat16|torch\.|mps|cuda|xpu|directml|DirectML" backend/backends/pytorch_backend.py backend -S

echo
echo "== package version hints =="
for f in $(git ls-files | rg '(^|/)(requirements.*\.txt|pyproject\.toml|setup\.py|setup\.cfg|Pipfile|poetry\.lock|environment\.ya?ml)$'); do
  echo "--- $f"
  rg -n "torch|transformers|whisper|huggingface_hub|directml|xpu|mkl|xla" "$f" -i || true
done

Repository: jamiepine/voicebox

Length of output: 719


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant pytorch_backend lines =="
awk '{printf "%6d\t%s\n", NR, $0}' backend/backends/pytorch_backend.py | sed -n '240,335p'

echo
echo "== device/dtype/import references in pytorch_backend.py =="
rg -n "from_pretrained|torch_dtype|_get_device|get_device|device_id|get_device_id|device|xpu|mps|cuda|DirectML|directml|torch\b" backend/backends/pytorch_backend.py -S

echo
echo "== device implementation candidates =="
rg -n "class .*Device|def _get_device|def get_device|device_id|device_id" backend/backends/pytorch_backend.py backend/services backend -S

echo
echo "== dependency version hints =="
for f in backend/pyproject.toml backend/requirements.txt requirements.txt; do
  if [ -f "$f" ]; then
    echo "--- $f"
    rg -n "torch|transformers|openai-whisper|huggingface_hub|directml|xpu|xla" "$f" -i || true
  fi
done

Repository: jamiepine/voicebox

Length of output: 9508


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== utils containing get_torch_device =="
rg -n "def get_torch_device|allow_xpu|allow_directml|mps|mkl|torch_directml|is_bf16_supported|torch\.bfloat16" backend -S

echo
echo "== file containing get_torch_device with line numbers =="
f="$(rg -l "def get_torch_device" backend || true)"
if [ -n "${f:-}" ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' "$f" | sed -n '1,220p'
fi

Repository: jamiepine/voicebox

Length of output: 17141


Query the device before requesting torch.bfloat16.

_get_device() can return DirectML, XPU, CPU, or fallback CPU when MPS is unavailable, but _load_model_sync() hard-codes torch_dtype=torch.bfloat16. Add a device-specific dtype resolver with a tested fallback for Whisper, especially for DirectML where torch.bfloat16 is not generally supported by the backend.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/backends/pytorch_backend.py` around lines 302 - 305, Update
_load_model_sync to query _get_device before loading Whisper and resolve a
supported torch dtype per device instead of hard-coding torch.bfloat16. Add a
dedicated device-specific dtype resolver with a tested Whisper fallback,
ensuring DirectML, XPU, CPU, and fallback CPU avoid unsupported bfloat16 while
preserving bfloat16 only where supported.

Comment on lines +376 to +403
transcriptions = []
for chunk in chunks:
# Process audio
inputs = self.processor(
chunk,
sampling_rate=16000,
return_tensors="pt",
)
# Mover al device y convertir al mismo dtype que el modelo para
# evitar el error "Input type (float) and bias type (...) should be
# the same" en GPUs como la RTX 5060.
inputs = inputs.to(device=self.device, dtype=self.model.dtype)

with torch.no_grad():
predicted_ids = self.model.generate(
inputs["input_features"],
**generate_kwargs,
)

# Decode
transcriptions.append(
self.processor.batch_decode(
predicted_ids,
skip_special_tokens=True,
)[0]
)

# Decode
transcription = self.processor.batch_decode(
predicted_ids,
skip_special_tokens=True,
)[0]

return transcription.strip()
return " ".join(t.strip() for t in transcriptions).strip()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Serialize model unload with active transcription.

The loop repeatedly reads self.model and self.processor without retaining stable references or preventing unload_model(). backend/services/generation.py unloads all models after generation. If it overlaps a long transcription, a later chunk can access None and fail. The new multi-chunk path increases this exposure window.

Protect model load, unload, and inference with one shared lifecycle guard. Make unload_model() wait until active transcriptions finish.

Based on learnings: serialize model swap with in-flight operations because reload/unload can race inference references.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/backends/pytorch_backend.py` around lines 376 - 403, Serialize model
lifecycle operations with transcription by adding or reusing one shared guard
around model loading, unloading, and the entire chunk-inference loop. Update the
transcription path to retain stable model and processor references while the
guard is held, and make unload_model() wait for active transcriptions to
complete before clearing them.

Source: Learnings

Comment on lines +41 to +47
if scope["type"] == "http" and scope.get("path") == "":
scope = dict(scope)
scope["path"] = "/"
# Keep path and raw_path coherent for ASGI routers that inspect
# both fields (Starlette normally supplies raw_path from the
# original request, e.g. b"/mcp").
scope["raw_path"] = b"/"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
if [ -f backend/mcp_server/server.py ]; then
  cat -n backend/mcp_server/server.py | sed -n '1,120p'
else
  echo "backend/mcp_server/server.py not found"
fi

echo
echo "== ASGI path/raw_path usages =="
rg -n --hidden --glob '!*.pyc' 'raw_path|scope\["path"\]|scope\["raw_path"\]|\["raw_path"\]' backend || true

echo
echo "== repository dependencies mentioning starlette/fastmcp/ASGI (if present) =="
for f in pyproject.toml backend/pyproject.toml requirements.txt backend/requirements.txt uv.lock backend/uv.lock poetry.lock backend/poetry.lock; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n "starlette|fastmcp|asgi" "$f" || true
  fi
done

echo
echo "== current git status/diff summary =="
git diff --stat || true
git diff -- backend/mcp_server/server.py | sed -n '1,220p' || true

Repository: jamiepine/voicebox

Length of output: 5976


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import pathlib
import re

path = pathlib.Path("backend/mcp_server/server.py")
if not path.exists():
    print("backend/mcp_server/server.py missing")
    raise SystemExit

text = path.read_text()
m = re.search(r'def MountRootSlashRewrite\(app\):.*?(?=\ndef |\Z)', text, re.S)
print("MountRootSlashRewrite function present:", bool(m))
if m:
    body = m.group(0)
    print("contains scope copy:", bool(re.search(r'scope\s*=\s*dict\(scope\)', body)))
    print("contains path rewrite:", bool(re.search(r'scope\["path"\]\s*=\s*"/"', body)))
    print("contains raw_path rewrite:", bool(re.search(r'scope\["raw_path"\]\s*=\s*b"/"', body)))

# Deterministic probe of the exact transformation for a bare mounted request.
initial = {"type": "http", "path": "", "raw_path": b"/mcp"}
if initial["type"] == "http" and initial.get("path") == "":
    scope = dict(initial)
    scope["path"] = "/"
    scope["raw_path"] = b"/"
else:
    scope = initial

print("initial raw_path:", initial["raw_path"])
print("transformed raw_path:", scope["raw_path"])
print("child_match_path:", scope["path"])
PY

Repository: jamiepine/voicebox

Length of output: 275


🌐 Web query:

ASGI scope raw_path specification original path

💡 Result:

In the ASGI specification, raw_path is an optional field within the connection scope [1][2]. It is defined as a byte string representing the original HTTP path component exactly as it was received by the web server, without modification, unquoting, or percent-decoding [1][3]. Key characteristics of raw_path include: 1. Data Type: It is provided as a byte string, distinct from the path field, which is a Unicode string that has been fully decoded from both UTF-8 and percent-encoded sequences [1][2]. 2. Scope: It excludes the query string [1][3]. 3. Optionality: Because some web server implementations may be unable to provide the original raw bytes or may perform their own path normalization before the application layer is reached, raw_path is optional [1][4]. If a server cannot provide it, the field may be missing or set to None [1][3]. The addition of raw_path to the ASGI specification was intended to allow applications to access the path without the information loss that occurs when a server automatically performs percent-decoding on the standard path field [4][5]. However, developers should note that because intermediate proxies or the underlying web server might modify the path before it reaches the ASGI interface, the availability and exact content of raw_path can depend on the specific server implementation [1][5].

Citations:


Preserve the original ASGI raw_path.

For a bare mount request, raw_path is the unmodified client request path. Rewriting it to b"/" makes request inspection, logging, auditing, and signatures return a path the client did not send. Keep raw_path unchanged and rewrite only scope["path"] to / for the child application.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/server.py` around lines 41 - 47, Update the bare-mount
normalization in the HTTP scope handling to rewrite only scope["path"] to "/"
while preserving the original scope["raw_path"] value. Remove the raw_path
assignment from this block and leave the surrounding scope-copy behavior
unchanged.

Comment on lines 331 to +339
finally:
bg_db.close()
# Unload TTS model to free VRAM after speak
try:
tts_backend = get_tts_backend_for_engine(engine)
if tts_backend.is_loaded():
tts_backend.unload_model()
except Exception:
logger.warning("Failed to unload TTS model after speak", exc_info=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move TTS cleanup after synchronous inference.

This finally runs before generate_chunked() at lines 356-358. It unloads the loaded tts_model before audio generation starts. The synchronous persist=false speech path will then generate with an unloaded backend.

Keep bg_db.close() in the setup cleanup. Put model cleanup in a finally that encloses inference and WAV encoding. Reuse the local tts_model instance. Add a regression test for this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/generation.py` around lines 331 - 339, Move TTS model
cleanup out of the setup `finally` and into a `finally` enclosing synchronous
inference and WAV encoding, so cleanup occurs only after `generate_chunked()`
completes. Reuse the local `tts_model` instance for the loaded check and unload
operation, while keeping `bg_db.close()` in the existing setup cleanup. Add a
regression test covering the synchronous persist=false speech path and
confirming inference runs before unloading.

Comment thread backend/tests/conftest.py
Comment on lines +49 to +56
def _read() -> int:
out = subprocess.run(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
capture_output=True,
text=True,
check=True,
)
return int(out.stdout.strip().splitlines()[0])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)backend/tests/(conftest|test_tts_vram_churn)\.py$' || true

echo "== conftest excerpt =="
if [ -f backend/tests/conftest.py ]; then
  nl -ba backend/tests/conftest.py | sed -n '1,120p'
fi

echo "== test excerpt =="
if [ -f backend/tests/test_tts_vram_churn.py ]; then
  nl -ba backend/tests/test_tts_vram_churn.py | sed -n '1,120p'
fi

echo "== vram_mb usages =="
rg -n "vram_mb\(|nvidia-smi|pytest\.skip|skip" backend/tests || true

Repository: jamiepine/voicebox

Length of output: 294


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== conftest excerpt =="
if [ -f backend/tests/conftest.py ]; then
  cat -n backend/tests/conftest.py | sed -n '1,120p'
fi

echo "== test excerpt =="
if [ -f backend/tests/test_tts_vram_churn.py ]; then
  cat -n backend/tests/test_tts_vram_churn.py | sed -n '1,140p'
fi

echo "== vram_mb usages =="
grep -RInE "vram_mb\(|nvidia-smi|pytest\.skip|skip|skipif|marked|gpu" backend/tests || true

echo "== behavioral probe of vram_sm subprocess failure semantics =="
python3 - <<'PY'
import subprocess
def vram_mb():
    out = subprocess.run(
        ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
        capture_output=True,
        text=True,
        check=True,
    )
    return int(out.stdout.strip().splitlines()[0])
try:
    vram_mb()
except Exception as e:
    print(type(e).__name__, repr(str(e)))
PY

Repository: jamiepine/voicebox

Length of output: 14648


Skip VRAM checks when nvidia-smi is unavailable or fails.

backend/tests/test_tts_vram_churn.py:72, 77 calls vram_mb(), but backend/tests/conftest.py:49-56 raises on missing nvidia-smi or subprocess errors. Wrap the subprocess.run(..., check=True) call, catch FileNotFoundError and subprocess.CalledProcessError, and call pytest.skip with a clear message.

🧰 Tools
🪛 ast-grep (0.45.0)

[error] 49-54: Command coming from incoming request
Context: subprocess.run(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
capture_output=True,
text=True,
check=True,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/conftest.py` around lines 49 - 56, Update the _read helper to
catch FileNotFoundError and subprocess.CalledProcessError from subprocess.run,
then call pytest.skip with a clear message when nvidia-smi is unavailable or
fails. Preserve the existing parsing and integer return behavior when the
command succeeds.

Comment thread backend/tests/conftest.py
Comment on lines +117 to +127
try:
_wait_for_health(base_url, proc, timeout=HEALTH_TIMEOUT)
except Exception as exc:
proc.kill()
proc.wait(timeout=10)
log_fh.close()
with contextlib.suppress(OSError):
tail = "\n".join(log_path.read_text(errors="replace").splitlines()[-40:])
if "tail" not in locals():
tail = ""
pytest.skip(f"backend did not become healthy: {exc}\n--- server log tail ---\n{tail}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate conftest =="
fd -a 'conftest.py$' . | sed 's#^\./##'

echo "== backend/tests/conftest.py outline =="
if [ -f backend/tests/conftest.py ]; then
  wc -l backend/tests/conftest.py
  ast-grep outline backend/tests/conftest.py 2>/dev/null | sed -n '1,200p' || true
  echo "== relevant conftest section =="
  sed -n '1,220p' backend/tests/conftest.py
fi

echo "== health/failure fixture usages =="
if [ -f backend/tests/conftest.py ]; then
  rg -n "backend_health|_wait_for_health|pytest\.skip|pytest\.fail|subprocess\.Popen|HEALTH_TIMEOUT" backend/tests backend || true
fi

Repository: jamiepine/voicebox

Length of output: 8164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== live_backend usages in backend/tests =="
rg -n "def .*\(.*live_backend|live_backend|e2e|backend did not become healthy|could not spawn backend process" backend/tests backend/tests/conftest.py

echo "== test file sections likely receiving fixture =="
for f in backend/tests/test_tts_vram_churn.py backend/tests/test_whisper_long_audio_e2e.py backend/tests/test_all_models_e2e.py; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n "def .*\(.*live_backend|live_backend|`@pytest`|`@mark`" "$f" || true
    sed -n '1,120p' "$f"
  fi
done

echo "== pytest skip/fail semantics =="
python3 - <<'PY'
try:
    import pytest
except Exception as exc:
    print(f"pytest unavailable: {type(exc).__name__}: {exc}")
else:
    print(f"pytest available: {pytest.__version__}")
PY

Repository: jamiepine/voicebox

Length of output: 14884


🌐 Web query:

Pytest skip vs fail documentation pytest.skip test skips pytest.fail test fails

💡 Result:

In pytest, the primary difference between skip and fail lies in whether the test is executed and how the test suite's outcome is reported [1][2]. pytest.skip (or @pytest.mark.skip) Skipping a test means that the test is not executed at all [1][2]. It is used when you expect a test to be relevant only under specific conditions (e.g., specific platforms or availability of external resources) [1][3]. Because the test is not run, it does not contribute to the "fail" count of your test suite, keeping the overall suite "green" [1][4]. You can call pytest.skip imperatively within a test or setup phase, or use the @pytest.mark.skip or @pytest.mark.skipif decorators to skip tests declaratively [5][4]. pytest.fail Calling pytest.fail explicitly forces an executing test to fail [5][6]. Unlike skipping, where the test is bypassed, pytest.fail triggers an immediate failure and is typically used within test logic to signal that a specific condition or assertion has been met which should cause the test to fail [6]. This will be counted as a failure in your test summary and will affect the exit status of your test suite [5]. Note on xfail While not the same as skipping or failing, pytest provides the xfail (expected fail) mechanism [1][7]. Tests marked with @pytest.mark.xfail are executed, but they are expected to fail; if they do fail as expected, they are reported as XFAIL (not a test suite failure) [1][2]. If they unexpectedly pass, they are reported as XPASS [1][7]. Unlike skip, the test code is still run [2].

Citations:


Fail when the spawned backend does not become healthy.

live_backend is an e2e fixture used by GPU-backed E2E tests. A subprocess.Popen() failure is a precondition, so pytest.skip() is appropriate only there. Once the backend is spawned, a health-check timeout or early process exit should report a failure to avoid masking live backend regressions.

Proposed fix
-        pytest.skip(f"backend did not become healthy: {exc}\n--- server log tail ---\n{tail}")
+        pytest.fail(f"backend did not become healthy: {exc}\n--- server log tail ---\n{tail}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
_wait_for_health(base_url, proc, timeout=HEALTH_TIMEOUT)
except Exception as exc:
proc.kill()
proc.wait(timeout=10)
log_fh.close()
with contextlib.suppress(OSError):
tail = "\n".join(log_path.read_text(errors="replace").splitlines()[-40:])
if "tail" not in locals():
tail = ""
pytest.skip(f"backend did not become healthy: {exc}\n--- server log tail ---\n{tail}")
try:
_wait_for_health(base_url, proc, timeout=HEALTH_TIMEOUT)
except Exception as exc:
proc.kill()
proc.wait(timeout=10)
log_fh.close()
with contextlib.suppress(OSError):
tail = "\n".join(log_path.read_text(errors="replace").splitlines()[-40:])
if "tail" not in locals():
tail = ""
pytest.fail(f"backend did not become healthy: {exc}\n--- server log tail ---\n{tail}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/conftest.py` around lines 117 - 127, Update the exception
handling around _wait_for_health in live_backend so that failures after the
backend has been spawned are reported as test failures rather than skipped.
Preserve pytest.skip only for the subprocess.Popen precondition failure, while
retaining the existing process cleanup and server-log-tail diagnostics for
health-check errors.

Comment on lines +73 to +80
# The concat stream is longer than 403 seconds; -t makes all three outputs
# exactly 6:43 (or fails rather than silently producing a short fixture).
ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "$concat_list" \
-t 10 -ac 1 -ar 16000 "$OUT_DIR/cv_10s.wav"
ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "$concat_list" \
-t 30 -ac 1 -ar 16000 "$OUT_DIR/cv_30s.wav"
ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "$concat_list" \
-t 403 -ac 1 -ar 16000 "$OUT_DIR/cv_6m43s.wav"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)backend/tests/fixtures/generate_fixtures\.sh$|backend/tests/test_whisper_long_audio_e2e\.py$' || true

echo
echo "== generate fixture relevant section =="
if [ -f backend/tests/fixtures/generate_fixtures.sh ]; then
  wc -l backend/tests/fixtures/generate_fixtures.sh
  sed -n '1,140p' backend/tests/fixtures/generate_fixtures.sh | cat -n
fi

echo
echo "== e2e test relevant section =="
if [ -f backend/tests/test_whisper_long_audio_e2e.py ]; then
  wc -l backend/tests/test_whisper_long_audio_e2e.py
  sed -n '1,120p' backend/tests/test_whisper_long_audio_e2e.py | cat -n
fi

echo
echo "== search fixture duration usages =="
rg -n "cv_6m43s|whisper_long_audio|400|403|duration|duration" backend/tests tests . -g '!node_modules' -g '!dist' -g '!build' || true

Repository: jamiepine/voicebox

Length of output: 50374


🌐 Web query:

FFmpeg duration option -t output shorter input exits normally documentation

💡 Result:

When using the -t option in FFmpeg, the software is designed to stop processing as soon as the specified duration limit is reached, regardless of whether the input file is shorter or longer than that duration [1][2]. If the input file is shorter than the duration specified with -t, FFmpeg will reach the end of the input file before the duration is met. In this scenario, FFmpeg will exit normally [1][2][3]. It does not throw an error simply because the input ended prematurely; rather, it completes the task based on the available input data and terminates the process as expected [1][2]. To summarize: - Input shorter than -t duration: FFmpeg reads until the end of the file and exits normally [1][2]. - Input longer than -t duration: FFmpeg stops reading the input and writing the output once the specified -t duration is reached [1][2]. The -t option is a limit on the duration of data read (when used as an input option before -i) or the duration of the output file (when used as an output option) [1][4]. It is distinct from the -shortest option, which instructs FFmpeg to stop muxing at the end of the shortest available input stream [5][6].

Citations:


Validate the generated long-fixture duration.

ffmpeg -t 403 only caps how long FFmpeg writes; if the concat input is shorter than 403 seconds, FFmpeg exits normally and produces a short cv_6m43s.wav. This can still satisfy the downstream E2E pass condition, which only asserts duration >= 400.0. Fail the fixture script if the output is below 403 seconds, or keep adding normalized clips until the concat input is long enough.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/fixtures/generate_fixtures.sh` around lines 73 - 80, Update the
fixture generation flow around the long `ffmpeg` invocation for `cv_6m43s.wav`
to validate the generated WAV duration is at least 403 seconds after encoding.
Make the script exit nonzero when the duration is shorter, using the
repository’s available media-inspection tooling, while preserving the existing
10-second and 30-second fixture generation.

Comment on lines +30 to +32
La matriz de calidad WER para audio real de 6:43, formatos WAV/FLAC/MP3 y
carga en frío/caliente requiere `pytest -m gpu` con fixtures locales generadas
por `backend/tests/fixtures/generate_fixtures.sh`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the WER verification claim.

backend/tests/test_whisper_long_audio_e2e.py does not calculate WER or compare against reference text. It only checks non-empty text and audio duration. Either add a reference transcript with a WER threshold or describe this command as a transcription smoke test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/evaluation/whisper-chunking.md` around lines 30 - 32, Update the WER
verification description in whisper-chunking.md to accurately describe
backend/tests/test_whisper_long_audio_e2e.py as a transcription smoke test,
unless the test is enhanced with reference text and an explicit WER threshold.
Do not claim WER quality-matrix validation while the test only checks non-empty
transcription output and audio duration.

Comment thread justfile
Comment on lines +337 to +352
# Run local evaluation harnesses; GPU/model tests remain opt-in via --gpu.
[unix]
eval-local: _ensure-venv
#!/usr/bin/env bash
set -euo pipefail
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m "not e2e and not slow" -v
bash scripts/test_backend_detection.sh
if [[ -f "${VOICEBOX_DICTATE_SCRIPT:-$HOME/.local/bin/voicebox-dictate.sh}" ]]; then
VOICEBOX_DICTATE_SCRIPT="${VOICEBOX_DICTATE_SCRIPT:-$HOME/.local/bin/voicebox-dictate.sh}" bash scripts/test_dictate_e2e.sh
else
echo "Skipping dictation harness: no installed script found."
fi
if [[ "${GPU:-0}" == "1" ]]; then
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m gpu -v
else
echo "Skipping GPU suite. Re-run with GPU=1 after model/fixture setup."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exclude GPU tests from the initial eval-local run.

Line 342 selects every test except e2e and slow. It therefore runs gpu tests before the GPU=1 gate. On GPU hosts, GPU=1 runs the GPU suite twice. Add not gpu to the initial marker expression.

Proposed fix
-    {{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m "not e2e and not slow" -v
+    {{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m "not e2e and not slow and not gpu" -v
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Run local evaluation harnesses; GPU/model tests remain opt-in via --gpu.
[unix]
eval-local: _ensure-venv
#!/usr/bin/env bash
set -euo pipefail
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m "not e2e and not slow" -v
bash scripts/test_backend_detection.sh
if [[ -f "${VOICEBOX_DICTATE_SCRIPT:-$HOME/.local/bin/voicebox-dictate.sh}" ]]; then
VOICEBOX_DICTATE_SCRIPT="${VOICEBOX_DICTATE_SCRIPT:-$HOME/.local/bin/voicebox-dictate.sh}" bash scripts/test_dictate_e2e.sh
else
echo "Skipping dictation harness: no installed script found."
fi
if [[ "${GPU:-0}" == "1" ]]; then
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m gpu -v
else
echo "Skipping GPU suite. Re-run with GPU=1 after model/fixture setup."
# Run local evaluation harnesses; GPU/model tests remain opt-in via --gpu.
[unix]
eval-local: _ensure-venv
#!/usr/bin/env bash
set -euo pipefail
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m "not e2e and not slow and not gpu" -v
bash scripts/test_backend_detection.sh
if [[ -f "${VOICEBOX_DICTATE_SCRIPT:-$HOME/.local/bin/voicebox-dictate.sh}" ]]; then
VOICEBOX_DICTATE_SCRIPT="${VOICEBOX_DICTATE_SCRIPT:-$HOME/.local/bin/voicebox-dictate.sh}" bash scripts/test_dictate_e2e.sh
else
echo "Skipping dictation harness: no installed script found."
fi
if [[ "${GPU:-0}" == "1" ]]; then
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -m gpu -v
else
echo "Skipping GPU suite. Re-run with GPU=1 after model/fixture setup."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@justfile` around lines 337 - 352, Update the initial pytest marker expression
in the eval-local recipe to exclude gpu tests alongside e2e and slow tests. Keep
the separate GPU=1 conditional suite unchanged so GPU tests run only through
that gated path and are not executed twice.

Comment on lines +157 to +172
# Runs the installed dictate script in a fully controlled environment.
run_dictate() {
local rc=0
env -i \
HOME="$FAKE_HOME" \
PATH="$STUB_DIR:/usr/bin:/bin" \
WAYLAND_DISPLAY="wayland-vbtest" \
CALLS_LOG="$CALLS_LOG" \
CLIP_FILE="$CLIP_FILE" \
VB_FIXTURE_WAV="$FIXTURE" \
VB_STUB_ARECORD_FAIL="${VB_STUB_ARECORD_FAIL:-0}" \
VB_STUB_ARECORD_NOWAV="${VB_STUB_ARECORD_NOWAV:-0}" \
VB_STUB_CURL_FAIL="${VB_STUB_CURL_FAIL:-0}" \
VB_STUB_WLCOPY_FAIL="${VB_STUB_WLCOPY_FAIL:-0}" \
VB_STUB_YDOTOOL_FAIL="${VB_STUB_YDOTOOL_FAIL:-0}" \
"$DICTATE_SCRIPT" >/dev/null 2>&1 || rc=$?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Stub pw-record before exposing host binaries.

The isolated PATH includes /usr/bin, but the harness only stubs arecord. If the installed script selects pw-record, it can invoke the host recorder and access real audio hardware. Add a pw-record stub with the same controlled SIGTERM and WAV behavior, then make recorder assertions accept the selected recorder.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test_dictate_e2e.sh` around lines 157 - 172, Update the test harness
around run_dictate to create and expose a controlled pw-record stub before the
host binaries in PATH, matching the existing arecord stub’s SIGTERM handling and
WAV-output behavior. Adjust recorder-related assertions to accept whichever
recorder, arecord or pw-record, the installed dictate script selects while
preserving controlled failure and output checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant