Skip to content

feat: Scalar API docs, community health files, Quickstart cards - #41

Merged
debpalash merged 3 commits into
mainfrom
feat/scalar-docs-community-health
May 4, 2026
Merged

feat: Scalar API docs, community health files, Quickstart cards#41
debpalash merged 3 commits into
mainfrom
feat/scalar-docs-community-health

Conversation

@debpalash

@debpalash debpalash commented May 3, 2026

Copy link
Copy Markdown
Owner

Summary

Adds Scalar-powered API documentation, GitHub community health files, and README improvements.

Changes

Backend

  • Scalar API docs at /docs — replaces default Swagger UI with modern, interactive Scalar reference
  • OpenAI-compatible /v1/audio endpoints (openai_compat router)
  • TTS streaming endpoint (tts_stream router)
  • Voice marketplace router scaffold
  • scalar-fastapi added to dependencies + lockfile

Community Health (GitHub tabs)

  • SECURITY.md — vulnerability reporting policy (Security tab)
  • CODE_OF_CONDUCT.md — Contributor Covenant v2.1 (Community tab)
  • .github/FUNDING.yml — GitHub Sponsors button
  • .github/ISSUE_TEMPLATE/ — bug report + feature request templates
  • .github/pull_request_template.md — PR checklist with version-sync + no-local-paths rules

README

  • Quickstart redesigned as 3-column progressive cards (Desktop → Docker → Source)
  • Docker section updated with GHCR docker pull instructions
  • API Docs row added to service table (localhost:3900/docs)

Frontend

  • CaptureWidget, WaveformTimeline, layout refinements
  • CSS polish

Testing

  • pytest: 193 passed, 3 skipped, 10 xfailed (100s)
  • node:test: 36 passed (182ms)
  • tsc --noEmit: clean

Type

  • ✨ New feature
  • 📝 Documentation

Checklist

  • Tested locally
  • Documentation updated
  • No local machine paths, logs, or personal env details
  • Version files in sync

Summary by CodeRabbit

  • New Features

    • Voice marketplace: export/import/publish/browse/install/delete voice bundles
    • OpenAI-compatible audio API: TTS, STT, and voice listing
    • Ultra-low-latency streaming TTS via WebSocket
    • New TTS engines with a voice‑design generation mode
    • Compact Capture pill UI with hotkey/tray controls, auto‑dismiss, clipboard paste, and autostart
  • Documentation

    • Expanded README, security policy, code of conduct, and issue/PR templates
  • Chores

    • Funding configured and ignore list updated; packaging/dependency build updates

… Docker

Backend:
- Replace Swagger UI with Scalar at /docs (scalar-fastapi)
- Add OpenAI-compatible /v1/audio endpoints (openai_compat router)
- Add TTS streaming endpoint (tts_stream router)
- Add voice marketplace router (marketplace)
- Update TTS backend registry

Frontend:
- Refine CaptureWidget, WaveformTimeline, App layout
- CSS polish and index.css updates

Community health:
- SECURITY.md — vulnerability reporting policy
- CODE_OF_CONDUCT.md — Contributor Covenant v2.1
- .github/FUNDING.yml — GitHub Sponsors
- .github/ISSUE_TEMPLATE/ — bug report + feature request
- .github/pull_request_template.md — PR checklist

README:
- Quickstart redesigned as 3-column progressive cards
- Docker section updated with GHCR pull instructions
- API Docs row added to service table

Infra:
- scalar-fastapi added to pyproject.toml + uv.lock
- research/ added to .gitignore
@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds community and governance files; implements OpenAI-compatible HTTP audio APIs (TTS, STT, voices), a WebSocket streaming TTS endpoint, a local voice-profile marketplace, new TTS backend adapters and wiring, a compact “capture pill” UI with Tauri autostart/shortcut support, and assorted docs/config updates.

Changes

Community & Repo Metadata

