Skip to content

fix: lazy-load ASR during desktop startup - #61

Merged
debpalash merged 3 commits into
debpalash:mainfrom
sunsetsobserver:fix/lazy-load-asr
May 16, 2026
Merged

fix: lazy-load ASR during desktop startup#61
debpalash merged 3 commits into
debpalash:mainfrom
sunsetsobserver:fix/lazy-load-asr

Conversation

@sunsetsobserver

@sunsetsobserver sunsetsobserver commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Avoid preloading PyTorch Whisper ASR when the desktop app loads the OmniVoice TTS model.
  • Keep ASR loading available on demand, with OMNIVOICE_PRELOAD_TTS_ASR=1 as an opt-in for eager preload.
  • Make capture/dictation ASR warmup opt-in with OMNIVOICE_PRELOAD_CAPTURE_ASR=1.
  • Allow dub transcription routes to use the configured ASR backend without requiring the PyTorch Whisper fallback to already be attached to the TTS model.

Why

On Apple Silicon, eager TTS + ASR loading can overcommit unified memory during desktop startup. This can leave the app stuck in the model-loading stage before the UI becomes usable.

Tests

  • uv run pytest backend/ tests/test_model_manager_preload.py -x -q
  • ./node_modules/.bin/vite build --mode development

Summary by CodeRabbit

  • Bug Fixes

    • Improved transcription error handling for streaming and non-streaming flows: more informative in-stream error events, reliable fallback behavior, and ensured backend cleanup on failure.
  • New Features

    • ASR preloading is now configurable via environment flags so ASR models can be optionally preloaded or deferred to reduce startup resource use.
  • Tests

    • Added tests for ASR preload configuration and model-loading defaults.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Makes ASR preloading opt-in via environment flags for TTS and Capture, gates Capture startup preload, adjusts streaming and full transcription to handle missing ASR more precisely, and adds tests for model-manager preload behavior.

Changes

Conditional ASR Preloading with Graceful Fallback

Layer / File(s) Summary
Environment Variable Interpretation Helpers
backend/services/model_manager.py, backend/main.py
New _env_flag() helpers parse boolean environment variables with case-insensitive matching and defaults for feature flags.
TTS Model ASR Preload Integration
backend/services/model_manager.py
Adds should_preload_tts_asr() and passes preload_asr into OmniVoice.from_pretrained(load_asr=...) so PyTorch Whisper preload is opt-in.
Capture ASR Preload Conditional Startup
backend/main.py
Capture ASR warmup is now gated by OMNIVOICE_PRELOAD_CAPTURE_ASR; when enabled, schedules async warmup in the GPU pool and updates _loading_detail; when disabled, logs and skips preload.
Streaming and Full Transcription Fallbacks
backend/api/routers/dub_core.py
Streaming preflight selects vocals_path then audio_path, verifies existence, and limits SSE preflight errors to pytorch-whisper when _asr_pipe is missing; full transcription no longer returns an upfront 503 and instead raises a RuntimeError during fallback handling if the PyTorch Whisper fallback is not preloaded, otherwise uses the in-memory fallback and moves unload into a finally.
Preload Configuration and Integration Tests
tests/test_model_manager_preload.py
Adds fixture and tests that confirm should_preload_tts_asr() is opt-in and _load_model_sync() defaults to load_asr=False unless the env flag enables it; asserts expected args passed to from_pretrained.

Sequence Diagram

