Skip to content

refactor: architecture cleanup — 6 deferred items from master fix list - #17

Merged
davidbzyk merged 3 commits into
mainfrom
fix/architecture-cleanup
Apr 2, 2026
Merged

davidbzyk merged 3 commits into
mainfrom
fix/architecture-cleanup

Conversation

@davidbzyk

Copy link
Copy Markdown
Owner

Summary

  • Q1+Q2: Extract _do_generate (129 lines) and _do_voice_convert (155 lines) from inline closures to module-level functions using functools.partial and set_status callback
  • Q3: Replace 8 per-call reqwest::Client creations with shared HttpClient in Tauri state, per-request tokio::time::timeout()
  • Q4: Migrate SidecarState child/data_dir/starting to tokio::sync::Mutex, replace polling loop with tokio::sync::Notify
  • Q8: Move ~150 lines of duplicated CSS from settings + create-voice into app.css
  • S6: Route sidecar audio through get_generation_audio Tauri command (blob), remove media-src http://127.0.0.1:* from CSP

Review fixes (second commit)

  • Add send_with_timeout to poll_and_download bare .send() calls
  • Revoke blob URL before reassignment (memory leak)
  • Add log::warn to get_generation_audio error paths
  • Extract validate_generation_id helper + 4 unit tests
  • Replace gen_id+generations params with set_status callback
  • Handle health_check client build failure gracefully

Test plan

  • 99 Rust, 69 frontend, 95 sidecar tests pass
  • cargo clippy -- -D warnings clean
  • pnpm check — 0 errors, 0 warnings
  • Dev mode: record + process with voice conversion
  • Dev mode: record + process with TTS
  • Build mode: same tests
  • Create voice page: test generation + audio playback

…pe CSS, tighten CSP

Six deferred architecture cleanup items from the master fix list:

- Q1+Q2: Extract _do_generate (129 lines) and _do_voice_convert (155 lines)
  from inline closures to module-level async functions in server.py, using
  functools.partial to bind parameters for the generation queue protocol.

- Q3: Replace 8 per-call reqwest::Client creations with a single shared
  HttpClient in Tauri managed state. Each call site uses
  tokio::time::timeout() with its original per-request duration (10s–600s).

- Q4: Migrate SidecarState child/data_dir/starting fields from
  std::sync::Mutex to tokio::sync::Mutex. Replace the 120-iteration polling
  loop in ensure_running() with tokio::sync::Notify. Port stays std::sync
  for cheap synchronous reads.

- Q8: Move ~150 lines of duplicated CSS (11 class selectors) from settings
  and create-voice page styles into global app.css.

- S6: Route sidecar audio through new get_generation_audio Tauri command
  returning blob bytes, replacing direct browser HTTP fetch. Remove
  media-src http://127.0.0.1:* wildcard from CSP.
- Add send_with_timeout to poll_and_download's bare .send() calls (lost
  600s coverage when migrating to shared client)
- Revoke blob URL before reassignment in create-voice to prevent memory leak
- Add log::warn to all error paths in get_generation_audio
- Extract validate_generation_id helper, deduplicate from poll_generation
  and get_generation_audio, add 4 unit tests
- Replace gen_id+generations params with set_status callback in both
  extracted Python functions
- Add exc_info=True to generation worker outer catch
- Remove dead import soundfile, unnecessary _asyncio alias, unused as e
- Handle health_check client build failure with log+return instead of
  unwrap_or_default
@coderabbitai

coderabbitai Bot commented Apr 2, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ad0419e0-3f6c-47ca-bcec-c1610d275202

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes refactor the TTS system to use shared HTTP client management, extract generation and voice-conversion logic into reusable backend coroutines, migrate audio URL retrieval from localhost HTTP to in-memory blob URLs, transition sidecar state management from synchronous to asynchronous, and consolidate shared CSS styling across components.

Changes