Layer / File(s) Summary
Repo Metadata
.github/FUNDING.yml
Enable GitHub Sponsors for debpalash.
Governance & Templates
CODE_OF_CONDUCT.md, SECURITY.md, .github/ISSUE_TEMPLATE/bug_report.md, .github/ISSUE_TEMPLATE/feature_request.md, .github/pull_request_template.md
Add Contributor Covenant code of conduct, security policy, bug/feature issue templates, and PR template.
Docs & Repo Config
README.md, .gitignore, docs/*
Revise README quickstart and desktop docs; add marketing.md to .gitignore; update release/build docs (docs/DESKTOP_RELEASE.md, docs/desktop-build.md, docs/STRUCTURE.md).
Build / Dependencies
pyproject.toml
Add scalar-fastapi dependency for custom docs integration.

Audio APIs, Marketplace, TTS Backends, and Capture UI

Layer / File(s) Summary
Data / Schemas
backend/api/routers/marketplace.py, backend/api/routers/openai_compat.py, backend/services/tts_backend.py
Define .omnivoice ZIP bundle format and bundle constants; add OpenAI-compatible Pydantic schemas (SpeechRequest, transcription responses); extend TTS backend signature to accept description and capability flag.
Core Implementations
backend/services/tts_backend.py
Implement voice-design mode in VoxCPM2Backend; add and register three new adapters: IndexTTS2Backend, GPTSoVITSBackend, SherpaOnnxBackend.
API Endpoints / Logic
backend/api/routers/openai_compat.py, backend/api/routers/tts_stream.py, backend/api/routers/marketplace.py
Add OpenAI-compatible endpoints: POST /v1/audio/speech (TTS), POST /v1/audio/transcriptions (STT), GET /v1/audio/voices; add WebSocket /ws/tts streaming TTS with PCM16 chunk frames; implement marketplace endpoints for export/import/publish/browse/install/delete .omnivoice bundles (in-memory export, persisted marketplace dir, DB registration, event emission).
Integration / App Wiring
backend/main.py
Register openai_compat, tts_stream, and marketplace routers; disable built-in docs (docs_url=None, redoc_url=None) and add GET /docs custom endpoint; reduce httpx logging verbosity.
Frontend Capture & Tauri Integration
frontend/src/components/CaptureWidget.jsx, frontend/src/components/CaptureWidget.css, frontend/src/index.css, frontend/src-tauri/src/lib.rs, frontend/src-tauri/src/commands.rs, frontend/src-tauri/tauri.conf.json, frontend/src-tauri/Cargo.toml
Refactor capture UI into compact status-only “pill” with clipboard + Tauri simulate_paste, WS-first recording with EOF messaging and HTTP fallback, auto-dismiss behavior and onDismiss callback; add Tauri --pill mode handling, pill autostart IPC commands (enable/disable/is_enabled) with OS-specific implementations, add dirs-next dependency, adjust widget window sizing/centering and CSP.
Frontend: Media/Playback Cleanup
frontend/src/components/WaveformTimeline.jsx
Enhance WaveSurfer teardown: pause, cancel in-flight audio fetch, and clear media element src before destroy.
Frontend: UX Wiring
frontend/src/main-app.jsx, frontend/src/App.jsx, frontend/src/store/pillSlice.ts
Load CaptureWidget in Suspense (null fallback), small placeholder/ comment updates.
Utilities & Operational Tweaks
backend/api/routers/capture_ws.py, backend/api/routers/setup/download.py, backend/services/ffmpeg_utils.py, backend/utils/hf_progress.py
Increase capture partial-buffer threshold (MIN_BUFFER_BYTES 16k→64k) and add MIN_FINAL_BUFFER_BYTES; make _chunks_to_wav fall back to raw WebM on ffmpeg failure; add per-repo_id install cooldown (60s) returning HTTP 429 during cooldown; tighten imageio-ffmpeg candidate verification and lower noisy logs; remove attribution line from hf_progress docstring.

Sequence Diagram

sequenceDiagram
  participant Client as Client (web / SDK)
  participant API as FastAPI (/v1 & /ws/tts)
  participant GPU as GPU pool / TTS backends
  participant DBFS as DB & Filesystem

  Client->>API: POST /v1/audio/speech or open WS /ws/tts (StreamTTSRequest)
  API->>DBFS: Resolve voice/profile (ref audio, ref text, metadata)
  API->>GPU: Submit generation job (text + description/ref + params)
  GPU-->>API: Return waveform tensor
  API->>DBFS: Read/write profile audio or publish bundle (marketplace)
  API-->>Client: Stream encoded audio (HTTP) or chunked PCM16 frames (WebSocket) with start/done messages
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I stitched a bundle, zipped and neat,

Voices bundled, metadata sweet.
Streams that hum and pills that pop,
Send a WS ping — the bytes don’t stop.
The rabbit hops off, trailing code and beat.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.54% 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 'feat: Scalar API docs, community health files, Quickstart cards' accurately summarizes the main changes: addition of Scalar API documentation, community health/governance files, and UI redesign, matching the primary scope across backend, docs, and repository configuration.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scalar-docs-community-health

Review rate limit: 9/10 reviews remaining, refill in 6 minutes.

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

@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: 9

🧹 Nitpick comments (1)
frontend/src/index.css (1)

2361-2378: ⚡ Quick win

Add reduced-motion handling for the new overlay animations.

The new fade/scale animations should be disabled for users with reduced-motion preferences.

Proposed fix
 `@keyframes` capture-ov-panel {
   from { opacity: 0; transform: translateY(20px) scale(0.92); }
   to   { opacity: 1; transform: translateY(0) scale(1); }
 }
+
+@media (prefers-reduced-motion: reduce) {
+  .capture-overlay,
+  .capture-overlay__panel {
+    animation: none !important;
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/index.css` around lines 2361 - 2378, Add reduced-motion support
by disabling the overlay animations when the user prefers reduced motion: wrap
rules for .capture-overlay and .capture-overlay__panel (which currently use
animation: capture-ov-fade and animation: capture-ov-panel) in a `@media`
(prefers-reduced-motion: reduce) block and set animation: none; and remove any
transform/transition by setting transform: none; and transition: none; (or
ensure overflow/visibility stays the same) so these elements render statically.
Leave keyframe definitions (capture-ov-fade, capture-ov-panel) intact but ensure
they are not applied under the reduced-motion media query.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/api/routers/marketplace.py`:
- Around line 138-165: Current code materializes the entire uploaded bundle via
file.read() and uses zf.read(...) for each member which can OOM; instead enforce
a hard max upload size (check file size from the incoming UploadFile/stream
before reading) and open the zip via zipfile.ZipFile(io.BytesIO(...)) only after
validating size or, better, avoid reading all bytes by saving a streamed temp
file or using file.file to seek/stream; then replace zf.read(name) calls (e.g.,
where metadata = json.loads(zf.read("metadata.json")) and where you write ref
audio into VOICES_DIR using ref_audio_filename/profile_id) with
ZipFile.open(name) and copy to disk via shutil.copyfileobj(zf.open(name),
dst_file) (and for metadata stream into a small buffer or parse with json.load
on the opened file) so entries are streamed and per-entry size checks are
applied; apply the same fix pattern to the other import path that also uses
zf.read() for members.

In `@backend/api/routers/openai_compat.py`:
- Around line 149-194: The _encode_audio function currently advertises the
requested format (fmt) even when it falls back to WAV/PCM/OGG bytes; change the
fallbacks so the returned mime type matches the actual bytes produced (e.g.,
when mp3 fallback writes WAV, return "audio/wav"; when opus fallback writes WAV,
return "audio/wav"; when AAC fallback writes WAV, return "audio/wav"), and
ensure callers use the returned mime/type to derive the download filename
(instead of blindly using req.response_format or fmt) so saved filenames (e.g.,
"speech.{ext}") match the actual content.
- Around line 274-323: The create_transcription handler currently ignores the
model parameter by always calling get_active_asr_backend(); update it to honor
model by mapping model=="whisper-1" to the active backend and other values
(e.g., "whisperx", "faster-whisper", "mlx-whisper", "pytorch-whisper") to their
specific backends via a selector function (either add/get or use an existing
get_asr_backend_by_name/get_backend(name) API) and call that backend's
transcribe; validate the model string, raise HTTPException(400) for unknown
backends, and keep the thread-pool call (loop.run_in_executor) using the
selected backend.transcribe so behavior and word_timestamps logic remain
unchanged.
- Around line 224-250: The current profile-resolution block tries to treat
req.voice as a profile ID but only sets kw["voice"] on exception; when the DB
query returns no row valid engine preset names are dropped — fix by forwarding
non-profile voices: inside the try block after the existing "if row:" handling
add an explicit else branch that sets kw["voice"] = voice (so when get_db/SELECT
succeeds but returns no row we preserve engine-specific preset names); keep the
existing exception handler that also sets kw["voice"] to cover DB errors. Ensure
you reference voice, req.voice, _OPENAI_VOICE_ALIASES, kw and row when making
the change.

In `@backend/api/routers/tts_stream.py`:
- Around line 149-171: The start frame is sent using backend.sample_rate before
lazy-loading occurs, so move sending the websocket.send_json start message until
after _generate() runs and real sample rate is known: run the generation via
loop.run_in_executor(_gpu_pool, _generate) to obtain wav (wav_tensor), read the
actual sample rate from the returned wav (or backend.sample_rate after
generation), then send the "start" JSON (using sample_rate, channels, format,
engine) before streaming PCM chunks; update references to backend.sample_rate,
_generate, wav_tensor, and websocket.send_json accordingly so the start frame
reflects the real sample rate.
- Around line 119-147: The DB lookup for voice profiles currently only sets
kw["voice"] in the exception handler, so when the query returns no row the
requested non-profile voice is never passed through; update the logic in the
voice resolution block (the code that calls get_db(), queries "SELECT * FROM
voice_profiles WHERE id=?", inspects row, and populates kw) so that if row is
falsy (no profile found) you explicitly set kw["voice"] = voice before leaving
the try block; keep the existing behavior that if a row exists you populate
kw["ref_audio"], kw["ref_text"], kw["instruct"], etc., and preserve the
exception handler that also sets kw["voice"] = voice.

In `@frontend/src/App.jsx`:
- Around line 2066-2073: The backdrop onClick directly calls
setShowCapture(false) which unmounts CaptureWidget immediately and can abort
active sessions; change this to invoke a close callback that lets CaptureWidget
finish cleanup (e.g., call a provided onRequestClose or onClose prop) or waits
for a cleanup promise before toggling visibility. Update the capture overlay
handler to call a new close handler (referencing setShowCapture and the
CaptureWidget component) and implement/consume an onRequestClose/onClose
callback on CaptureWidget that performs recording/transcription cleanup and only
then resolves so the parent can call setShowCapture(false).
- Around line 118-124: The global hotkey handler inside the useEffect (function
h) is toggling setShowCapture even when the CaptureWidget is already open,
causing a race with the widget's own hotkey handler; fix by guarding the handler
so it does nothing when the overlay is open or when the event originates from
inside the widget: add an early return in h (e.g., if (showCapture) return; or
if (event.target.closest && event.target.closest('#capture-widget')) return;)
and include showCapture in the useEffect dependency array so the handler sees
the current state; keep the existing key detection logic and ensure the effect
still adds/removes the same listener.

In `@frontend/src/index.css`:
- Around line 2351-2355: The overlay's z-index (selector .capture-overlay) is
too low and can be under elements like .rail-label (z-index: 10000); update
.capture-overlay to use a higher stacking value (e.g., >10000 such as 10001) or
switch to a top-layer CSS variable and apply that to .capture-overlay so the
modal reliably appears above floating chrome layers; ensure you update the
.capture-overlay rule (position: fixed; inset: 0; z-index: ...) accordingly.

---

Nitpick comments:
In `@frontend/src/index.css`:
- Around line 2361-2378: Add reduced-motion support by disabling the overlay
animations when the user prefers reduced motion: wrap rules for .capture-overlay
and .capture-overlay__panel (which currently use animation: capture-ov-fade and
animation: capture-ov-panel) in a `@media` (prefers-reduced-motion: reduce) block
and set animation: none; and remove any transform/transition by setting
transform: none; and transition: none; (or ensure overflow/visibility stays the
same) so these elements render statically. Leave keyframe definitions
(capture-ov-fade, capture-ov-panel) intact but ensure they are not applied under
the reduced-motion media query.
🪄 Autofix (Beta)

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: 4408bf9a-4d41-4a17-bc5a-4d75147f97de

📥 Commits

Reviewing files that changed from the base of the PR and between 9616ca6 and 8ae6189.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/FUNDING.yml
  • .github/ISSUE_TEMPLATE/bug_report.md
  • .github/ISSUE_TEMPLATE/feature_request.md
  • .github/pull_request_template.md
  • .gitignore
  • CODE_OF_CONDUCT.md
  • README.md
  • SECURITY.md
  • backend/api/routers/marketplace.py
  • backend/api/routers/openai_compat.py
  • backend/api/routers/tts_stream.py
  • backend/main.py
  • backend/services/tts_backend.py
  • frontend/src/App.jsx
  • frontend/src/components/CaptureWidget.jsx
  • frontend/src/components/WaveformTimeline.jsx
  • frontend/src/index.css
  • pyproject.toml

Comment thread backend/api/routers/marketplace.py Outdated
Comment thread backend/api/routers/openai_compat.py Outdated
Comment thread backend/api/routers/openai_compat.py
Comment on lines +274 to +323
@router.post("/transcriptions")
async def create_transcription(
file: UploadFile = File(..., description="Audio file to transcribe"),
model: str = Form(
default="whisper-1",
description=(
"ASR model. Accepts 'whisper-1' (maps to active engine), or an "
"OmniVoice engine ID: whisperx, faster-whisper, mlx-whisper, pytorch-whisper."
),
),
language: Optional[str] = Form(
default=None,
description="Language of the input audio (ISO 639-1). Optional.",
),
prompt: Optional[str] = Form(
default=None,
description="Optional text to guide the model's style or continue a previous segment.",
),
response_format: str = Form(
default="json",
description="Output format: json, text, verbose_json, srt, vtt.",
),
temperature: Optional[float] = Form(
default=None,
description="Sampling temperature (0–1). Not used by all backends.",
),
):
"""Transcribe audio to text. Compatible with OpenAI's POST /v1/audio/transcriptions."""
from services.asr_backend import get_active_asr_backend

# Write uploaded file to a temp location
suffix = os.path.splitext(file.filename or "audio.wav")[1] or ".wav"
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not read audio file: {e}")

try:
backend = get_active_asr_backend()

# Run transcription in the thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
word_ts = response_format == "verbose_json"
result = await loop.run_in_executor(
_gpu_pool,
lambda: backend.transcribe(tmp_path, word_timestamps=word_ts),
)

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor the model parameter for transcription requests.

This endpoint documents model as a backend selector, but the implementation always calls get_active_asr_backend(). Any caller asking for whisperx, faster-whisper, etc. gets the current default instead, so the API contract here is silently wrong.

🧰 Tools
🪛 Ruff (0.15.12)

[warning] 276-276: Do not perform function call File in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


[warning] 298-298: String contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF001)


[warning] 311-311: Do not catch blind exception: Exception

(BLE001)


[warning] 312-312: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/openai_compat.py` around lines 274 - 323, The
create_transcription handler currently ignores the model parameter by always
calling get_active_asr_backend(); update it to honor model by mapping
model=="whisper-1" to the active backend and other values (e.g., "whisperx",
"faster-whisper", "mlx-whisper", "pytorch-whisper") to their specific backends
via a selector function (either add/get or use an existing
get_asr_backend_by_name/get_backend(name) API) and call that backend's
transcribe; validate the model string, raise HTTPException(400) for unknown
backends, and keep the thread-pool call (loop.run_in_executor) using the
selected backend.transcribe so behavior and word_timestamps logic remain
unchanged.

Comment thread backend/api/routers/tts_stream.py
Comment thread backend/api/routers/tts_stream.py Outdated
Comment thread frontend/src/App.jsx Outdated
Comment thread frontend/src/App.jsx Outdated
Comment thread frontend/src/index.css Outdated
…packaging dependencies and capture UI performance.

@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: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
docs/DESKTOP_RELEASE.md (2)

60-60: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Setup download stream path in docs doesn’t match implemented route.

The doc uses /setup/download/stream, while backend exposes /setup/download-stream. This mismatch will break consumers following the release plan.

Also applies to: 152-152

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/DESKTOP_RELEASE.md` at line 60, Docs reference the wrong SSE route;
update all occurrences of the path `/setup/download/stream` in the documentation
to match the backend's implemented route `/setup/download-stream` (or, if you
prefer to standardize on the dotted form, change the server route to
`/setup/download/stream` instead). Locate and edit the doc strings that mention
the SSE endpoint (instances of `/setup/download/stream`) and make them
consistent with the backend route name (`/setup/download-stream`) so consumers
and the implementation match.

31-33: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

/health response contract is out of sync with backend.

This section says status == "healthy" with model_loaded/gpu_available, but backend /health currently returns {"status":"ok","device":...}. Please align the doc or endpoint contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/DESKTOP_RELEASE.md` around lines 31 - 33, The docs mention the /health
response using fields status == "healthy" and model_loaded/gpu_available but the
backend currently returns {"status":"ok","device":...}; update either the docs
or the backend to make them consistent: either change the DESKTOP_RELEASE.md
health-check steps to expect status == "ok" and the actual device/fields the
server returns (replace model_loaded/gpu_available with the backend's device
field and its shape), or modify the backend /health endpoint to return the
documented contract (status: "healthy", model_loaded: bool, gpu_available:
bool); refer to the /health endpoint and the specific field names (status,
model_loaded, gpu_available, device) when making the change.
frontend/src-tauri/src/lib.rs (1)

75-82: ⚠️ Potential issue | 🟠 Major

Use the new instance's argv to determine which window to focus.

This callback ignores _argv and always picks the target from the already-running instance's pill_mode. When the app is started with --pill, clicking "Open OmniVoice Studio" (line 257) spawns a new process without the --pill flag, but the second-instance handler will still focus widget because the first instance's pill_mode is true. This prevents the Studio window from opening correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src-tauri/src/lib.rs` around lines 75 - 82, The second-instance
handler passed to tauri_plugin_single_instance::init currently ignores the
provided _argv and uses the existing pill_mode to pick target window; change the
closure to inspect the incoming argv (the _argv parameter) for the "--pill" flag
and set target = if argv contains "--pill" { "widget" } else { "main" } instead
of using the existing pill_mode, then proceed to get_webview_window(target) and
show/unminimize/set_focus as before so the new process's intent determines which
window is focused.
♻️ Duplicate comments (1)
frontend/src/index.css (1)

2350-2356: ⚠️ Potential issue | 🟡 Minor

Raise the pill host above existing floating chrome layers.

z-index: 9999 is still below .rail-label at 10000, so the capture pill can render under nav/tooltips instead of above them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/index.css` around lines 2350 - 2356, The .capture-pill-host
z-index (currently 9999) is lower than .rail-label (10000) so the pill can
render underneath; update the .capture-pill-host selector to use a higher
z-index (e.g., 10001 or 10010) so it stacks above .rail-label and other floating
chrome layers while leaving other properties like position and pointer-events
unchanged.
🧹 Nitpick comments (2)
backend/api/routers/setup/download.py (2)

104-110: ⚡ Quick win

Add Retry-After to the 429 response.

The cooldown response is good, but clients can’t reliably back off without a standard Retry-After header.

Suggested diff
         raise HTTPException(
             status_code=429,
             detail=(
                 f"Model {req.repo_id!r} install failed recently. "
                 f"Retry in {remaining}s or check your network."
             ),
+            headers={"Retry-After": str(max(1, remaining))},
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/setup/download.py` around lines 104 - 110, The 429
HTTPException raised for a recent install cooldown should include a standard
Retry-After header so clients can back off; modify the HTTPException call (the
one using HTTPException(status_code=429, detail=...)) to pass
headers={"Retry-After": str(remaining)} (or an RFC-compliant timestamp if you
prefer) so the response includes Retry-After (use the existing remaining
variable and keep the same detail message referencing req.repo_id).

192-194: ⚡ Quick win

Preserve traceback on install failure logs.

Using logger.info here hides stack traces for unexpected failures, which makes root-cause debugging harder.

Suggested diff
-            logger.info("model install failed for %s: %s", req.repo_id, e)
+            logger.exception("model install failed for %s", req.repo_id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/setup/download.py` around lines 192 - 194, The current
catch block logs install failures with logger.info which hides the traceback;
change the log to include the exception traceback (e.g., use logger.exception or
logger.error(..., exc_info=True)) so the full stack trace for the exception
variable e is preserved while keeping the existing behavior of setting
_install_cooldowns[req.repo_id] = _time_fail.time(); locate the code that
references logger, _install_cooldowns and req.repo_id in the install failure
path and replace the logger.info call with a logger.exception/ logger.error call
that includes the exception details.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/api/routers/capture_ws.py`:
- Line 40: The MIN_BUFFER_BYTES constant is being used for both partials and
final transcription, causing utterances shorter than ~2s to skip calling
_transcribe_buffer_full() on EOF and return a synthetic empty final; introduce a
separate threshold (e.g., MIN_FINAL_BYTES or MIN_FINAL_BUFFER_BYTES) and use
MIN_BUFFER_BYTES for partials while gating the final-transcription path (the
EOF/finish branch that currently checks MIN_BUFFER_BYTES) against the new
MIN_FINAL_BYTES so that _transcribe_buffer_full() is invoked for final sends
even for shorter utterances; update all checks that currently reference
MIN_BUFFER_BYTES for final/EOF handling to use the new constant and leave
partial buffering logic (and variable name MIN_BUFFER_BYTES) unchanged.
- Around line 301-310: In the except block that handles ffmpeg conversion
failures, only return tmp_in.name when the buffered input is actually a WebM
container: detect WebM by opening tmp_in, reading the initial bytes and checking
for the EBML magic sequence (b'\x1A\x45\xDF\xA3') or another reliable WebM
signature, rewind/seek the file as needed, and only log "Falling back to raw
WebM input for ASR" and return tmp_in.name when that check passes; otherwise,
clean up tmp_in/tmp_out as appropriate and return None (the original fallback)
so PCM inputs are not misclassified. Ensure you update the except block around
the ffmpeg conversion (where tmp_in and tmp_out are referenced) and preserve
existing OSError handling for os.unlink.

In `@frontend/src-tauri/src/commands.rs`:
- Around line 392-441: The exe_str is injected verbatim into the plist XML and
the Linux desktop entry, so escape/serialize it before writing: XML-escape
special chars (&, <, >, ", ') for the plist creation block (the plist
variable/format! call that inserts {exe_str}), and properly escape or
shell-quote the Exec value for the Linux desktop entry (the desktop
variable/format! that contains Exec="{exe_str}" --pill). Add small helpers like
escape_for_plist(exe: &str) and escape_for_desktop(exe: &str) and call them
where exe_str is interpolated (and adjust the Windows reg value if needed) using
an XML-escape library for the plist and a safe shell-quoting or
backslash-escaping approach for the .desktop Exec line.

In `@frontend/src-tauri/src/lib.rs`:
- Around line 264-266: The tray "dictate" handler currently only emits a start
event ("tray-dictate") so the CaptureWidget never receives the matching stop;
update the "dictate" match arm in lib.rs to toggle by checking the current
recording state or by emitting "tray-dictate-stop" when recording —
specifically, change the block that calls app.emit("tray-dictate", ()) to emit
"tray-dictate-stop" if CaptureWidget (or the app state) reports recording,
otherwise emit "tray-dictate"; ensure the emitted event names exactly match the
CaptureWidget listeners ("tray-dictate" and "tray-dictate-stop").

In `@frontend/src-tauri/tauri.conf.json`:
- Line 44: The Content Security Policy string under the "csp" key currently
allows broad localhost access via http/ws://localhost:* and http://127.0.0.1:*,
which is too permissive; update the "csp" value so connect-src, media-src, and
img-src only allow the specific backend/dev ports OmniVoice actually uses
(replace the wildcard ports with the exact ports or named placeholders for your
app's backend/dev server), e.g., change occurrences of http://localhost:*
ws://localhost:* and http://127.0.0.1:* ws://127.0.0.1:* to explicit origins
like http://localhost:PORT and ws://localhost:PORT while keeping required tokens
(ipc://localhost, blob:, data:, and style-src 'unsafe-inline' etc.) intact.

In `@frontend/src/components/CaptureWidget.css`:
- Around line 15-29: In the .capture-pill block remove the unexpected empty
line(s) between property declarations (fixing declaration-empty-line-before) and
normalize the font-family value to use double quotes for named families and no
quotes for system/generic families (e.g. change font-family: 'Inter Variable',
'Inter', -apple-system, sans-serif; to font-family: "Inter Variable", "Inter",
-apple-system, sans-serif;), then ensure the properties remain consecutively
listed with proper semicolons and no extra blank lines so Stylelint passes.

In `@frontend/src/components/CaptureWidget.jsx`:
- Around line 118-149: The current logic always treats data.text as "pasted" and
auto-dismisses even when navigator.clipboard.writeText or the Tauri
invoke('simulate_paste') call fail; change it to detect success before updating
UI and hiding the window: in the block that handles data.text, perform
navigator.clipboard.writeText and then invoke('simulate_paste') inside try/catch
but record a boolean like pasteSucceeded; only run the auto-dismiss code
(setState('idle')/setTranscript('')/setDuration(0)/getCurrentWindow().hide()/onDismiss())
when pasteSucceeded is true; if paste fails, do not hide or clear the transcript
(or set a non-pasted state) so the visible transcript remains for the user.
Reference functions/idents: navigator.clipboard.writeText,
invoke('simulate_paste'), pasteSucceeded flag, setTimeout callback, setState,
setTranscript, setDuration, getCurrentWindow, onDismiss.
- Around line 57-79: The Tauri listeners inside the useEffect are re-created on
every state change, creating a gap where a quick tray-dictate-stop can be
missed; change the effect so listeners are mounted once (no state dep) and read
the current state via a ref: create a stateRef = useRef(state), update
stateRef.current in a separate small effect whenever state changes, then in the
listener callbacks check stateRef.current before calling startRecording or
stopRecording; keep the existing unlistenStart/unlistenStop cleanup but ensure
the mounting effect has an empty deps array so listeners remain stable during
async import/listen.

---

Outside diff comments:
In `@docs/DESKTOP_RELEASE.md`:
- Line 60: Docs reference the wrong SSE route; update all occurrences of the
path `/setup/download/stream` in the documentation to match the backend's
implemented route `/setup/download-stream` (or, if you prefer to standardize on
the dotted form, change the server route to `/setup/download/stream` instead).
Locate and edit the doc strings that mention the SSE endpoint (instances of
`/setup/download/stream`) and make them consistent with the backend route name
(`/setup/download-stream`) so consumers and the implementation match.
- Around line 31-33: The docs mention the /health response using fields status
== "healthy" and model_loaded/gpu_available but the backend currently returns
{"status":"ok","device":...}; update either the docs or the backend to make them
consistent: either change the DESKTOP_RELEASE.md health-check steps to expect
status == "ok" and the actual device/fields the server returns (replace
model_loaded/gpu_available with the backend's device field and its shape), or
modify the backend /health endpoint to return the documented contract (status:
"healthy", model_loaded: bool, gpu_available: bool); refer to the /health
endpoint and the specific field names (status, model_loaded, gpu_available,
device) when making the change.

In `@frontend/src-tauri/src/lib.rs`:
- Around line 75-82: The second-instance handler passed to
tauri_plugin_single_instance::init currently ignores the provided _argv and uses
the existing pill_mode to pick target window; change the closure to inspect the
incoming argv (the _argv parameter) for the "--pill" flag and set target = if
argv contains "--pill" { "widget" } else { "main" } instead of using the
existing pill_mode, then proceed to get_webview_window(target) and
show/unminimize/set_focus as before so the new process's intent determines which
window is focused.

---

Duplicate comments:
In `@frontend/src/index.css`:
- Around line 2350-2356: The .capture-pill-host z-index (currently 9999) is
lower than .rail-label (10000) so the pill can render underneath; update the
.capture-pill-host selector to use a higher z-index (e.g., 10001 or 10010) so it
stacks above .rail-label and other floating chrome layers while leaving other
properties like position and pointer-events unchanged.

---

Nitpick comments:
In `@backend/api/routers/setup/download.py`:
- Around line 104-110: The 429 HTTPException raised for a recent install
cooldown should include a standard Retry-After header so clients can back off;
modify the HTTPException call (the one using HTTPException(status_code=429,
detail=...)) to pass headers={"Retry-After": str(remaining)} (or an
RFC-compliant timestamp if you prefer) so the response includes Retry-After (use
the existing remaining variable and keep the same detail message referencing
req.repo_id).
- Around line 192-194: The current catch block logs install failures with
logger.info which hides the traceback; change the log to include the exception
traceback (e.g., use logger.exception or logger.error(..., exc_info=True)) so
the full stack trace for the exception variable e is preserved while keeping the
existing behavior of setting _install_cooldowns[req.repo_id] =
_time_fail.time(); locate the code that references logger, _install_cooldowns
and req.repo_id in the install failure path and replace the logger.info call
with a logger.exception/ logger.error call that includes the exception details.
🪄 Autofix (Beta)

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: aadbfc74-5b5d-41f0-8e44-55f45ce371c6

📥 Commits

Reviewing files that changed from the base of the PR and between 8ae6189 and 3deebf2.

⛔ Files ignored due to path filters (1)
  • frontend/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • backend/api/routers/capture_ws.py
  • backend/api/routers/setup/download.py
  • backend/main.py
  • backend/services/ffmpeg_utils.py
  • backend/utils/hf_progress.py
  • docs/DESKTOP_RELEASE.md
  • docs/STRUCTURE.md
  • docs/desktop-build.md
  • frontend/src-tauri/Cargo.toml
  • frontend/src-tauri/src/commands.rs
  • frontend/src-tauri/src/lib.rs
  • frontend/src-tauri/tauri.conf.json
  • frontend/src/App.jsx
  • frontend/src/components/CaptureWidget.css
  • frontend/src/components/CaptureWidget.jsx
  • frontend/src/components/FloatingPill.css
  • frontend/src/components/FloatingPill.jsx
  • frontend/src/components/ReadinessChecklist.css
  • frontend/src/components/ReadinessChecklist.jsx
  • frontend/src/index.css
  • frontend/src/main-app.jsx
  • frontend/src/store/pillSlice.ts
💤 Files with no reviewable changes (2)
  • docs/STRUCTURE.md
  • backend/utils/hf_progress.py
✅ Files skipped from review due to trivial changes (8)
  • frontend/src/components/ReadinessChecklist.css
  • frontend/src/components/FloatingPill.jsx
  • frontend/src/components/ReadinessChecklist.jsx
  • frontend/src/components/FloatingPill.css
  • docs/desktop-build.md
  • frontend/src-tauri/Cargo.toml
  • frontend/src/store/pillSlice.ts
  • frontend/src/App.jsx

Comment thread backend/api/routers/capture_ws.py
Comment on lines +301 to +310
except Exception as e:
logger.debug("ffmpeg conversion failed: %s", e)
try:
os.unlink(tmp_in.name)
os.unlink(tmp_out.name)
except OSError:
pass
# Fallback: return the raw WebM — ASR backends (MLX Whisper,
# WhisperX) can decode WebM/Opus containers natively.
logger.debug("Falling back to raw WebM input for ASR")
return tmp_in.name

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only fall back to raw WebM when the buffered input is actually WebM.

This helper still advertises PCM/WebM support, but on ffmpeg failure it now always returns tmp_in.name as if the buffer were a WebM container. If a PCM client hits this path, the backend will hand mislabeled raw PCM bytes to ASR and fail harder than the old None fallback.

Suggested guard
     except Exception as e:
         logger.debug("ffmpeg conversion failed: %s", e)
         try:
             os.unlink(tmp_out.name)
         except OSError:
             pass
-        # Fallback: return the raw WebM — ASR backends (MLX Whisper,
-        # WhisperX) can decode WebM/Opus containers natively.
-        logger.debug("Falling back to raw WebM input for ASR")
-        return tmp_in.name
+        # Only fall back when the input is actually an EBML/WebM container.
+        if blob.startswith(b"\x1A\x45\xDF\xA3"):
+            logger.debug("Falling back to raw WebM input for ASR")
+            return tmp_in.name
+        try:
+            os.unlink(tmp_in.name)
+        except OSError:
+            pass
+        return None
🧰 Tools
🪛 Ruff (0.15.12)

[warning] 301-301: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/capture_ws.py` around lines 301 - 310, In the except
block that handles ffmpeg conversion failures, only return tmp_in.name when the
buffered input is actually a WebM container: detect WebM by opening tmp_in,
reading the initial bytes and checking for the EBML magic sequence
(b'\x1A\x45\xDF\xA3') or another reliable WebM signature, rewind/seek the file
as needed, and only log "Falling back to raw WebM input for ASR" and return
tmp_in.name when that check passes; otherwise, clean up tmp_in/tmp_out as
appropriate and return None (the original fallback) so PCM inputs are not
misclassified. Ensure you update the except block around the ffmpeg conversion
(where tmp_in and tmp_out are referenced) and preserve existing OSError handling
for os.unlink.

Comment thread frontend/src-tauri/src/commands.rs
Comment thread frontend/src-tauri/src/lib.rs
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* blob: data:; media-src 'self' blob: data: http://localhost:* http://127.0.0.1:* asset: https://asset.localhost; img-src 'self' blob: data: asset: https://asset.localhost https://fonts.gstatic.com; font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;",
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ipc://localhost http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* blob: data:; media-src 'self' blob: data: http://localhost:* http://127.0.0.1:* asset: https://asset.localhost; img-src 'self' blob: data: asset: https://asset.localhost http://localhost:* http://127.0.0.1:* https://fonts.gstatic.com; font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;",

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten the localhost CSP to the ports OmniVoice actually owns.

Allowing http/ws://localhost:* and 127.0.0.1:* lets any renderer injection probe arbitrary local services on the machine. This shell appears to need only the app's own backend/dev ports, so keeping connect-src, media-src, and img-src scoped to those exact ports would preserve least privilege.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src-tauri/tauri.conf.json` at line 44, The Content Security Policy
string under the "csp" key currently allows broad localhost access via
http/ws://localhost:* and http://127.0.0.1:*, which is too permissive; update
the "csp" value so connect-src, media-src, and img-src only allow the specific
backend/dev ports OmniVoice actually uses (replace the wildcard ports with the
exact ports or named placeholders for your app's backend/dev server), e.g.,
change occurrences of http://localhost:* ws://localhost:* and http://127.0.0.1:*
ws://127.0.0.1:* to explicit origins like http://localhost:PORT and
ws://localhost:PORT while keeping required tokens (ipc://localhost, blob:,
data:, and style-src 'unsafe-inline' etc.) intact.

Comment thread frontend/src/components/CaptureWidget.css
Comment on lines +57 to +79
// ── Hold-to-talk: listen for tray-dictate (start) and tray-dictate-stop (release) ──
useEffect(() => {
let unlistenStart, unlistenStop;
(async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', () => {
if (state === 'idle' || state === 'done' || state === 'error') {
startRecording();
}
});
unlistenStop = await listen('tray-dictate-stop', () => {
if (state === 'recording') {
stopRecording();
}
});
} catch { /* not in Tauri */ }
})();
return () => {
if (unlistenStart) unlistenStart();
if (unlistenStop) unlistenStop();
};
}, [state]);

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the tray listeners are tied to state changes and re-created asynchronously.
rg -n -C3 'useEffect|tray-dictate|tray-dictate-stop|\[state\]' frontend/src/components/CaptureWidget.jsx