sequenceDiagram
  participant Client
  participant TTS as TTS Model Load
  participant Capture as Capture ASR Init
  participant Streaming as Streaming Transcribe
  participant FullTranscode as Full Transcribe
  
  rect rgba(100, 200, 255, 0.5)
  Note over TTS: OMNIVOICE_PRELOAD_TTS_ASR
  TTS->>TTS: should_preload_tts_asr() → False (default)
  TTS->>TTS: load_asr=False
  end
  
  rect rgba(100, 200, 255, 0.5)
  Note over Capture: OMNIVOICE_PRELOAD_CAPTURE_ASR
  Capture->>Capture: Flag check → True/False
  alt enabled
    Capture->>Capture: Schedule warmup task
  else disabled
    Capture->>Capture: Log and skip
  end
  end
  
  Client->>Streaming: audio request
  rect rgba(255, 150, 150, 0.5)
  Streaming->>Streaming: Select audio target
  Streaming->>Streaming: Check _asr_pipe available
  alt pytorch-whisper missing
    Streaming-->>Client: SSE error message
  else available
    Streaming-->>Client: transcription stream
  end
  end
  
  Client->>FullTranscode: audio request
  rect rgba(255, 150, 150, 0.5)
  FullTranscode->>FullTranscode: Attempt backend
  alt backend fails AND _asr_pipe None
    FullTranscode-->>Client: RuntimeError
  else fallback available
    FullTranscode->>FullTranscode: Use PyTorch fallback
    FullTranscode-->>Client: transcription result
  end
  end
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • debpalash/OmniVoice-Studio#36: Modifies streaming transcription flow and SSE/error handling in backend/api/routers/dub_core.py, related to stream-transcription control logic.

Poem

🐰 In the burrow I tweak each flag with care,

Whisper may nap until you tell it to share.
Streams check their paths and fallbacks stand near,
Quiet errors that guide, not shout in your ear.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the summary and motivation well, but is missing the key required sections: Changes list, Type checkbox, Testing details, and Checklist. Add a 'Changes' section listing the modifications, check the appropriate 'Type' box (appears to be both Bug fix and Refactor), complete the 'Testing' section, and mark completed checklist items.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: lazy-loading ASR during desktop startup, which is the core objective of the PR.
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 unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 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: 2

🤖 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/api/routers/dub_core.py`:
- Around line 297-303: The call to get_active_asr_backend happens outside the
SSE preflight handling and can raise, causing an HTTP 500 instead of an
in-stream preflight error; wrap the call to get_active_asr_backend (and
subsequent access to _asr_backend.id / getattr(_model, "_asr_pipe", None)) in a
try/except that catches exceptions (Exception as e) and assigns a descriptive
string to preflight_error (including e) so gen() can emit the in-stream "error"
event instead of letting the exception bubble; keep references to _asr_backend
and the existing check for "pytorch-whisper" but ensure any failure sets
preflight_error rather than raising.

In `@backend/main.py`:
- Around line 266-277: If the ASR warmup in function/_warm (invoked via
loop.run_in_executor with _gpu_pool) raises, ensure _loading_detail is reset so
stale "loading_asr" status isn't left behind: inside the except Exception as e
block, explicitly set _loading_detail["sub_stage"] and _loading_detail["detail"]
back to a neutral or failed state (e.g., "idle" or "failed" and a short message)
and then log the error; update the exception handler that currently only logs
("Capture ASR preload skipped: %s") to perform this reset so any callers reading
_loading_detail see a consistent state after backend.warmup() fails.
🪄 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: 84a41a5f-87fa-4843-a19a-d588fdb70f4b

📥 Commits

Reviewing files that changed from the base of the PR and between 9082705 and 49974bc.

📒 Files selected for processing (4)
  • backend/api/routers/dub_core.py
  • backend/main.py
  • backend/services/model_manager.py
  • tests/test_model_manager_preload.py

Comment thread backend/api/routers/dub_core.py Outdated
Comment thread backend/main.py Outdated

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

🤖 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/api/routers/dub_core.py`:
- Around line 570-575: The failure path in _transcribe() currently raises before
unloading the ASR backend, leaving the failed backend resident; wrap the ASR
call/exception handling so that _asr.unload() is always called (place the unload
in a finally block surrounding the try/except that logs the error and may raise
the RuntimeError), and then remove the now-redundant later unload block (the
separate unload at Lines 614-617) to avoid double-unload. Ensure you still
preserve the existing logger.error call and the RuntimeError raise when
getattr(_model, "_asr_pipe", None) is None.
🪄 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: 94814647-949a-4eae-888e-b564d52a4f5e