Cohort / File(s) Summary
Sidecar Backend Logic
src-tauri/sidecar/server.py
Extracted generation and voice-conversion handling into shared async functions (_do_generate, _do_voice_convert) with segment-aware synthesis, model caching, fallback chunking, and improved error logging via exc_info=True.
Tauri HTTP Client Setup
src-tauri/src/lib.rs
Registered shared HttpClient in Tauri application state with configurable connection pooling and exposed new get_generation_audio command handler.
Local TTS Module
src-tauri/src/local_tts.rs
Introduced shared HttpClient struct, added validate_generation_id() validation helper, unified HTTP request routing through send_with_timeout(), updated speech_to_speech and voice_convert function signatures to accept client references, and added get_generation_audio Tauri command with unit tests.
Pipeline Integration
src-tauri/src/pipeline.rs
Wired shared HTTP client from Tauri state into local voice transformation call sites (voice_convert and speech_to_speech).
Sidecar Async Refactoring
src-tauri/src/sidecar.rs
Replaced std::sync::Mutex with tokio::sync::Mutex for state management, introduced startup_done notification for startup coordination, made stop_sidecar async, updated health-check error handling to log warnings instead of silencing failures, and converted test to #[tokio::test].
Configuration Update
src-tauri/tauri.conf.json
Removed http://127.0.0.1:* from CSP media-src directive and added trailing semicolon after https://asset.localhost.
Shared CSS Styling
src/app.css
Added reusable layout, form control, and button styling classes (header, section, card, input, button variants, status indicators, provider toggle) previously defined in component-scoped stylesheets.
Audio URL Retrieval
src/lib/voicebox.ts, src/lib/voicebox.test.ts
Refactored getAudioUrl() to invoke get_generation_audio Tauri command instead of constructing localhost HTTP URLs; now returns blob: object URLs from audio byte arrays; updated tests to match new behavior.
Component Style Consolidation
src/routes/create-voice/+page.svelte, src/routes/settings/+page.svelte
Removed component-scoped CSS rules for layout, form controls, and shared UI elements (now defined globally in app.css); updated testGenerate() to properly revoke blob URLs before clearing state.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #15: Implements sidecar generation/voice-conversion endpoint foundations that are refactored and centralized in this PR's server.py changes.
  • PR #16: Directly extends sidecar server logic with additional generation/voice-conversion handling and model caching patterns applied in parallel.
  • PR #13: Establishes local Voicebox integration infrastructure (local_tts module, Tauri commands, pipeline wiring) that this PR builds upon and extends.

Poem

🐰 Hops of joy through async code,
HTTP clients share the load,
Blob URLs dance where HTTP once rang,
Shared styles harmonize the gang,
Refactored whispers, cleaner still—
A voicebox blooms upon the hill! 🎵

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: a refactoring that addresses 6 deferred architectural items from a fix list, which matches the multi-faceted refactoring scope of the changeset.
Description check ✅ Passed The description is directly related to the changeset, providing detailed summaries of each refactoring item (Q1+Q2, Q3, Q4, Q8, S6) and review fixes that correspond to the code changes across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/architecture-cleanup
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/architecture-cleanup

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

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (3)
src-tauri/src/sidecar.rs (2)

231-242: ⚠️ Potential issue | 🟠 Major

Clear the stored sidecar state when startup dies early.

This branch removes child but leaves port populated. After that, get_sidecar_status() can still report a running sidecar on a dead port until some later restart path clears it. Reset the stored startup state before returning this error.

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

In `@src-tauri/src/sidecar.rs` around lines 231 - 242, The branch that handles an
unexpected child exit removes state.child but leaves state.port set, causing
get_sidecar_status() to report a running sidecar on a stale port; before
returning the error in the child.try_wait() Ok(Some(status)) and Err(e)
branches, also clear the stored startup state (e.g., set state.port to None and
any other startup-related fields) so the port is not left populated—update the
block around state.child.lock().await where child.try_wait() is handled to reset
state.port (and related startup flags) prior to returning the error.

282-294: ⚠️ Potential issue | 🟠 Major

Use kill().await instead of start_kill() to avoid leaving the process unreaped.

start_kill() only sends the SIGKILL signal without waiting for the process to exit; immediately dropping the tokio::process::Child leaves it as a zombie process on Unix. Since this is already an async function, take the child out of the mutex and kill().await to both send the signal and wait for process termination before clearing state.