Repository: debpalash/OmniVoice-Studio

Length of output: 1943


🏁 Script executed:

cat -n frontend/src/components/CaptureWidget.jsx | sed -n '100,160p'

Repository: debpalash/OmniVoice-Studio

Length of output: 2436


Stabilize the Tauri listeners; re-subscribing on every state change can miss the release event.

This effect tears down both listeners whenever state changes, then re-attaches them asynchronously. On a quick hold-to-talk cycle, the tray-dictate-stop event can arrive during the gap between cleanup (synchronous) and re-registration (async import/listen), leaving the widget stuck in the recording state. Use useRef to track state instead, so the listeners remain stable and read the current state from refs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/CaptureWidget.jsx` around lines 57 - 79, The Tauri
listeners inside the useEffect are re-created on every state change, creating a
gap where a quick tray-dictate-stop can be missed; change the effect so
listeners are mounted once (no state dep) and read the current state via a ref:
create a stateRef = useRef(state), update stateRef.current in a separate small
effect whenever state changes, then in the listener callbacks check
stateRef.current before calling startRecording or stopRecording; keep the
existing unlistenStart/unlistenStop cleanup but ensure the mounting effect has
an empty deps array so listeners remain stable during async import/listen.

Comment on lines +118 to +149
if (data.text) {
try {
await navigator.clipboard.writeText(data.text);
setCopied(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste');
toast.success('Pasted into active app', { duration: 2000 });
} catch {
toast.success('Copied to clipboard — paste with ⌘V', { duration: 2000 });
}

// Auto-dismiss the floating widget after 2.5 seconds so it gets out of the way
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
setCopied(false);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
}, 2500);

} catch { /* clipboard API may fail in some contexts */ }
} catch { /* not in Tauri */ }
} catch { /* clipboard API may fail */ }

// Auto-dismiss after 1.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
}, 1500);
} else {
// No speech — auto-dismiss after 2.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
}, 2500);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't auto-dismiss as "Pasted" when clipboard/paste failed.

Both failure paths are swallowed here, but the success branch still shows done, labels the result as "Pasted", and hides the pill. In web mode or any denied-clipboard case, the user loses the only visible transcript even though nothing was inserted.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/CaptureWidget.jsx` around lines 118 - 149, The
current logic always treats data.text as "pasted" and auto-dismisses even when
navigator.clipboard.writeText or the Tauri invoke('simulate_paste') call fail;
change it to detect success before updating UI and hiding the window: in the
block that handles data.text, perform navigator.clipboard.writeText and then
invoke('simulate_paste') inside try/catch but record a boolean like
pasteSucceeded; only run the auto-dismiss code
(setState('idle')/setTranscript('')/setDuration(0)/getCurrentWindow().hide()/onDismiss())
when pasteSucceeded is true; if paste fails, do not hide or clear the transcript
(or set a non-pasted state) so the visible transcript remains for the user.
Reference functions/idents: navigator.clipboard.writeText,
invoke('simulate_paste'), pasteSucceeded flag, setTimeout callback, setState,
setTranscript, setDuration, getCurrentWindow, onDismiss.

Backend:
- marketplace: stream zip entries via ZipFile.open()/copyfileobj, add 100MB
  upload cap, fix raise-from exception chaining (OOM prevention)
- openai_compat: _encode_audio returns actual file ext so Content-Disposition
  matches real format; forward non-profile voices when DB row not found
- tts_stream: send 'start' frame after generation so sample_rate is real;
  forward non-profile voices on DB miss
- capture_ws: split MIN_BUFFER_BYTES into separate partial/final thresholds
  so short utterances (<2s) still get transcribed

Frontend (Tauri):
- lib.rs: tray 'dictate' now toggles start/stop based on widget visibility
- commands.rs: XML-escape exe path in LaunchAgent plist, shell-quote in
  .desktop Exec line to prevent injection from special-char paths
- CaptureWidget.css: fix Stylelint violations (empty lines, font-family quotes)

@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: 6

♻️ Duplicate comments (4)
frontend/src/components/CaptureWidget.css (1)

23-23: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove quotes around Inter to satisfy Stylelint

Line 23 still violates font-family-name-quotes; keep quotes for "Inter Variable" but remove them for Inter.

Proposed fix
-  font-family: "Inter Variable", "Inter", -apple-system, sans-serif;
+  font-family: "Inter Variable", Inter, -apple-system, sans-serif;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/CaptureWidget.css` at line 23, Update the font-family
declaration in CaptureWidget.css so it satisfies Stylelint by keeping quotes
only around "Inter Variable" and removing the quotes around Inter; specifically
edit the font-family line currently set to `font-family: "Inter Variable",
"Inter", -apple-system, sans-serif;` to use unquoted Inter (e.g., `font-family:
"Inter Variable", Inter, -apple-system, sans-serif;`) so the rule
`font-family-name-quotes` is not violated.
backend/api/routers/capture_ws.py (1)

305-314: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard raw fallback by container signature before returning tmp_in.name.

On ffmpeg failure, Line 314 currently returns tmp_in.name unconditionally. For PCM inputs, that path mislabels raw bytes as WebM and can make ASR fail harder. Only fall back when the buffered input is actually EBML/WebM; otherwise clean up and return None.

Proposed fix
     except Exception as e:
         logger.debug("ffmpeg conversion failed: %s", e)
         try:
             os.unlink(tmp_out.name)
         except OSError:
             pass
-        # Fallback: return the raw WebM — ASR backends (MLX Whisper,
-        # WhisperX) can decode WebM/Opus containers natively.
-        logger.debug("Falling back to raw WebM input for ASR")
-        return tmp_in.name
+        # Fallback only if input is actually EBML/WebM.
+        if blob.startswith(b"\x1A\x45\xDF\xA3"):
+            logger.debug("Falling back to raw WebM input for ASR")
+            return tmp_in.name
+        try:
+            os.unlink(tmp_in.name)
+        except OSError:
+            pass
+        return None
#!/bin/bash
# Verify fallback behavior and whether a WebM signature guard exists.
rg -n -C3 'ffmpeg conversion failed|Falling back to raw WebM input for ASR|return tmp_in.name|blob\.startswith\(b"\\x1A\\x45\\xDF\\xA3"\)' backend/api/routers/capture_ws.py
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/capture_ws.py` around lines 305 - 314, When ffmpeg
conversion in capture_ws (the except block around tmp_in/tmp_out) fails, don't
unconditionally return tmp_in.name; first check the buffered input's container
signature by reading the start of the tmp_in file and confirming it begins with
the EBML/WebM magic bytes (b"\x1A\x45\xDF\xA3") before falling back. If the
signature matches, log the fallback and return tmp_in.name; otherwise
remove/close tmp_in (clean up) and return None. Use tmp_in.seek(0) then read a
small prefix and tmp_in_blob.startswith(b"\x1A\x45\xDF\xA3") to decide; keep the
existing logger.debug messages and the tmp_out unlink logic intact.
backend/api/routers/openai_compat.py (1)

280-285: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

model is documented here but never used to choose the ASR backend.

This handler always calls get_active_asr_backend(), so requests for whisperx, faster-whisper, etc. silently run on the default backend instead of the requested one.

Also applies to: 305-325

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/openai_compat.py` around lines 280 - 285, The request
handler documents a "model" Form parameter but always ignores it by calling
get_active_asr_backend(); change the handler to use the model value to select
the ASR backend: when model == "whisper-1" keep the existing
get_active_asr_backend() call, otherwise resolve the requested backend by
mapping known IDs (e.g., "whisperx", "faster-whisper", "mlx-whisper",
"pytorch-whisper") to their corresponding backend implementation or by calling a
resolver like get_asr_backend_by_id(model) and use that backend for processing;
update the same logic in the other affected handler block (around the 305-325
region) so incoming model values actually determine which ASR backend is used
and return an error if the requested backend is not available.
backend/api/routers/marketplace.py (1)

141-150: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Bundle handling is still RAM-bound and only checks compressed size.

await file.read() and f.read() still materialize the entire archive before validation, and /install has no bundle-size check at all. A highly compressed bundle can also expand to oversized audio because extraction never checks ZipInfo.file_size before copyfileobj.

Also applies to: 169-182, 352-357, 371-381

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/marketplace.py` around lines 141 - 150, The code
currently materializes the entire upload via await file.read() (variable
content) and opens zipfile.ZipFile(io.BytesIO(content)) which allows very large
compressed-to-expanded attacks and missing checks in the /install handler and
the other similar blocks; change to stream-read the incoming UploadFile in
fixed-size chunks and early-reject when the cumulative compressed bytes exceed
MAX_BUNDLE_BYTES (do not call await file.read() to completion), only construct
an in-memory buffer (io.BytesIO) after the compressed size is validated, and
when extracting use ZipFile.infolist() / ZipInfo.file_size to validate each
entry against a MAX_EXPANDED_BYTES per-file (and cumulative expanded size)
before calling ZipFile.open() + shutil.copyfileobj, enforcing limits and raising
HTTPException 413 on breach; apply the same streaming+ZipInfo size checks for
the other occurrences that currently use content/zf/copyfileobj (the blocks at
the other mentioned locations and the /install endpoint).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/api/routers/marketplace.py`:
- Around line 348-350: Reject filenames that escape the marketplace directory by
resolving the candidate path and ensuring it is inside MARKETPLACE_DIR before
touching the filesystem: compute base = MARKETPLACE_DIR.resolve() and candidate
= (MARKETPLACE_DIR / filename).resolve() (or use candidate.relative_to(base) in
a try/except), and if candidate is not under base raise HTTPException(404,
detail=...) instead of directly using bundle_path; apply the same check to the
other occurrence handling filenames in the same module (the block around the
later bundle handling).
- Around line 161-162: The json.load(mf) call when reading "metadata.json" can
raise JSONDecodeError or UnicodeDecodeError and should be treated as a client
error; catch json.JSONDecodeError and UnicodeDecodeError around the
zf.open("metadata.json") block (both occurrences where metadata = json.load(mf)
is used) and convert them to an HTTP 400 response (raise
HTTPException(status_code=400, detail="Invalid metadata.json" or similar) so
malformed or non-UTF-8 metadata is reported as bad input rather than an internal
server error). Ensure you reference the same error handling in both code paths
that use zf and metadata.

In `@backend/api/routers/openai_compat.py`:
- Around line 329-387: The verbose_json/srt/vtt branches only iterate the local
segments list, so when the backend returns transcription in chunks you lose
timestamps; normalize chunks into segments right after computing full_text by
setting segments = segments if segments else chunks (and then recompute duration
using the last entry from segments if needed) so VerboseTranscriptionResponse,
the srt loop (uses _format_ts_srt), and vtt loop (uses _format_ts_vtt) operate
on the fallback data; ensure any uses of enumerate(segments) and the duration
calculation reference the normalized segments variable.
- Around line 307-313: The current code buffers the whole upload via await
file.read() before writing to disk; change to stream the upload in chunks into
the NamedTemporaryFile to avoid high memory use: inside the with
tempfile.NamedTemporaryFile(...) as tmp block, loop reading fixed-size chunks
(e.g., chunk = await file.read(8192)) until empty and write each chunk to tmp
(optionally track total bytes and raise an error if a configured max size is
exceeded). Keep the existing suffix/tmp_path logic and ensure tmp is
flushed/closed by the context manager; reference the variables file, tmp,
tmp_path and the tempfile.NamedTemporaryFile usage to locate where to replace
the single await file.read() call.

In `@backend/api/routers/tts_stream.py`:
- Line 35: Guard against zero/negative chunk sizes by validating
OMNIVOICE_STREAM_CHUNK when initializing CHUNK_SAMPLES: parse the env var,
convert to int, and if value <= 0 fallback to the default (4800) or a minimum
positive value, e.g., max(1, parsed) so CHUNK_SAMPLES is always > 0; also ensure
the send loop that advances sent_samples (the websocket streaming loop
referencing sent_samples and CHUNK_SAMPLES) will always make progress when
CHUNK_SAMPLES is used.

In `@frontend/src-tauri/src/commands.rs`:
- Around line 473-478: The registry-delete Command::new(...) call currently
ignores both the Result and the ExitStatus so failures are swallowed; capture
the result of .status(), handle the Err from process spawn and non-success exit
statuses from the returned ExitStatus, log an error (e.g., via log::error!) with
the command, status and any stderr info, and return an Err from the surrounding
function instead of unconditionally returning Ok(()). Locate the
Command::new("reg") invocation in commands.rs and replace the ignored call with
error-handling that maps process::ExitStatus failures into a meaningful error
(using your crate's error type/path) so callers see failure when autostart
deletion fails.

---

Duplicate comments:
In `@backend/api/routers/capture_ws.py`:
- Around line 305-314: When ffmpeg conversion in capture_ws (the except block
around tmp_in/tmp_out) fails, don't unconditionally return tmp_in.name; first
check the buffered input's container signature by reading the start of the
tmp_in file and confirming it begins with the EBML/WebM magic bytes
(b"\x1A\x45\xDF\xA3") before falling back. If the signature matches, log the
fallback and return tmp_in.name; otherwise remove/close tmp_in (clean up) and
return None. Use tmp_in.seek(0) then read a small prefix and
tmp_in_blob.startswith(b"\x1A\x45\xDF\xA3") to decide; keep the existing
logger.debug messages and the tmp_out unlink logic intact.

In `@backend/api/routers/marketplace.py`:
- Around line 141-150: The code currently materializes the entire upload via
await file.read() (variable content) and opens
zipfile.ZipFile(io.BytesIO(content)) which allows very large
compressed-to-expanded attacks and missing checks in the /install handler and
the other similar blocks; change to stream-read the incoming UploadFile in
fixed-size chunks and early-reject when the cumulative compressed bytes exceed
MAX_BUNDLE_BYTES (do not call await file.read() to completion), only construct
an in-memory buffer (io.BytesIO) after the compressed size is validated, and
when extracting use ZipFile.infolist() / ZipInfo.file_size to validate each
entry against a MAX_EXPANDED_BYTES per-file (and cumulative expanded size)
before calling ZipFile.open() + shutil.copyfileobj, enforcing limits and raising
HTTPException 413 on breach; apply the same streaming+ZipInfo size checks for
the other occurrences that currently use content/zf/copyfileobj (the blocks at
the other mentioned locations and the /install endpoint).

In `@backend/api/routers/openai_compat.py`:
- Around line 280-285: The request handler documents a "model" Form parameter
but always ignores it by calling get_active_asr_backend(); change the handler to
use the model value to select the ASR backend: when model == "whisper-1" keep
the existing get_active_asr_backend() call, otherwise resolve the requested
backend by mapping known IDs (e.g., "whisperx", "faster-whisper", "mlx-whisper",
"pytorch-whisper") to their corresponding backend implementation or by calling a
resolver like get_asr_backend_by_id(model) and use that backend for processing;
update the same logic in the other affected handler block (around the 305-325
region) so incoming model values actually determine which ASR backend is used
and return an error if the requested backend is not available.

In `@frontend/src/components/CaptureWidget.css`:
- Line 23: Update the font-family declaration in CaptureWidget.css so it
satisfies Stylelint by keeping quotes only around "Inter Variable" and removing
the quotes around Inter; specifically edit the font-family line currently set to
`font-family: "Inter Variable", "Inter", -apple-system, sans-serif;` to use
unquoted Inter (e.g., `font-family: "Inter Variable", Inter, -apple-system,
sans-serif;`) so the rule `font-family-name-quotes` is not violated.
🪄 Autofix (Beta)

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: 05da7664-1621-4552-be4c-47803683c7df

📥 Commits

Reviewing files that changed from the base of the PR and between 3deebf2 and 9b7e2dc.

📒 Files selected for processing (7)
  • backend/api/routers/capture_ws.py
  • backend/api/routers/marketplace.py
  • backend/api/routers/openai_compat.py
  • backend/api/routers/tts_stream.py
  • frontend/src-tauri/src/commands.rs
  • frontend/src-tauri/src/lib.rs
  • frontend/src/components/CaptureWidget.css
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src-tauri/src/lib.rs

Comment on lines +161 to +162
with zf.open("metadata.json") as mf:
metadata = json.load(mf)

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat malformed metadata.json as a 400, not a 500.

A bad or non-UTF-8 metadata.json currently bubbles out of json.load(...) as an internal error from both import paths even though the bundle is just invalid input.

Proposed fix
-    with zf.open("metadata.json") as mf:
-        metadata = json.load(mf)
+    try:
+        with zf.open("metadata.json") as mf:
+            metadata = json.load(mf)
+    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+        raise HTTPException(
+            status_code=400,
+            detail="Invalid .omnivoice bundle: malformed metadata.json",
+        ) from exc

Also applies to: 364-365

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/marketplace.py` around lines 161 - 162, The json.load(mf)
call when reading "metadata.json" can raise JSONDecodeError or
UnicodeDecodeError and should be treated as a client error; catch
json.JSONDecodeError and UnicodeDecodeError around the zf.open("metadata.json")
block (both occurrences where metadata = json.load(mf) is used) and convert them
to an HTTP 400 response (raise HTTPException(status_code=400, detail="Invalid
metadata.json" or similar) so malformed or non-UTF-8 metadata is reported as bad
input rather than an internal server error). Ensure you reference the same error
handling in both code paths that use zf and metadata.

Comment on lines +348 to +350
bundle_path = MARKETPLACE_DIR / filename
if not bundle_path.is_file():
raise HTTPException(status_code=404, detail=f"Bundle not found: {filename}")

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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Reject path separators in filename before touching the filesystem.

MARKETPLACE_DIR / filename will resolve ..\... or absolute-path inputs on Windows, so /install can read and DELETE can unlink files outside the marketplace directory. Resolve the candidate path and reject anything that does not stay under MARKETPLACE_DIR.

Proposed fix
+def _resolve_marketplace_bundle(filename: str) -> Path:
+    root = MARKETPLACE_DIR.resolve()
+    candidate = (root / filename).resolve()
+    if candidate.parent != root or candidate.name != filename:
+        raise HTTPException(status_code=400, detail="Invalid bundle filename")
+    return candidate
+
 `@router.post`("/install/{filename}")
 async def install_from_marketplace(filename: str):
     """Import a voice profile from a bundle in the local marketplace directory."""
-    bundle_path = MARKETPLACE_DIR / filename
+    bundle_path = _resolve_marketplace_bundle(filename)
     if not bundle_path.is_file():
         raise HTTPException(status_code=404, detail=f"Bundle not found: {filename}")
@@
 `@router.delete`("/{filename}")
 def remove_from_marketplace(filename: str):
     """Remove a bundle from the local marketplace directory."""
-    bundle_path = MARKETPLACE_DIR / filename
+    bundle_path = _resolve_marketplace_bundle(filename)
     if not bundle_path.is_file():
         raise HTTPException(status_code=404, detail=f"Bundle not found: {filename}")

Also applies to: 421-427

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/marketplace.py` around lines 348 - 350, Reject filenames
that escape the marketplace directory by resolving the candidate path and
ensuring it is inside MARKETPLACE_DIR before touching the filesystem: compute
base = MARKETPLACE_DIR.resolve() and candidate = (MARKETPLACE_DIR /
filename).resolve() (or use candidate.relative_to(base) in a try/except), and if
candidate is not under base raise HTTPException(404, detail=...) instead of
directly using bundle_path; apply the same check to the other occurrence
handling filenames in the same module (the block around the later bundle
handling).

Comment on lines +307 to +313
# Write uploaded file to a temp location
suffix = os.path.splitext(file.filename or "audio.wav")[1] or ".wav"
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stream the upload to disk instead of buffering the whole audio file first.

await file.read() loads the entire transcription upload into RAM before the temp-file write. Large recordings can spike worker memory and are easy to abuse without a size cap.

Proposed fix
     suffix = os.path.splitext(file.filename or "audio.wav")[1] or ".wav"
     try:
         with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
-            content = await file.read()
-            tmp.write(content)
+            total = 0
+            while chunk := await file.read(1024 * 1024):
+                total += len(chunk)
+                tmp.write(chunk)
             tmp_path = tmp.name
     except Exception as e:
         raise HTTPException(status_code=400, detail=f"Could not read audio file: {e}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/openai_compat.py` around lines 307 - 313, The current
code buffers the whole upload via await file.read() before writing to disk;
change to stream the upload in chunks into the NamedTemporaryFile to avoid high
memory use: inside the with tempfile.NamedTemporaryFile(...) as tmp block, loop
reading fixed-size chunks (e.g., chunk = await file.read(8192)) until empty and
write each chunk to tmp (optionally track total bytes and raise an error if a
configured max size is exceeded). Keep the existing suffix/tmp_path logic and
ensure tmp is flushed/closed by the context manager; reference the variables
file, tmp, tmp_path and the tempfile.NamedTemporaryFile usage to locate where to
replace the single await file.read() call.

Comment on lines +329 to +387
segments = result.get("segments", [])
chunks = result.get("chunks", [])
full_text = " ".join(
seg.get("text", "").strip()
for seg in (segments if segments else chunks)
).strip()
detected_lang = result.get("language", language or "en")

# Format response based on requested format
if response_format == "text":
from fastapi.responses import PlainTextResponse
return PlainTextResponse(full_text)

if response_format == "verbose_json":
duration = result.get("duration", 0.0)
if not duration and segments:
last = segments[-1]
duration = last.get("end", 0.0)
return VerboseTranscriptionResponse(
task="transcribe",
language=detected_lang,
duration=duration,
text=full_text,
segments=[
{
"id": i,
"text": seg.get("text", ""),
"start": seg.get("start", 0.0),
"end": seg.get("end", 0.0),
}
for i, seg in enumerate(segments)
],
)

if response_format == "srt":
from fastapi.responses import PlainTextResponse
srt_lines = []
for i, seg in enumerate(segments, 1):
start = seg.get("start", 0.0)
end = seg.get("end", 0.0)
text = seg.get("text", "").strip()
srt_lines.append(
f"{i}\n"
f"{_format_ts_srt(start)} --> {_format_ts_srt(end)}\n"
f"{text}\n"
)
return PlainTextResponse("\n".join(srt_lines), media_type="text/plain")

if response_format == "vtt":
from fastapi.responses import PlainTextResponse
vtt_lines = ["WEBVTT\n"]
for seg in segments:
start = seg.get("start", 0.0)
end = seg.get("end", 0.0)
text = seg.get("text", "").strip()
vtt_lines.append(
f"{_format_ts_vtt(start)} --> {_format_ts_vtt(end)}\n{text}\n"
)
return PlainTextResponse("\n".join(vtt_lines), media_type="text/vtt")

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize chunks into segments before building timed responses.

full_text already falls back to chunks, but verbose_json, srt, and vtt still iterate only segments. Backends that populate chunks will therefore return empty timestamps/subtitles even when transcription succeeded.

Proposed fix
-        segments = result.get("segments", [])
-        chunks = result.get("chunks", [])
+        chunks = result.get("chunks", [])
+        segments = result.get("segments") or chunks or []
         full_text = " ".join(
             seg.get("text", "").strip()
             for seg in (segments if segments else chunks)
         ).strip()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/openai_compat.py` around lines 329 - 387, The
verbose_json/srt/vtt branches only iterate the local segments list, so when the
backend returns transcription in chunks you lose timestamps; normalize chunks
into segments right after computing full_text by setting segments = segments if
segments else chunks (and then recompute duration using the last entry from
segments if needed) so VerboseTranscriptionResponse, the srt loop (uses
_format_ts_srt), and vtt loop (uses _format_ts_vtt) operate on the fallback
data; ensure any uses of enumerate(segments) and the duration calculation
reference the normalized segments variable.


# Chunk size for streaming PCM audio (in samples). At 24kHz, 4800 samples = 200ms.
# Smaller chunks = lower latency but more WebSocket overhead.
CHUNK_SAMPLES = int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800"))

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against zero or negative CHUNK_SAMPLES.

If OMNIVOICE_STREAM_CHUNK is 0 or negative, sent_samples never advances and this loop hangs the websocket request indefinitely.

Proposed fix
-CHUNK_SAMPLES = int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800"))
+try:
+    CHUNK_SAMPLES = max(1, int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800")))
+except ValueError:
+    CHUNK_SAMPLES = 4800

Also applies to: 185-193

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api/routers/tts_stream.py` at line 35, Guard against zero/negative
chunk sizes by validating OMNIVOICE_STREAM_CHUNK when initializing
CHUNK_SAMPLES: parse the env var, convert to int, and if value <= 0 fallback to
the default (4800) or a minimum positive value, e.g., max(1, parsed) so
CHUNK_SAMPLES is always > 0; also ensure the send loop that advances
sent_samples (the websocket streaming loop referencing sent_samples and
CHUNK_SAMPLES) will always make progress when CHUNK_SAMPLES is used.

Comment on lines +473 to +478
let _ = Command::new("reg")
.args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
"/v", "OmniVoicePill", "/f"])
.status();
log::info!("Pill autostart disabled via registry");
return Ok(());

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return an error if Windows registry deletion fails.

Lines 473-476 ignore both process-exec errors and non-zero exit status, so the command can report success even when autostart remains enabled.

Suggested patch
     #[cfg(target_os = "windows")]
     {
         use std::process::Command;
-        let _ = Command::new("reg")
+        let status = Command::new("reg")
             .args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
                    "/v", "OmniVoicePill", "/f"])
-            .status();
+            .status()
+            .map_err(|e| format!("reg delete: {e}"))?;
+        if !status.success() {
+            return Err("Failed to delete registry key".into());
+        }
         log::info!("Pill autostart disabled via registry");
         return Ok(());
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src-tauri/src/commands.rs` around lines 473 - 478, The
registry-delete Command::new(...) call currently ignores both the Result and the
ExitStatus so failures are swallowed; capture the result of .status(), handle
the Err from process spawn and non-success exit statuses from the returned
ExitStatus, log an error (e.g., via log::error!) with the command, status and
any stderr info, and return an Err from the surrounding function instead of
unconditionally returning Ok(()). Locate the Command::new("reg") invocation in
commands.rs and replace the ignored call with error-handling that maps
process::ExitStatus failures into a meaningful error (using your crate's error
type/path) so callers see failure when autostart deletion fails.

@debpalash
debpalash merged commit 545b39c into main May 4, 2026
5 checks passed
@debpalash
debpalash deleted the feat/scalar-docs-community-health branch May 17, 2026 06:20
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