📥 Commits

Reviewing files that changed from the base of the PR and between 49974bc and 3b58335.

📒 Files selected for processing (2)
  • backend/api/routers/dub_core.py
  • backend/main.py

Comment thread backend/api/routers/dub_core.py Outdated

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

🤖 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/api/routers/dub_core.py`:
- Around line 579-580: The one-line conditional causing Ruff E701 should be
expanded into a multi-line if block: after calling sf.read(asr_audio_target,
dtype="float32") and assigning audio_np, sr, replace the single-line "if
audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)" with a standard if block
that checks audio_np.ndim and then assigns audio_np = audio_np.mean(axis=1)
inside the indented block; update the code around the sf.read(...) call
(referenced symbols: sf.read, asr_audio_target, audio_np, sr) to follow this
style so the file passes linting.
- Around line 564-591: The code currently calls get_active_asr_backend() and
assigns _asr before the outer try, so if backend construction fails we never run
the PyTorch-Whisper fallback; move the call to
get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None)) inside the
outer try block and wrap only the backend usage (transcribe/unload) in that
try/except so that if get_active_asr_backend() raises you fall back to using
_model._asr_pipe when present; also guard the finally/_asr.unload() so you only
call _asr.unload() if _asr was successfully created (e.g., check _asr is not
None or use a local flag) and keep references to symbols like _asr,
_model._asr_pipe, transcribe, unload, and asr_audio_target to locate the change.
🪄 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: 001595a0-0e70-4401-bbba-b8bb18febe09

📥 Commits

Reviewing files that changed from the base of the PR and between 3b58335 and d3b190b.

📒 Files selected for processing (1)
  • backend/api/routers/dub_core.py

Comment on lines 564 to +591
from services.asr_backend import get_active_asr_backend
_asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
try:
logger.info("Transcribing full audio via %s ...", _asr.id)
result = _asr.transcribe(asr_audio_target, word_timestamps=True)
detected_lang = result.get("language")
except Exception as e:
logger.error("ASR backend %s failed: %s", _asr.id, e)
# Last-resort fallback — in-memory pytorch whisper via the TTS
# model's pipeline. Guaranteed present since the TTS model is
# already loaded to reach this code path.
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
bs = 16 if torch.cuda.is_available() else 1
result = _model._asr_pipe(
{"array": audio_np, "sampling_rate": sr},
return_timestamps=True, chunk_length_s=15, batch_size=bs,
)
detected_lang = (result.get("language") if isinstance(result, dict) else None)
try:
logger.info("Transcribing full audio via %s ...", _asr.id)
result = _asr.transcribe(asr_audio_target, word_timestamps=True)
detected_lang = result.get("language")
except Exception as e:
logger.error("ASR backend %s failed: %s", _asr.id, e)
if getattr(_model, "_asr_pipe", None) is None:
raise RuntimeError(
f"ASR backend {_asr.id} failed and PyTorch Whisper fallback is not preloaded: {e}"
) from e
# Last-resort fallback — in-memory pytorch whisper via the TTS
# model's pipeline when explicitly preloaded.
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
bs = 16 if torch.cuda.is_available() else 1
result = _model._asr_pipe(
{"array": audio_np, "sampling_rate": sr},
return_timestamps=True, chunk_length_s=15, batch_size=bs,
)
detected_lang = (result.get("language") if isinstance(result, dict) else None)
finally:
try:
_asr.unload()
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)

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

Move backend selection inside the new fallback block.

Line 565 still initializes the active backend before the new try. If backend construction fails, this route skips the fallback logic entirely and falls through to the generic 500 path even when _model._asr_pipe is already preloaded.

♻️ Proposed fix
         from services.asr_backend import get_active_asr_backend
-        _asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
+        _asr = None
         try:
             try:
+                _asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
                 logger.info("Transcribing full audio via %s ...", _asr.id)
                 result = _asr.transcribe(asr_audio_target, word_timestamps=True)
                 detected_lang = result.get("language")
             except Exception as e:
-                logger.error("ASR backend %s failed: %s", _asr.id, e)
+                backend_id = getattr(_asr, "id", "unknown")
+                logger.error("ASR backend %s failed: %s", backend_id, e)
                 if getattr(_model, "_asr_pipe", None) is None:
                     raise RuntimeError(
-                        f"ASR backend {_asr.id} failed and PyTorch Whisper fallback is not preloaded: {e}"
+                        f"ASR backend {backend_id} failed and PyTorch Whisper fallback is not preloaded: {e}"
                     ) from e
                 # Last-resort fallback — in-memory pytorch whisper via the TTS
                 # model's pipeline when explicitly preloaded.
                 audio_np, sr = sf.read(asr_audio_target, dtype="float32")
-                if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
+                if audio_np.ndim > 1:
+                    audio_np = audio_np.mean(axis=1)
                 bs = 16 if torch.cuda.is_available() else 1
                 result = _model._asr_pipe(
                     {"array": audio_np, "sampling_rate": sr},
                     return_timestamps=True, chunk_length_s=15, batch_size=bs,
                 )
                 detected_lang = (result.get("language") if isinstance(result, dict) else None)
         finally:
-            try:
-                _asr.unload()
-            except Exception as e:
-                logger.warning("Failed to unload ASR backend: %s", e)
+            if _asr is not None:
+                try:
+                    _asr.unload()
+                except Exception as e:
+                    logger.warning("Failed to unload ASR backend: %s", e)
🧰 Tools
🪛 Ruff (0.15.12)

[error] 580-580: Multiple statements on one line (colon)

(E701)


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

(BLE001)

🤖 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/api/routers/dub_core.py` around lines 564 - 591, The code currently
calls get_active_asr_backend() and assigns _asr before the outer try, so if
backend construction fails we never run the PyTorch-Whisper fallback; move the
call to get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
inside the outer try block and wrap only the backend usage (transcribe/unload)
in that try/except so that if get_active_asr_backend() raises you fall back to
using _model._asr_pipe when present; also guard the finally/_asr.unload() so you
only call _asr.unload() if _asr was successfully created (e.g., check _asr is
not None or use a local flag) and keep references to symbols like _asr,
_model._asr_pipe, transcribe, unload, and asr_audio_target to locate the change.