🔧 Proposed fix
 async fn stop_sidecar_inner(state: &SidecarState) {
     let mut guard = state.child.lock().await;
-    if let Some(ref mut child) = *guard {
+    if let Some(mut child) = guard.take() {
         log::info!("[sidecar] Stopping child process");
-        match child.start_kill() {
-            Ok(()) => log::info!("[sidecar] Kill signal sent to child process"),
-            Err(e) => log::warn!("[sidecar] Failed to send kill signal: {}", e),
+        match child.kill().await {
+            Ok(()) => log::info!("[sidecar] Child process terminated"),
+            Err(e) => log::warn!("[sidecar] Failed to terminate child process: {}", e),
         }
     }
-    *guard = None;
     *state.port.lock().unwrap() = None;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/sidecar.rs` around lines 282 - 294, In stop_sidecar_inner:
instead of calling child.start_kill() while holding the mutex and then dropping
the Child (which can leave a zombie), take the Child out of state.child (use
Option::take on state.child inside the mutex), drop the mutex guard, then call
child.kill().await (await termination) and handle/log any errors from the
kill/await; finally clear state.port and leave state.child as None. Refer to
stop_sidecar_inner, SidecarState, state.child, state.port, and variable child to
locate where to change the logic.
src-tauri/src/local_tts.rs (1)

403-408: ⚠️ Potential issue | 🟠 Major

Pass original_duration in the /voice-convert payload.

src-tauri/sidecar/server.py Line 797 still parses this field, and Lines 597-603 only restore converted length when it is > 0. Because the long-input path crossfades chunks on Lines 593-595, omitting the duration here means long conversions can come back short. Either thread the known source duration into this helper or derive it from input_wav before building the JSON.

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

In `@src-tauri/src/local_tts.rs` around lines 403 - 408, The payload sent to
/voice-convert is missing the original_duration field, causing the server to
misrestore lengths for long inputs; before building the JSON body in this
function, compute or accept the source duration (e.g., derive duration from
input_wav or add a parameter like original_duration) and include it in the
serde_json::json! payload as "original_duration": original_duration (alongside
profile_id and source_audio_path) so server.py can use it to restore converted
length.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src-tauri/sidecar/server.py`:
- Line 514: The log call uses an unnecessary f-string prefix causing Ruff F541;
remove the stray "f" from the logger.info call so the static message in
logger.info("  Truncated sample — omitting transcript to avoid mismatch") is a
plain string (locate the offending line by searching for the logger.info call
with the f prefix).

In `@src-tauri/src/local_tts.rs`:
- Around line 17-27: The current send_with_timeout function only wraps the
Future that yields a Response, which times out only until headers arrive and
leaves body reads unprotected; update callers to use reqwest's
RequestBuilder::timeout (or change send_with_timeout to accept a RequestBuilder
and call .timeout(Duration) before .send()) so the timeout covers the entire
request lifecycle including body reads; adjust call sites like poll_and_download
and get_generation_audio to pass the RequestBuilder (e.g.,
http.client.get(&url).timeout(Duration::from_secs(timeout_secs))) so the timeout
is enforced for connection, headers, and full response body consumption.

In `@src-tauri/src/sidecar.rs`:
- Around line 113-121: The start/launch logic is racy because start_sidecar()
flips SidecarState.starting in a separate critical section from the check in
ensure_running(), allowing two callers to concurrently spawn sidecars; fix this
by making the check-and-set atomic inside a single locked section: acquire
state.starting.lock().await, check the boolean, and if false set it to true and
immediately release (or keep locked through spawn decision) before calling
start_sidecar_inner() or spawning any background task so only one path proceeds
to spawn; update start_sidecar(), ensure_running(), and any other places that
set/read SidecarState.starting (including the background startup task and the
other block referenced) to use this single locked check-and-set pattern and
ensure child/port are only written after the atomic ownership of startup is
established.

In `@src/app.css`:
- Around line 13-148: The global selectors (.header, .section, .provider-toggle,
.provider-btn and their variants) conflict with other routes; rename/namespace
them (e.g., .create-voice-header, .create-voice-section,
.create-voice-provider-toggle, .create-voice-provider-btn and respective
.*-title/.card/.input/.small-btn variants) in this stylesheet and update the
corresponding markup/components that render the settings/create-voice UI to use
the new class names so the styles remain scoped to the intended routes.

In `@src/routes/create-voice/`+page.svelte:
- Around line 249-253: generatedAudioUrl is only revoked inside testGenerate(),
but other code paths (e.g., when the user navigates back to step 3 or leaves the
page) drop the blob URL without revoking it; update every place that clears or
replaces generatedAudioUrl to first call URL.revokeObjectURL(generatedAudioUrl)
if generatedAudioUrl is truthy (or startsWith('blob:')) — specifically add this
revoke call before clearing generatedAudioUrl in the handler that moves the UI
back to step 3 (the function that sets the step/state back) and add a component
cleanup (onDestroy) that revokes generatedAudioUrl before clearing it, ensuring
no blob: URLs are leaked anywhere generatedAudioUrl is set to '' or replaced.

---

Outside diff comments:
In `@src-tauri/src/local_tts.rs`:
- Around line 403-408: The payload sent to /voice-convert is missing the
original_duration field, causing the server to misrestore lengths for long
inputs; before building the JSON body in this function, compute or accept the
source duration (e.g., derive duration from input_wav or add a parameter like
original_duration) and include it in the serde_json::json! payload as
"original_duration": original_duration (alongside profile_id and
source_audio_path) so server.py can use it to restore converted length.

In `@src-tauri/src/sidecar.rs`:
- Around line 231-242: The branch that handles an unexpected child exit removes
state.child but leaves state.port set, causing get_sidecar_status() to report a
running sidecar on a stale port; before returning the error in the
child.try_wait() Ok(Some(status)) and Err(e) branches, also clear the stored
startup state (e.g., set state.port to None and any other startup-related
fields) so the port is not left populated—update the block around
state.child.lock().await where child.try_wait() is handled to reset state.port
(and related startup flags) prior to returning the error.
- Around line 282-294: In stop_sidecar_inner: instead of calling
child.start_kill() while holding the mutex and then dropping the Child (which
can leave a zombie), take the Child out of state.child (use Option::take on
state.child inside the mutex), drop the mutex guard, then call
child.kill().await (await termination) and handle/log any errors from the
kill/await; finally clear state.port and leave state.child as None. Refer to
stop_sidecar_inner, SidecarState, state.child, state.port, and variable child to
locate where to change the logic.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb7ea487-6966-432d-b4e2-a34cfda37fd0

📥 Commits

Reviewing files that changed from the base of the PR and between 4610842 and 700a1cf.

📒 Files selected for processing (11)
  • src-tauri/sidecar/server.py
  • src-tauri/src/lib.rs
  • src-tauri/src/local_tts.rs
  • src-tauri/src/pipeline.rs
  • src-tauri/src/sidecar.rs
  • src-tauri/tauri.conf.json
  • src/app.css
  • src/lib/voicebox.test.ts
  • src/lib/voicebox.ts
  • src/routes/create-voice/+page.svelte
  • src/routes/settings/+page.svelte
💤 Files with no reviewable changes (1)
  • src/routes/settings/+page.svelte
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: rust
  • GitHub Check: claude-review
🧰 Additional context used
📓 Path-based instructions (11)
src-tauri/src/**/*.rs

📄 CodeRabbit inference engine (GEMINI.md)

Secrets use OS keyring (keyring crate), never stored in config files

src-tauri/src/**/*.rs: Backend tests must pass via cargo test before task completion
No new clippy warnings should be introduced, verified via cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
For end-to-end feature addition: Backend agent adds Rust command in appropriate module and registers in lib.rs
When adding a new sidecar endpoint: Backend agent adds Rust wrapper in local_tts.rs or new module and exposes as Tauri command

src-tauri/src/**/*.rs: Use log::info! and similar macros for logging on the Rust side
Write Rust tests using #[cfg(test)] mod tests in the same file as the code being tested
Store API keys and tokens via OS keyring using the keyring crate; never store secrets in config files
Ensure the TTS sidecar launches automatically on app startup; call sidecar::ensure_running() before any local TTS operation

Files:

  • src-tauri/src/pipeline.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/sidecar.rs
  • src-tauri/src/local_tts.rs
src-tauri/src/**

📄 CodeRabbit inference engine (AGENTS.md)

Backend agent owns src-tauri/src/ directory and may read but not modify frontend or sidecar files

Files:

  • src-tauri/src/pipeline.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/sidecar.rs
  • src-tauri/src/local_tts.rs
src-tauri/tauri.conf.json

📄 CodeRabbit inference engine (CLAUDE.md)

Define Content Security Policy (CSP) in tauri.conf.json and update it when adding new external domains

Files:

  • src-tauri/tauri.conf.json
src/**/*.{ts,tsx,svelte}

📄 CodeRabbit inference engine (GEMINI.md)

All Tauri IPC must go through src/lib/tauri.ts tauriInvoke<T>() wrapper

src/**/*.{ts,tsx,svelte}: Type checking must pass via pnpm check before task completion
For end-to-end feature addition: Frontend agent adds tauriInvoke call and updates state/UI
Frontend must call sidecar endpoints via tauriInvoke, never via direct HTTP calls

src/**/*.{ts,tsx,svelte}: Route all backend calls through the tauriInvoke<T>() wrapper in tauri.ts instead of calling Tauri commands directly
Never call sidecar HTTP endpoints directly from frontend code; always route through Rust commands (sidecar_fetch, sidecar_upload)
Use logger.ts with [VO:tag] prefix pattern for frontend logging

Files:

  • src/lib/voicebox.ts
  • src/lib/voicebox.test.ts
  • src/routes/create-voice/+page.svelte
src/**

📄 CodeRabbit inference engine (AGENTS.md)

Frontend agent owns src/ directory and may read but not modify Rust or Python files

Files:

  • src/lib/voicebox.ts
  • src/lib/voicebox.test.ts
  • src/routes/create-voice/+page.svelte
  • src/app.css
src/**/*.test.{ts,tsx,svelte}

📄 CodeRabbit inference engine (AGENTS.md)

Frontend tests must pass via pnpm test before task completion

Files:

  • src/lib/voicebox.test.ts
src/**/*.{test.ts,svelte.test.ts}

📄 CodeRabbit inference engine (CLAUDE.md)

Co-locate test files with source code as *.test.ts or *.svelte.test.ts

Files:

  • src/lib/voicebox.test.ts
src/**/*.svelte

📄 CodeRabbit inference engine (GEMINI.md)

Use Svelte 5 runes only ($state(), $derived(), $effect()). Never use Svelte 4 syntax ($:, export let, $$props)

Use Svelte 5 runes syntax exclusively; do not use Svelte 4 syntax like $: or export let

Files:

  • src/routes/create-voice/+page.svelte
src-tauri/sidecar/**/*.py

📄 CodeRabbit inference engine (GEMINI.md)

Python sidecar targets Apple Silicon only (MLX, no CUDA)

Files:

  • src-tauri/sidecar/server.py
src-tauri/sidecar/**

📄 CodeRabbit inference engine (AGENTS.md)

Sidecar agent owns src-tauri/sidecar/ directory and may read but not modify Rust or frontend files

Files:

  • src-tauri/sidecar/server.py
src-tauri/sidecar/server.py

📄 CodeRabbit inference engine (AGENTS.md)

src-tauri/sidecar/server.py: For end-to-end feature addition: Sidecar agent adds FastAPI endpoint in server.py if TTS-related
When adding a new sidecar endpoint: implement in server.py first and test standalone

Files:

  • src-tauri/sidecar/server.py
🧠 Learnings (15)
📓 Common learnings
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T16:02:35.934Z
Learning: Applies to src-tauri/src/**/*.rs : Ensure the TTS sidecar launches automatically on app startup; call `sidecar::ensure_running()` before any local TTS operation
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Applies to src-tauri/sidecar/server.py : For end-to-end feature addition: Sidecar agent adds FastAPI endpoint in `server.py` if TTS-related
📚 Learning: 2026-04-01T02:22:27.651Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Applies to src-tauri/src/**/*.rs : When adding a new sidecar endpoint: Backend agent adds Rust wrapper in `local_tts.rs` or new module and exposes as Tauri command

Applied to files:

  • src-tauri/src/pipeline.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/sidecar.rs
  • src-tauri/src/local_tts.rs
📚 Learning: 2026-04-02T16:02:35.934Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T16:02:35.934Z
Learning: Applies to src-tauri/src/**/*.rs : Ensure the TTS sidecar launches automatically on app startup; call `sidecar::ensure_running()` before any local TTS operation

Applied to files:

  • src-tauri/src/pipeline.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/sidecar.rs
  • src-tauri/src/local_tts.rs
📚 Learning: 2026-04-01T02:22:13.859Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: GEMINI.md:0-0
Timestamp: 2026-04-01T02:22:13.859Z
Learning: Config changes: update `src-tauri/src/config.rs` (Rust struct + persistence)

Applied to files:

  • src-tauri/src/pipeline.rs
  • src-tauri/src/lib.rs
  • src-tauri/tauri.conf.json
  • src-tauri/src/sidecar.rs
  • src-tauri/src/local_tts.rs
📚 Learning: 2026-04-01T02:22:13.859Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: GEMINI.md:0-0
Timestamp: 2026-04-01T02:22:13.859Z
Learning: New Tauri command: implement in Rust module, register in `src-tauri/src/lib.rs` `invoke_handler`, call via `tauriInvoke` on frontend

Applied to files:

  • src-tauri/src/lib.rs
📚 Learning: 2026-04-01T02:22:27.651Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Tauri commands must be registered in `lib.rs` `invoke_handler` and have corresponding `tauriInvoke` calls on the frontend

Applied to files:

  • src-tauri/src/lib.rs
📚 Learning: 2026-04-01T02:22:27.651Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Applies to src-tauri/src/**/*.rs : For end-to-end feature addition: Backend agent adds Rust command in appropriate module and registers in `lib.rs`

Applied to files:

  • src-tauri/src/lib.rs
  • src-tauri/src/sidecar.rs
📚 Learning: 2026-04-01T02:22:13.859Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: GEMINI.md:0-0
Timestamp: 2026-04-01T02:22:13.859Z
Learning: New sidecar endpoint: add to `src-tauri/sidecar/server.py`, wrap in Rust command, expose to frontend

Applied to files:

  • src-tauri/src/lib.rs
  • src-tauri/src/sidecar.rs
  • src-tauri/src/local_tts.rs
📚 Learning: 2026-04-02T16:02:35.934Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T16:02:35.934Z
Learning: Applies to src-tauri/tauri.conf.json : Define Content Security Policy (CSP) in `tauri.conf.json` and update it when adding new external domains

Applied to files:

  • src-tauri/tauri.conf.json
📚 Learning: 2026-04-01T02:22:27.651Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Applies to src/**/*.{ts,tsx,svelte} : For end-to-end feature addition: Frontend agent adds `tauriInvoke` call and updates state/UI

Applied to files:

  • src-tauri/tauri.conf.json
📚 Learning: 2026-04-01T02:22:27.651Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Applies to src-tauri/sidecar/** : Sidecar agent owns `src-tauri/sidecar/` directory and may read but not modify Rust or frontend files

Applied to files:

  • src-tauri/src/sidecar.rs
📚 Learning: 2026-04-01T02:22:13.859Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: GEMINI.md:0-0
Timestamp: 2026-04-01T02:22:13.859Z
Learning: Frontend never calls sidecar HTTP directly; always through Rust Tauri commands

Applied to files:

  • src-tauri/src/sidecar.rs
  • src-tauri/src/local_tts.rs
📚 Learning: 2026-04-01T02:22:27.651Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Applies to src-tauri/src/**/*.rs : No new clippy warnings should be introduced, verified via `cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings`

Applied to files:

  • src-tauri/src/sidecar.rs
📚 Learning: 2026-04-01T02:22:27.651Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-01T02:22:27.651Z
Learning: Applies to src-tauri/sidecar/server.py : For end-to-end feature addition: Sidecar agent adds FastAPI endpoint in `server.py` if TTS-related

Applied to files:

  • src-tauri/sidecar/server.py
📚 Learning: 2026-04-02T16:02:35.934Z
Learnt from: CR
Repo: davidbzyk/voiceover PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T16:02:35.934Z
Learning: Applies to src/**/*.{ts,tsx,svelte} : Never call sidecar HTTP endpoints directly from frontend code; always route through Rust commands (`sidecar_fetch`, `sidecar_upload`)

Applied to files:

  • src-tauri/src/local_tts.rs
🪛 Ruff (0.15.7)
src-tauri/sidecar/server.py

[error] 514-514: f-string without any placeholders

Remove extraneous f prefix

(F541)

🔇 Additional comments (5)
src-tauri/tauri.conf.json (1)

26-26: CSP tightening looks good.

Dropping http://127.0.0.1:* from media-src matches the new blob-based audio path and narrows the policy. As per coding guidelines: "Define Content Security Policy (CSP) in tauri.conf.json and update it when adding new external domains"

src-tauri/src/pipeline.rs (1)

170-190: Nice: the local path still goes through ensure_running() before using the shared client.

This keeps the sidecar-startup contract intact while removing per-call HTTP client churn. Based on learnings: "Ensure the TTS sidecar launches automatically on app startup; call sidecar::ensure_running() before any local TTS operation"

src-tauri/sidecar/server.py (1)

738-748: Nice endpoint slimming.

functools.partial(...) plus the set_status callback keeps /generate and /voice-convert focused on queue orchestration while preserving progress updates.

Also applies to: 812-820

src-tauri/src/local_tts.rs (2)

613-656: Good IPC boundary for sidecar audio.

Returning raw bytes here keeps the frontend off direct 127.0.0.1 media URLs while still reusing the sidecar’s /audio/{id} contract.

Based on learnings: Frontend never calls sidecar HTTP directly; always through Rust Tauri commands.


870-888: Good regression coverage for the new ID validator.

These tests hit the obvious traversal and shell-metacharacter inputs the helper is supposed to reject.

elif was_truncated:
# Audio was truncated — omit transcript to avoid text/audio mismatch
all_have_text = False
logger.info(f" Truncated sample — omitting transcript to avoid mismatch")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Drop the stray f prefix on the static log line.

Line 514 triggers Ruff F541, so this will fail lint even though the message itself is fine.

🔧 Minimal fix
-            logger.info(f"  Truncated sample — omitting transcript to avoid mismatch")
+            logger.info("  Truncated sample — omitting transcript to avoid mismatch")
📝 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
logger.info(f" Truncated sample — omitting transcript to avoid mismatch")
logger.info(" Truncated sample — omitting transcript to avoid mismatch")
🧰 Tools
🪛 Ruff (0.15.7)

[error] 514-514: f-string without any placeholders

Remove extraneous f prefix

(F541)

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

In `@src-tauri/sidecar/server.py` at line 514, The log call uses an unnecessary
f-string prefix causing Ruff F541; remove the stray "f" from the logger.info
call so the static message in logger.info("  Truncated sample — omitting
transcript to avoid mismatch") is a plain string (locate the offending line by
searching for the logger.info call with the f prefix).

Comment on lines +17 to +27
/// Send an HTTP request with a per-call timeout.
async fn send_with_timeout(
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
timeout_secs: u64,
context: &str,
) -> Result<reqwest::Response, String> {
tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), future)
.await
.map_err(|_| format!("{context}: timed out after {timeout_secs}s"))?
.map_err(|e| format!("{context}: {e}"))
}

Copy link
Copy Markdown

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

🌐 Web query:

In reqwest, does wrapping only RequestBuilder::send()intokio::time::timeout()also time out reading the response body from.text(), .json(), or .bytes()? How does that compare with RequestBuilder::timeout()/ClientBuilder::timeout()?

💡 Result:

No, wrapping only RequestBuilder::send in tokio::time::timeout does NOT time out reading the response body from .text, .json, or .bytes. The send future completes once the response headers are received and the Response object is returned, but reading the body (which can be streamed/chunked) happens separately in subsequent calls like .text.await, which are not covered by that timeout. This can lead to hangs if the body transfer stalls. In contrast: - RequestBuilder::timeout applies a timeout from connection start through complete response body reading, covering both send and body consumption like .text. - ClientBuilder::timeout sets a default timeout for all requests from that client, with the same coverage (connect + full response), but can be overridden per-request. To time out the full request including body reading with tokio::time::timeout, wrap the entire chain: tokio::time::timeout(duration, async { let res = builder.send.await?; res.text.await // or .json, etc. }) This ensures timeout protection for both phases. Official docs confirm RequestBuilder::timeout: "The timeout is applied from when the request starts connecting until the response body has finished." Multiple GitHub issues and Stack Overflow posts highlight body reading hangs post-send without additional timeouts (e.g., slow chunked responses).

Citations:


send_with_timeout only times the request head, leaving body reads unprotected.

Wrapping only .send() in tokio::time::timeout() stops once the Response exists; every later .text(), .json(), and .bytes() read is still unbounded. A stalled sidecar body will hang poll_and_download() and get_generation_audio(). This is a regression from the old reqwest request-timeout behavior, which covers both the request headers and complete response body reading.

Use RequestBuilder::timeout() instead, which applies timeout protection from connection start through entire response body consumption:

Safer implementation
-async fn send_with_timeout(
-    future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
+async fn send_with_timeout(
+    request: reqwest::RequestBuilder,
     timeout_secs: u64,
     context: &str,
 ) -> Result<reqwest::Response, String> {
-    tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), future)
-        .await
-        .map_err(|_| format!("{context}: timed out after {timeout_secs}s"))?
-        .map_err(|e| format!("{context}: {e}"))
+    request
+        .timeout(std::time::Duration::from_secs(timeout_secs))
+        .send()
+        .await
+        .map_err(|e| {
+            if e.is_timeout() {
+                format!("{context}: timed out after {timeout_secs}s")
+            } else {
+                format!("{context}: {e}")
+            }
+        })
 }

Call sites:

let response = send_with_timeout(http.client.get(&url), 10, "Failed to fetch voice profiles").await?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/local_tts.rs` around lines 17 - 27, The current
send_with_timeout function only wraps the Future that yields a Response, which
times out only until headers arrive and leaves body reads unprotected; update
callers to use reqwest's RequestBuilder::timeout (or change send_with_timeout to
accept a RequestBuilder and call .timeout(Duration) before .send()) so the
timeout covers the entire request lifecycle including body reads; adjust call
sites like poll_and_download and get_generation_audio to pass the RequestBuilder
(e.g., http.client.get(&url).timeout(Duration::from_secs(timeout_secs))) so the
timeout is enforced for connection, headers, and full response body consumption.

Comment thread src-tauri/src/sidecar.rs
Comment on lines 113 to +121
pub async fn start_sidecar(app: &tauri::AppHandle) -> Result<u16, String> {
let state = app.state::<SidecarState>();
*state.starting.lock().unwrap() = true;
*state.starting.lock().await = true;

let result = start_sidecar_inner(app).await;

let state = app.state::<SidecarState>();
*state.starting.lock().unwrap() = false;
*state.starting.lock().await = false;
state.startup_done.notify_waiters();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

starting is still racy; two callers can launch two sidecars.

ensure_running() reads starting, but start_sidecar() flips it in a separate critical section. The background startup task in src-tauri/src/lib.rs:54-58 can overlap with a foreground ensure_running(), so both paths can observe false, both spawn, and whichever finishes last overwrites child/port. Please make the check-and-set atomic in one place before any spawn happens.

Also applies to: 304-346

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

In `@src-tauri/src/sidecar.rs` around lines 113 - 121, The start/launch logic is
racy because start_sidecar() flips SidecarState.starting in a separate critical
section from the check in ensure_running(), allowing two callers to concurrently
spawn sidecars; fix this by making the check-and-set atomic inside a single
locked section: acquire state.starting.lock().await, check the boolean, and if
false set it to true and immediately release (or keep locked through spawn
decision) before calling start_sidecar_inner() or spawning any background task
so only one path proceeds to spawn; update start_sidecar(), ensure_running(),
and any other places that set/read SidecarState.starting (including the
background startup task and the other block referenced) to use this single
locked check-and-set pattern and ensure child/port are only written after the
atomic ownership of startup is established.

Comment thread src/app.css
Comment on lines +13 to +148
/* ---------------------------------------------------------------------------
Shared page styles — used by settings and create-voice routes
--------------------------------------------------------------------------- */

.header {
display: flex;
align-items: center;
gap: 12px;
}
.header h2 {
margin: 0;
font-size: 18px;
}
.back-btn {
background: #334155;
border: none;
color: #94a3b8;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.back-btn:hover {
background: #475569;
color: #f1f5f9;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-title {
font-size: 13px;
font-weight: 600;
}
.card {
background: #1e293b;
border-radius: 8px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.field-label {
font-size: 11px;
color: #64748b;
}
.input {
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
padding: 8px 12px;
color: #cbd5e1;
font-size: 12px;
outline: none;
width: 100%;
}
.input:focus {
border-color: #f97316;
}
select.input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2364748b' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
cursor: pointer;
}
select.input option {
background: #0f172a;
color: #cbd5e1;
}
.small-btn {
background: #334155;
border: none;
color: #94a3b8;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.small-btn:hover {
background: #475569;
}
.small-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.small-btn.accent {
background: #f97316;
color: white;
}
.small-btn.accent:disabled {
opacity: 0.4;
}
.status {
font-size: 11px;
}
.status.valid {
color: #22c55e;
}
.status.invalid {
color: #ef4444;
}
.hint-text {
font-size: 11px;
color: #64748b;
line-height: 1.4;
}
.provider-toggle {
display: flex;
gap: 4px;
background: #0f172a;
border-radius: 6px;
padding: 3px;
}
.provider-btn {
flex: 1;
background: transparent;
border: none;
color: #64748b;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
.provider-btn.active {
background: #334155;
color: #f1f5f9;
}
.provider-btn:hover:not(.active) {
color: #94a3b8;
}

Copy link
Copy Markdown

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
# Expected: no output. Any match is a candidate global-style collision.
rg -nP --glob 'src/**/*.svelte' 'class\s*=\s*"[^"]*\b(header|back-btn|section|section-title|card|field-label|input|small-btn|status|hint-text|provider-toggle|provider-btn)\b[^"]*"' \
  | grep -vE 'src/routes/(create-voice/\+page\.svelte|settings/\+page\.svelte):' || true

Repository: davidbzyk/voiceover

Length of output: 891


Namespace these selectors before applying them globally—verified collisions with existing routes.

The added CSS rules for .header, .section, .provider-toggle, and .provider-btn are already in use across other routes:

  • .header in preview route
  • .section, .provider-toggle, and .provider-btn in home route

Moving these from scoped styles to the global stylesheet will apply the settings/create-voice styling to these routes unintentionally. Prefix or scope these selectors (e.g., .create-voice-header, .settings-header) before placing them in app.css.

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

In `@src/app.css` around lines 13 - 148, The global selectors (.header, .section,
.provider-toggle, .provider-btn and their variants) conflict with other routes;
rename/namespace them (e.g., .create-voice-header, .create-voice-section,
.create-voice-provider-toggle, .create-voice-provider-btn and respective
.*-title/.card/.input/.small-btn variants) in this stylesheet and update the
corresponding markup/components that render the settings/create-voice UI to use
the new class names so the styles remain scoped to the intended routes.

Comment on lines 249 to 253
async function testGenerate() {
generating = true;
generateError = '';
if (generatedAudioUrl) URL.revokeObjectURL(generatedAudioUrl);
generatedAudioUrl = '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This only fixes one blob-URL leak path.

generatedAudioUrl is revoked before regeneration, but it is still cleared without cleanup when the user goes back to step 3 (Line 566) or leaves the page. Now that src/lib/voicebox.ts:123-126 returns blob: URLs, every path that drops the string needs to revoke it.

♻️ Suggested cleanup
-import { onMount } from 'svelte';
+import { onDestroy, onMount } from 'svelte';
@@
 onMount(() => {
 	checkPrerequisites();
 });
+
+onDestroy(() => {
+	if (generatedAudioUrl) URL.revokeObjectURL(generatedAudioUrl);
+});
@@
-						<button class="small-btn" onclick={() => { generatedAudioUrl = ''; step = 3; }}>
+						<button class="small-btn" onclick={() => {
+							if (generatedAudioUrl) URL.revokeObjectURL(generatedAudioUrl);
+							generatedAudioUrl = '';
+							step = 3;
+						}}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/create-voice/`+page.svelte around lines 249 - 253,
generatedAudioUrl is only revoked inside testGenerate(), but other code paths
(e.g., when the user navigates back to step 3 or leaves the page) drop the blob
URL without revoking it; update every place that clears or replaces
generatedAudioUrl to first call URL.revokeObjectURL(generatedAudioUrl) if
generatedAudioUrl is truthy (or startsWith('blob:')) — specifically add this
revoke call before clearing generatedAudioUrl in the handler that moves the UI
back to step 3 (the function that sets the step/state back) and add a component
cleanup (onDestroy) that revokes generatedAudioUrl before clearing it, ensuring
no blob: URLs are leaked anywhere generatedAudioUrl is set to '' or replaced.

@davidbzyk
davidbzyk merged commit 9f1704f into main Apr 2, 2026
3 of 4 checks passed
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