Comment on lines +579 to +580
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)

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

Split the one-line conditional at Line 580.

Ruff already flags this as E701, so keeping it on one line will keep the file out of lint compliance.

✂️ Proposed fix
-                if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
+                if audio_np.ndim > 1:
+                    audio_np = audio_np.mean(axis=1)
📝 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
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1:
audio_np = audio_np.mean(axis=1)
🧰 Tools
🪛 Ruff (0.15.12)

[error] 580-580: Multiple statements on one line (colon)

(E701)

🤖 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/api/routers/dub_core.py` around lines 579 - 580, The one-line
conditional causing Ruff E701 should be expanded into a multi-line if block:
after calling sf.read(asr_audio_target, dtype="float32") and assigning audio_np,
sr, replace the single-line "if audio_np.ndim > 1: audio_np =
audio_np.mean(axis=1)" with a standard if block that checks audio_np.ndim and
then assigns audio_np = audio_np.mean(axis=1) inside the indented block; update
the code around the sf.read(...) call (referenced symbols: sf.read,
asr_audio_target, audio_np, sr) to follow this style so the file passes linting.

@sunsetsobserver

Copy link
Copy Markdown
Contributor Author

I addressed the actionable CodeRabbit comments related to startup/memory behavior and ASR cleanup. I’m going to avoid expanding this PR further unless maintainers want additional hardening in the dub transcription fallback path, so the PR stays focused on lazy-loading ASR during desktop startup.

@debpalash
debpalash merged commit ba4cf6a into debpalash:main May 16, 2026
1 check passed
debpalash added a commit that referenced this pull request May 16, 2026
… OOS deferrals

- GATE-06: mark #53 + #61 merged (2026-05-16); add #62 (Wave 1 quick wins) to gate set
- INST-01: note PR #62 implements setuptools pin (closes #58)
- INST-04: note PR #62 lands README docs for #56 workaround
- INST-12: new requirement for #65 Windows Triton/torch.compile OOM (filed post-planning)
- Out of Scope: defer #67/PR #68 (audio effects), #64 (custom model dir),
  PR #66 zh-CN (i18n milestone), #63 (empty-template bug)

PR #62 is the user's own Wave 1 work landed as a separate PR while
GSD planning ran in parallel. Merging it eliminates duplicate work
in Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
debpalash added a commit that referenced this pull request May 17, 2026
…ase smoke (#71)

* docs: initialize OmniVoice stabilization milestone project

* chore: add project config (yolo + balanced)

* docs: domain research for stabilization milestone

* docs: define v1 requirements for stabilization milestone

* docs: add GGUF + singing engine spike requirements (Phase 4 new)

* docs: roadmap revision + CLAUDE.md (7 phases, 62 reqs, +GGUF/SING spikes)

* docs(phase-0): add Gates phase RESEARCH.md

Phase 0 research synthesizes the cross-platform CI matrix, frozen
omnivoice_data fixture, installer post-build smoke, SHA-256 checksum
publishing, and PR-template extension into copy-paste-ready YAML and
Python snippets composed entirely from existing in-repo patterns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(phase-0): add Gates phase CONTEXT, PATTERNS, and PLAN

Phase 0 — Gates is the hard pre-condition for v0.3.x stabilization.
Lays cross-platform CI matrix (macos-14/windows-2022/ubuntu-22.04),
regression fixture (≤200 KB), installer smoke on tag push, SHA-256
checksums in release body + per-OS SHA256SUMS-*.txt assets, PR
template with RC cadence + fixture line, and the open-PR landing
for #51.

Plan covers GATE-01..06; structured into 7 slices (A–G) with explicit
Slice C → Slice G dependency reordering so the new smoke-matrix lands
on main before PR #51 (CONTEXT.md L86 interleave decision).

Plan-checker iteration 2: APPROVED — all 3 BLOCKERs + 3 MAJORs from
iteration 1 resolved (file truncation/Slice-G missing, GATE-06 sibling
PR verification, Slice C ordering, Truth #5 wording, macOS Tauri
WebView avoidance per Pitfall #5, Windows taskkill per Pitfall #2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(00-gates): seed regression fixture (GATE-01)

- scripts/seed-test-fixture.py — deterministic builder for tests/fixtures/omnivoice_data/
  - wipes + rebuilds; fixed created_at=1700000000.0; all-zero PCM for byte-deterministic diffs
  - calls backend.core.db.init_db() directly (alembic versions/ is empty — see CONTEXT.md)
  - checkpoints WAL → DELETE on close so no -shm/-wal sidecars pollute git status
  - exits non-zero if fixture > 200 KB
- tests/fixtures/omnivoice_data/{omnivoice.db, README.md} — 8-table empty DB + 1 voice_profiles row
- tests/fixtures/omnivoice_data/voices/test-voice/{profile.json, sample.wav} — 1-sec 24 kHz mono silence
- .gitignore — explicit allow-list (!tests/fixtures/omnivoice_data/**) so the existing
  omnivoice_data/, *.db, *.wav patterns don't hide the fixture from git

Verifies: du = 144 KB on disk; sqlite_master lists 8 init_db tables + sqlite_sequence;
voice_profiles has exactly 1 row id='test-voice'; 0 rows in generation_history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(00-gates): add tests/smoke/test_boot_smoke.py (GATE-01)

- tests/smoke/__init__.py — package marker so pytest treats tests/smoke/ as a module
- tests/smoke/test_boot_smoke.py — 4 in-process FastAPI TestClient smoke tests:
    * test_health_returns_ok — /health returns 200 + {status:ok, device:...}
    * test_profiles_endpoint_lists_fixture_voice — /profiles surfaces the seeded
      test-voice row (validates OMNIVOICE_DATA_DIR wiring → DB_PATH → init_db schema)
    * test_system_info_includes_data_dir — /system/info resolves data_dir
    * test_history_endpoint_empty — /history reaches DB and returns []
  Test isolation env vars (OMNIVOICE_MODEL=test, OMNIVOICE_DISABLE_FILE_LOG=1)
  set at module top BEFORE any backend import — pattern from tests/test_router_smoke.py.
  Fixture is copied to a per-session temp dir so the test never mutates the
  checked-in artifact (SQLite file-change counter + runtime subdirs like dub_jobs/
  would otherwise dirty `git status` after every run).
  Failure mode: if tests/fixtures/omnivoice_data/ is missing, pytest.fail at
  import time with the regenerate command.
- .gitignore — tighten the GATE-01 allow-list to ONLY the seed-produced files
  (README.md, omnivoice.db, voices/test-voice/profile.json, sample.wav).
  Prevents future runtime subdirs the backend may create under the fixture
  from being accidentally committed.

Verifies: `uv run pytest tests/smoke/ -q --tb=short` → 4 passed in 1.31 s
(target was < 30 s). `git status` clean after a test run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(triage): record post-planning GitHub state — PR #62, new issues, OOS deferrals

- GATE-06: mark #53 + #61 merged (2026-05-16); add #62 (Wave 1 quick wins) to gate set
- INST-01: note PR #62 implements setuptools pin (closes #58)
- INST-04: note PR #62 lands README docs for #56 workaround
- INST-12: new requirement for #65 Windows Triton/torch.compile OOM (filed post-planning)
- Out of Scope: defer #67/PR #68 (audio effects), #64 (custom model dir),
  PR #66 zh-CN (i18n milestone), #63 (empty-template bug)

PR #62 is the user's own Wave 1 work landed as a separate PR while
GSD planning ran in parallel. Merging it eliminates duplicate work
in Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(00-gates): add cross-platform smoke matrix (GATE-02)

- New smoke-matrix job on macos-14, windows-2022, ubuntu-22.04
- needs: test, fail-fast: false, timeout-minutes: 10
- Pinned actions: checkout@v4, setup-python@v5, setup-uv@v3 (cache enabled)
- Per-OS ffmpeg + libsndfile install (brew/choco/apt via awalsh128 cache)
- UV_HTTP_TIMEOUT=120, UV_HTTP_RETRIES=5 for restricted-network resilience
- Narrow scope: uv run pytest tests/smoke/ -q --tb=short
- Existing `test` and `tauri-cross-platform` jobs untouched

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: add workflow_dispatch to ci.yml so smoke-matrix can run on feature branches

* feat(00-gates): add --health-check CLI flag to backend entrypoint (GATE-03)

- argparse on __main__ block; --health-check boots uvicorn in a daemon
  thread and polls http://127.0.0.1:3900/health every 5s for up to 60s.
- Prints 'OK — /health responded 200 after Ns' and exits 0 on first 200.
- Prints 'FAIL — /health did not respond 200 within 60s' to stderr and
  exits 1 on timeout. Default invocation behavior unchanged.
- No new deps (stdlib argparse/threading/time/urllib.request/sys + uvicorn).
- Consumed by per-OS installer-smoke step in .github/workflows/release.yml.

Verified locally: exits 0 in 5s against tests/fixtures/omnivoice_data/.

* ci(00-gates): add per-OS installer smoke to release.yml (GATE-03)

Adds three matrix-leg-specific steps after 'Build + release (Tauri)',
each gated by runner.os with timeout-minutes: 5:

- macOS (macos-14): hdiutil attach DMG → locate bundled Python backend
  inside *.app/Contents (NOT the Tauri WebView shell — RESEARCH Pitfall
  #5: WebView hangs on headless runners) → invoke --health-check →
  hdiutil detach. Falls back to *.app/Contents/Resources and hard-fails
  with a directory listing if no backend binary found.

- Windows (windows-2022): msiexec /quiet install → find backend.exe
  under 'C:/Program Files/OmniVoice Studio' → invoke --health-check in
  background, wait, then taskkill //F //T //PID to cleanup orphaned
  PyInstaller child processes on port 3900 (RESEARCH Pitfall #2).

- Linux (ubuntu-22.04): --appimage-extract (no FUSE on GH runners),
  locate binary or AppRun, run under xvfb-run -a.

Bundle-only regressions (PyInstaller missing-module, Tauri sidecar
path mismatch) are invisible to ci.yml's in-process smoke matrix —
this step closes that gap before any release is published.

Verified: YAML parses; all three steps present; gating + timeout
correct; Pitfall #2/#5 mitigations preserved.

* ci(00-gates): publish SHA-256 checksums in release body + as asset (GATE-05)

- Add 'Compute SHA-256 checksums' step writing SHA256SUMS-<label>.txt
  per matrix leg using native shasum/sha256sum (Git Bash on Windows).
- Add 'Append checksums to release + attach SHA256SUMS file' step using
  softprops/action-gh-release@v2 with append_body: true so the hashes
  land in the release body alongside tauri-action's content (not
  replacing it) and the file is uploaded as a release asset for
  'shasum -c SHA256SUMS-<label>.txt' verification.
- Both steps gated by 'github.event_name == push && refs/tags/v*' so
  workflow_dispatch dry-runs do not attempt to attach to a non-existent
  release (per CONTEXT.md L70 + RESEARCH Pitfall #7 deferral of any
  aggregate cross-leg SHA256SUMS job).
- fail_on_unmatched_files: true to surface path-resolution errors loudly.

* docs(00-gates): document RC cadence + regression-fixture check in PR template (GATE-04)

* docs(setup): add HF token persistence guide for macOS/Windows/Linux (DOCS-05)

Covers two persistent paths:
- Method A — canonical ~/.cache/huggingface/token via huggingface-cli login
- Method B — shell env var (~/.zshrc / ~/.bashrc / Windows User scope)

Documents the v0.2.7 "session only" in-app behavior + notes that
Phase 1 AUTH-03 will make in-app pastes write to the canonical file.

Bundled with Phase 0 PR per user request. Strictly DOCS-05 scope —
zero code changes, no engine touches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* spec(auth): redesign HF token resolution as 3-source cascade with fallback (AUTH-01..06)

Replaces the env_store.py file-based design with a SQLite-backed app
store + cascade resolver that checks app → env var → ~/.cache/huggingface/token
in priority order, with automatic fallback to next source on HTTP 401.

User-explicit design decision:
- App-stored token (SQLite settings table, AES-GCM encrypted) wins
- Env var ($HF_TOKEN) second
- Global huggingface-cli login file third
- All three sources visible in Settings → API Keys with "Active" badge
- Save action populates BOTH app store AND canonical HF file (defense in depth)

New requirement:
- AUTH-06 — on 401, auto-retry next source in cascade before erroring

Also: traceability count corrected (62 → 74 — undercount at planning +
INST-12 + AUTH-06 added post-planning). All 74 v1 reqs mapped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): backend recognizes HF token from canonical file, not just env var

Two call sites were only checking $HF_TOKEN env var, missing the canonical
~/.cache/huggingface/token file written by `huggingface-cli login` (or the
app's future Save action):

- system.py `/system/info` `has_hf_token` flag — UI showed "No HF token"
  even when `huggingface-cli login` had populated the file.
- model_manager.get_diarization_pipeline — pyannote diarization silently
  returned None when only the canonical file was set. This is the bug
  behind issue #35 (speaker diarization setup failure).

Both fixes use the same pattern: env var > huggingface_hub.get_token()
(which reads the canonical file). Adds a local _has_hf_token() helper
to system.py with a comment marking it as prelude to the AUTH-01..06
cascade (Phase 1 token_resolver.py will layer SQLite app-store on top).

Closes #35 sub-issue (canonical token invisible to diarization).
Cross-cuts AUTH-02 + AUTH-06 design for Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dictation): make pill-widget mode reachable from GUI + scripts (INST-13)

The dictation widget infrastructure shipped in PR #40 but was only reachable
via the undocumented --pill CLI flag. Adds three discovery paths:

1. Tray menu: "Switch to Dictation Widget" (studio mode) — saves
   launch_as_widget=true to config, relaunches with --pill, exits current.
   Mirrors the existing "Open Studio" path in pill-mode tray.

2. Persistent config: AppConfig.launch_as_widget (bool, default false). Read
   at startup via load_config_pre_app() (uses dirs-next, no AppHandle
   required). CLI --pill still takes precedence when explicitly passed.

3. Tauri commands: get_launch_as_widget / set_launch_as_widget for the
   Phase 2 Settings UI to bind a checkbox to.

4. Scripts: bun desktop-prod:pill / desktop-prod:run:pill — forward --pill
   to the bundled app launch. macOS uses `open -n --args` to spawn fresh
   instance with the flag.

Closes the GUI half of INST-13. Phase 2 closes the Settings UI half.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dictation): show widget unconditionally on pill-mode launch + visible Suspense fallback

Before: pill mode set up correctly but the widget window stayed hidden
until ⌘⇧Space was pressed. New users saw absolutely nothing on launch
(no main window, no dock icon, hidden widget) and assumed the app
failed. If global-shortcut Accessibility permission wasn't granted,
they had no path to discover the widget at all.

Two changes:

1. lib.rs: in pill_mode_setup, explicitly show + position + focus the
   widget window after hiding main. With per-call error logging so we
   can diagnose failures (and a clear error log if widget window
   wasn't created at all — points at tauri.conf.json regression).

2. main-app.jsx: Suspense fallback was `null`, which combined with
   widget's transparent+decorations:false config made any lazy-import
   delay or failure invisible. Now renders a dark pill saying
   "Loading dictation…" so even if CaptureWidget lazy-import stalls,
   the user sees the window exists.

Studio mode behavior unchanged — widget stays hidden until hotkey
or tray click triggers it (existing show() call in the shortcut/
menu handlers is preserved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dictation): create widget window programmatically; Tauri 2 silently dropped config-array creation

Root cause: declaring the widget window in tauri.conf.json's app.windows[]
silently failed in Tauri 2 — get_webview_window("widget") returned None
even though the config was syntactically valid. Probable culprit was the
transparent + decorations:false + visible:false combo, but Tauri offered
no error message either at startup or via webview_windows() enumeration.

Diagnosed by adding webview_windows() enumeration logging at setup start
(only ["main"] ever appeared) and a programmatic WebviewWindowBuilder
fallback that surfaces real Result errors.

Fix:
- tauri.conf.json: widget entry now has `create: false` to make the
  config-vs-programmatic handoff explicit.
- lib.rs setup(): call WebviewWindowBuilder::new(app, "widget", ...).build()
  with the exact same surface attributes the config used to declare.
- capabilities/default.json: include "widget" in windows array so the new
  window inherits the same Tauri permissions as main.
- tauri.conf.json: remove the invalid `"url": "/?window=widget"` field —
  WebviewUrl::App takes a path only, query strings aren't supported.
  Both windows now load index.html.
- main-app.jsx: replace URL-query-based widget detection with
  getCurrentWindow().label === 'widget' via @tauri-apps/api/window. This
  is the Tauri 2-recommended pattern for multi-window apps and works
  regardless of URL routing.

Closes the immediate UX bug behind the dictation widget being invisible.
Builds cleanly + manually verified: pill widget visible on screen at
top-center after `bun desktop-prod:pill`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants