Skip to content

Conversation

@AnthonyRonning
Copy link
Contributor

@AnthonyRonning AnthonyRonning commented Dec 4, 2025

Summary

Adds text-to-speech functionality to Maple desktop app using Supertonic, a fast on-device TTS engine.

Features

  • On-device TTS: All processing happens locally, no cloud API calls
  • One-time download: ~265MB models downloaded from HuggingFace on first use
  • Speaker button: Added to assistant messages (desktop only)
  • High quality: F2 voice, 10 inference steps, 1.2x speed, 50ms pause between chunks

Technical Details

  • Rust backend using ONNX Runtime for inference
  • React context for state management
  • Downloads models to app data directory
  • Supports 4 voice styles (F1, F2, M1, M2) - currently using F2

Testing

  • Desktop build works
  • iOS build (need to test)
  • Android build (need to test)

Notes

  • TTS button only appears in desktop app (Tauri environment)
  • Models stored in ~/Library/Application Support/cloud.opensecret.maple/tts_models/ on macOS

Summary by CodeRabbit

  • New Features

    • Text-to-Speech (TTS) synthesis with one-click playback in chat, per-message TTS controls, and a built-in audio player.
    • Guided TTS setup dialog with model download, loading, and progress tracking.
  • Chores

    • Platform-specific support and native dependencies added to enable on-device TTS across desktop and mobile builds.
  • UI

    • TTS provider integrated into app context so TTS is available throughout the interface.

✏️ Tip: You can customize this high-level summary in your review settings.

- Add Rust TTS module with Supertonic ONNX inference
- Download models (~265MB) from HuggingFace on first use
- Add TTSContext for React state management
- Add TTSDownloadDialog for model setup
- Add speaker button to assistant messages (desktop only)
- Settings: F2 voice, 10 inference steps, 1.2x speed

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@coderabbitai
Copy link

coderabbitai bot commented Dec 4, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds end-to-end Text-to-Speech: Rust ONNX-based backend (model download, load, synthesize, unload), Tauri commands, new Cargo dependencies, and React frontend integration (TTS context, UI dialog, playback, and app/provider wiring).

Changes

Cohort / File(s) Summary
Backend TTS implementation
frontend/src-tauri/src/tts.rs
New comprehensive TTS module: config and style types, Unicode text preprocessing, ONNX Runtime session orchestration (duration/text/vector/vocoder), model download/load/unload, synthesize -> WAV base64, and Tauri command handlers (tts_get_status, tts_download_models, tts_load_models, tts_synthesize, tts_unload_models).
Tauri crate / integration
frontend/src-tauri/Cargo.toml, frontend/src-tauri/src/lib.rs
Added TTS-related dependencies (ndarray, ort variants, hound, reqwest, etc.), platform-specific dependency flags (mobile/Android), registered mod tts;, managed TTSState and exposed TTS Tauri commands in invoke handler.
Frontend TTS context & API
frontend/src/services/tts/TTSContext.tsx, frontend/src/services/tts/index.ts
New React TTSProvider and useTTS hook: status lifecycle, tauri invoke interactions, download progress event handling, audio base64->Blob conversion, playback controls, and exports via services/tts index.
Frontend UI components
frontend/src/components/TTSDownloadDialog.tsx, frontend/src/components/UnifiedChat.tsx
New TTSDownloadDialog component for setup/download UI; UnifiedChat updated to include TTSButton, tts setup flow, and MessageList signature extended with onTTSSetupOpen.
App wiring
frontend/src/app.tsx
App provider tree wrapped with new TTSProvider so TTS context is available to UI.
Build targets
justfile
Added desktop-build-debug and desktop-build-debug-no-cc targets (debug variants).
Formatting-only
frontend/src-tauri/src/pdf_extractor.rs
Minor whitespace/formatting adjustments; no behavior changes.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant React as Frontend (TTSProvider/UI)
    participant Tauri as Backend (Rust)
    participant FS as File System
    participant ONNX as ONNX Runtime

    User->>React: Open TTS setup dialog
    React->>Tauri: invoke tts_get_status()
    Tauri->>FS: check model files
    Tauri-->>React: status (not_downloaded / models_downloaded)

    User->>React: Click Download
    React->>Tauri: invoke tts_download_models()
    loop download streaming
        Tauri->>FS: write model chunks
        Tauri-->>React: emit tts-download-progress(event)
        React->>React: update UI progress
    end
    Tauri-->>React: download complete
    React->>Tauri: invoke tts_load_models()
    Tauri->>ONNX: initialize sessions (text_enc/dp/vector/vocoder)
    Tauri-->>React: models_loaded / ready

    User->>React: Request synthesis (text)
    React->>Tauri: invoke tts_synthesize(text)
    Tauri->>Tauri: preprocess Unicode -> token ids
    Tauri->>ONNX: run text_encoder -> durations -> vector_est -> vocoder loop
    Tauri->>Tauri: encode waveform to WAV base64
    Tauri-->>React: return audio_base64 + metadata
    React->>React: base64 -> Blob -> Audio play
    User->>User: hears audio
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

  • Review focus areas:
    • frontend/src-tauri/src/tts.rs: ONNX session usage, memory/thread-safety, error paths, model download streaming and file integrity.
    • Tauri command signatures and state management in lib.rs.
    • React TTSContext.tsx: event listener lifecycle, base64->Blob handling, audio cleanup, and user-facing state transitions.
    • Cross-platform Cargo.toml platform flags (mobile/android) for build correctness.

Possibly related PRs

  • Tauri #23 — Modifies same Tauri crate (frontend/src-tauri) including Cargo.toml and src/lib.rs; likely shares integration overlap with TTS commands and dependency changes.
  • feat: replace native update dialog with toast notification #334 — Touches update-ready emission in frontend/src-tauri/src/lib.rs; related to the changed event emission formatting in this PR.
  • Android #246 — Contains Android-specific dependency edits to frontend/src-tauri/Cargo.toml (openssl vendored); aligns with added Android dependency entries here.

Poem

🐰
I nibbled on bytes beneath the moon,
Built voices that hum a gentle tune,
From text to sound, a hop, a cheer,
Models downloaded, now speak up, my dear! 🎶

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add on-device TTS using Supertonic' accurately and concisely summarizes the main change: adding text-to-speech functionality using the Supertonic backend.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/tts-supertonic

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

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Dec 4, 2025

Deploying maple with  Cloudflare Pages  Cloudflare Pages

Latest commit: fc94dfa
Status: ✅  Deploy successful!
Preview URL: https://b8ab1504.maple-ca8.pages.dev
Branch Preview URL: https://feature-tts-supertonic.maple-ca8.pages.dev

View logs

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Dec 4, 2025

Greptile Overview

Greptile Summary

Adds on-device text-to-speech using Supertonic ONNX models with HuggingFace downloads, ONNX Runtime inference, and audio playback in the desktop app.

Key Changes

  • Rust Backend: New tts.rs module (780 lines) implements ONNX-based TTS inference with text preprocessing, duration prediction, denoising loop, and WAV encoding
  • Model Management: Downloads ~265MB models from HuggingFace on first use, stores in app data directory
  • React Context: TTSContext manages TTS state, model downloads, and audio playback lifecycle
  • UI Integration: Speaker button added to assistant messages (desktop only) with download dialog for first-time setup

Issues Found

  • Memory leaks in TTSContext: Blob URLs not revoked when audio interrupted or errors occur (lines 165-169, 199-203)
  • File integrity issue: Downloaded model files not flushed before completion check, risking partial file acceptance (line 674)

The implementation is well-structured and follows the project's patterns, but the memory leaks and file integrity issue need to be addressed before merge.

Confidence Score: 3/5

  • Safe to merge after fixing memory leaks and file flush issue
  • The implementation is solid and well-integrated, but has three critical bugs: two memory leaks in audio URL handling and one file integrity issue in downloads. These are straightforward to fix but could cause issues in production.
  • Pay close attention to frontend/src-tauri/src/tts.rs (file flush) and frontend/src/services/tts/TTSContext.tsx (memory leaks)

Important Files Changed

File Analysis

Filename Score Overview
frontend/src-tauri/src/tts.rs 3/5 New 780-line TTS implementation with ONNX inference, HuggingFace model downloads, and audio synthesis. Has file flush issue in download that could leave partial files.
frontend/src/services/tts/TTSContext.tsx 3/5 React context managing TTS state and audio playback. Has memory leaks where blob URLs aren't revoked when audio is stopped or errors occur.
frontend/src/components/TTSDownloadDialog.tsx 5/5 UI dialog for TTS model download with progress tracking. Clean implementation with proper state handling.
frontend/src/components/UnifiedChat.tsx 5/5 Added TTS button component and integrated into message list. Clean integration following existing patterns.

Sequence Diagram

sequenceDiagram
    participant User
    participant UnifiedChat
    participant TTSContext
    participant TTSDownloadDialog
    participant Rust as Tauri/Rust Backend
    participant HF as HuggingFace

    User->>UnifiedChat: Click speaker button
    UnifiedChat->>TTSContext: Check status
    
    alt Models not downloaded
        TTSContext->>UnifiedChat: Status: not_downloaded
        UnifiedChat->>TTSDownloadDialog: Open dialog
        User->>TTSDownloadDialog: Click Download
        TTSDownloadDialog->>TTSContext: startDownload()
        TTSContext->>Rust: invoke("tts_download_models")
        
        loop For each model file
            Rust->>HF: GET model file
            HF-->>Rust: Stream model data
            Rust->>Rust: Write to disk
            Rust-->>TTSContext: emit("tts-download-progress")
            TTSContext-->>TTSDownloadDialog: Update progress
        end
        
        Rust-->>TTSContext: Download complete
        TTSContext->>Rust: invoke("tts_load_models")
        Rust->>Rust: Load ONNX models into memory
        Rust-->>TTSContext: Models loaded
        TTSContext->>TTSContext: Set status to "ready"
    end
    
    alt Models ready
        User->>UnifiedChat: Click speaker button
        UnifiedChat->>TTSContext: speak(text, messageId)
        TTSContext->>Rust: invoke("tts_synthesize", {text})
        Rust->>Rust: Text preprocessing
        Rust->>Rust: ONNX inference (duration, text encoding)
        Rust->>Rust: Denoising loop (10 steps)
        Rust->>Rust: Vocoder generates audio
        Rust->>Rust: WAV encoding to base64
        Rust-->>TTSContext: {audio_base64, sample_rate}
        TTSContext->>TTSContext: base64 to Blob
        TTSContext->>TTSContext: Create Audio element
        TTSContext->>User: Play audio
    end
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

10 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +288 to +306
let sentence_re = Regex::new(r"([.!?])\s+").unwrap();
let paragraphs: Vec<&str> = para_re.split(text).collect();
let mut chunks = Vec::new();

for para in paragraphs {
let para = para.trim();
if para.is_empty() {
continue;
}

if para.len() <= max_len {
chunks.push(para.to_string());
continue;
}

// Split by sentence boundaries
let mut current = String::new();

for part in sentence_re.split(para) {
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Sentence splitting regex loses punctuation marks. The pattern r"([.!?])\s+" splits on punctuation but split() discards the captured group, causing sentences to lose their ending punctuation.

Suggested change
let sentence_re = Regex::new(r"([.!?])\s+").unwrap();
let paragraphs: Vec<&str> = para_re.split(text).collect();
let mut chunks = Vec::new();
for para in paragraphs {
let para = para.trim();
if para.is_empty() {
continue;
}
if para.len() <= max_len {
chunks.push(para.to_string());
continue;
}
// Split by sentence boundaries
let mut current = String::new();
for part in sentence_re.split(para) {
let sentence_re = Regex::new(r"[.!?]\s+").unwrap();

Or use split_inclusive() or manually handle the captured groups to preserve punctuation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src-tauri/src/tts.rs
Line: 288:306

Comment:
**logic:** Sentence splitting regex loses punctuation marks. The pattern `r"([.!?])\s+"` splits on punctuation but `split()` discards the captured group, causing sentences to lose their ending punctuation.

```suggestion
    let sentence_re = Regex::new(r"[.!?]\s+").unwrap();
```

Or use `split_inclusive()` or manually handle the captured groups to preserve punctuation.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +643 to +654
if file_path.exists() {
total_downloaded += expected_size;
let _ = app.emit(
"tts-download-progress",
DownloadProgress {
downloaded: total_downloaded,
total: TOTAL_MODEL_SIZE,
file_name: file_name.to_string(),
percent: (total_downloaded as f64 / TOTAL_MODEL_SIZE as f64) * 100.0,
},
);
continue;
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: File existence check doesn't verify file integrity. If a download was interrupted, partially downloaded files will be treated as complete and skipped.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src-tauri/src/tts.rs
Line: 643:654

Comment:
**logic:** File existence check doesn't verify file integrity. If a download was interrupted, partially downloaded files will be treated as complete and skipped.

How can I resolve this? If you propose a fix, please make it concise.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
frontend/src/components/UnifiedChat.tsx (1)

179-233: TTSButton implementation looks solid with proper environment check and state handling.

The component correctly:

  • Returns null when not in Tauri environment (line 193-195)
  • Handles different TTS states appropriately
  • Provides proper aria-labels for accessibility

However, there's a potential issue with the async handleClick function:

The speak call on line 207 is awaited but errors are not handled. Consider adding error handling:

  const handleClick = async () => {
    if (status === "not_downloaded" || status === "error") {
      onNeedsSetup();
      return;
    }

    if (status === "ready") {
      if (isThisPlaying) {
        stop();
      } else {
-        await speak(text, messageId);
+        try {
+          await speak(text, messageId);
+        } catch (error) {
+          console.error("TTS playback failed:", error);
+        }
      }
    }
  };
frontend/src-tauri/src/tts.rs (3)

115-219: Consider compiling regexes once to improve performance.

The preprocess_text function creates multiple Regex objects on every call. Since this function is called for each text chunk during synthesis, this causes repeated compilation overhead.

Consider using lazy_static or once_cell::sync::Lazy to compile regexes once:

use once_cell::sync::Lazy;

static EMOJI_PATTERN: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"[\x{1F600}-\x{1F64F}...]").unwrap()
});

static DIACRITICS_PATTERN: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"[\u{0302}...]").unwrap()
});

// ... other regexes

fn preprocess_text(text: &str) -> String {
    let mut text: String = text.nfkd().collect();
    text = EMOJI_PATTERN.replace_all(&text, "").to_string();
    // ...
}

This is especially important since chunk_text may split long messages into many chunks, each processed separately.


734-771: Mutex held during CPU-intensive synthesis may cause contention.

The mutex guard is held at line 739 and only released at line 759, after the potentially long-running synthesize() call completes. For longer texts, ONNX inference across multiple chunks could take several seconds, blocking all other TTS commands.

Consider whether the ONNX sessions truly need mutable access. If Session::run only requires &self, you could restructure to minimize lock duration:

// Clone what's needed, release lock quickly
let (tts_data, style) = {
    let guard = state.lock().map_err(|e| e.to_string())?;
    let style = guard.style.as_ref().ok_or("Voice style not loaded")?.clone();
    // If possible, clone or Arc the session references
    // ...
};

// Synthesize outside the lock
let audio = /* ... */;

Alternatively, if mutable access is truly required by ONNX Runtime, this is acceptable for now but document the limitation. For production, consider using tokio::task::spawn_blocking to avoid blocking the async runtime.


536-566: Consider configuring ONNX session options for better performance.

The ONNX sessions are created with default settings. For a better user experience, you might want to configure the execution:

use ort::session::SessionBuilder;

let dp_ort = SessionBuilder::new()?
    .with_intra_threads(4)?  // Limit thread usage
    .commit_from_file(models_dir.join("duration_predictor.onnx"))?;

This is optional but could help with:

  • Controlling CPU usage during inference
  • Potentially enabling GPU acceleration in the future
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ad67576 and fc94dfa.

⛔ Files ignored due to path filters (1)
  • frontend/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • frontend/src-tauri/Cargo.toml (1 hunks)
  • frontend/src-tauri/src/lib.rs (4 hunks)
  • frontend/src-tauri/src/pdf_extractor.rs (1 hunks)
  • frontend/src-tauri/src/tts.rs (1 hunks)
  • frontend/src/app.tsx (2 hunks)
  • frontend/src/components/TTSDownloadDialog.tsx (1 hunks)
  • frontend/src/components/UnifiedChat.tsx (8 hunks)
  • frontend/src/services/tts/TTSContext.tsx (1 hunks)
  • frontend/src/services/tts/index.ts (1 hunks)
  • justfile (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js,jsx}: Use 2-space indentation, double quotes, and a 100-character line limit for formatting
Use camelCase for variable and function names
Use try/catch with specific error types for error handling

Files:

  • frontend/src/services/tts/index.ts
  • frontend/src/components/TTSDownloadDialog.tsx
  • frontend/src/app.tsx
  • frontend/src/services/tts/TTSContext.tsx
  • frontend/src/components/UnifiedChat.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use strict TypeScript typing and avoid any when possible

Files:

  • frontend/src/services/tts/index.ts
  • frontend/src/components/TTSDownloadDialog.tsx
  • frontend/src/app.tsx
  • frontend/src/services/tts/TTSContext.tsx
  • frontend/src/components/UnifiedChat.tsx
🧠 Learnings (1)
📚 Learning: 2025-07-19T21:31:44.925Z
Learnt from: CR
Repo: OpenSecretCloud/Maple PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-07-19T21:31:44.925Z
Learning: Applies to src/**/*.{tsx} : Use React context for global state management when needed

Applied to files:

  • frontend/src/services/tts/TTSContext.tsx
🧬 Code graph analysis (4)
frontend/src/components/TTSDownloadDialog.tsx (1)
frontend/src/services/tts/TTSContext.tsx (1)
  • useTTS (251-257)
frontend/src/app.tsx (5)
frontend/src/services/tts/index.ts (1)
  • TTSProvider (1-1)
frontend/src/components/BillingServiceProvider.tsx (1)
  • BillingServiceProvider (5-14)
frontend/src/components/ProxyEventListener.tsx (1)
  • ProxyEventListener (7-58)
frontend/src/components/UpdateEventListener.tsx (1)
  • UpdateEventListener (11-65)
frontend/src/components/DeepLinkHandler.tsx (1)
  • DeepLinkHandler (9-109)
frontend/src/services/tts/TTSContext.tsx (1)
frontend/src/services/tts/index.ts (3)
  • TTSStatus (2-2)
  • TTSProvider (1-1)
  • useTTS (1-1)
frontend/src-tauri/src/lib.rs (1)
frontend/src-tauri/src/tts.rs (7)
  • new (86-88)
  • new (341-346)
  • tts_get_status (602-620)
  • tts_download_models (631-704)
  • tts_load_models (707-725)
  • tts_synthesize (735-771)
  • tts_unload_models (774-780)
⏰ 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). (5)
  • GitHub Check: build-ios
  • GitHub Check: build-linux
  • GitHub Check: build-android
  • GitHub Check: build-macos (universal-apple-darwin)
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (16)
justfile (1)

46-57: Desktop debug build targets look consistent and useful

The new debug and debug-no-cc recipes mirror the existing release targets and should help diagnose desktop issues without impacting current workflows.

frontend/src/services/tts/TTSContext.tsx (1)

60-72: Overall TTS provider shape and status lifecycle look solid

The context cleanly encapsulates TTS state (status, errors, progress, playback) and exposes a small surface (checkStatus, startDownload, speak, stop). Checking isTauri() once, defaulting to "not_available" off-Tauri, and auto-checking status on mount is consistent with the existing platform utilities and React-context usage.

frontend/src/app.tsx (1)

17-17: TTSProvider placement in the provider tree looks appropriate

Wrapping BillingServiceProvider (and thus ProxyEventListener, UpdateEventListener, DeepLinkHandler, and InnerApp) with TTSProvider ensures all consumers of useTTS sit under the context without altering existing provider ordering. This is a good integration point.

Also applies to: 102-109

frontend/src-tauri/Cargo.toml (1)

42-53: TTS dependency set looks coherent; please verify cross-platform builds and versions

The added crates (ONNX Runtime via ort, ndarray with rayon, reqwest with streaming, dirs, etc.) are consistent with a local Supertonic TTS pipeline. Since they’re in the shared [dependencies] section (not cfg-gated), they’ll be compiled for all targets, including Android/iOS.

Please verify:

  • That ort = "2.0.0-rc.7" and the other new crates build cleanly for your mobile targets.
  • That there are no unexpected size or licensing issues introduced by this set.

Consider adding cfg-gating or feature flags later if you decide TTS should remain desktop-only.

frontend/src-tauri/src/pdf_extractor.rs (1)

19-27: PDF extractor changes are formatting-only and safe

The adjustments here are purely stylistic (parameter layout and whitespace) and do not affect decoding, file-type branching, or response construction.

Also applies to: 33-38, 43-51

frontend/src/services/tts/index.ts (1)

1-2: Barrel re-exports for TTS are clean and useful

Re-exporting TTSProvider, useTTS, and TTSStatus from the TTS module simplifies imports for consumers and keeps the public surface centralized.

frontend/src-tauri/src/lib.rs (2)

5-8: TTS state management and command wiring on desktop look correct

Registering mod tts;, managing tts::TTSState::new(), and adding the TTS commands to the desktop invoke_handler cleanly integrates the backend TTS pipeline. Scoping these commands to the desktop builder matches the “desktop-only TTS” goal.

Also applies to: 39-54


387-399: Update-ready event emission and logging structure are clear

The app_handle.emit("update-ready", UpdateReadyPayload { ... }) block, along with the success/failure logging, is straightforward and keeps the updater behavior unchanged while improving observability.

frontend/src/components/TTSDownloadDialog.tsx (1)

1-160: TTS download/setup dialog behavior matches the context state machine well

The dialog cleanly maps TTSStatus into UX states (not available, error, processing, ready, initial), wires startDownload appropriately, and prevents accidental closure while work is in progress. The progress bar and messaging around the one-time ~MB download and local-only processing are clear.

frontend/src/components/UnifiedChat.tsx (3)

412-427: LGTM!

The MessageList signature update correctly adds the onTTSSetupOpen callback with proper typing. The integration follows the existing pattern for handling dialogs in this component.


626-633: LGTM!

The TTSButton integration mirrors the existing CopyButton pattern, reusing the same text extraction logic. The button is properly placed within the hover-reveal action group for assistant messages.


3023-3024: LGTM!

The TTS setup dialog follows the same pattern as other dialogs in the component (e.g., WebSearchInfoDialog, ContextLimitDialog). State management is consistent with the rest of the codebase.

frontend/src-tauri/src/tts.rs (4)

335-347: LGTM!

The TTSState design with Mutex<Self> is appropriate here. While using std::sync::Mutex in async contexts can be problematic if held across .await points, the current implementation correctly performs all mutex-guarded operations synchronously before any awaits.


281-333: Text chunking logic is functional but has the same regex compilation overhead.

The function works correctly for splitting text into manageable chunks. The regex objects (para_re, sentence_re) are recreated on each call - same recommendation as preprocess_text applies.

Note: The sentence boundary regex ([.!?])\s+ may not handle all edge cases (e.g., abbreviations like "Dr. Smith"), but this is acceptable for TTS where minor imperfections in chunk boundaries won't significantly affect output quality.


568-588: LGTM!

The WAV encoding function correctly clamps audio samples to prevent overflow and uses proper 16-bit PCM encoding. The in-memory buffer approach is efficient for the expected audio sizes.


706-725: LGTM!

The model loading function is straightforward. While it doesn't explicitly check if models are downloaded first, the error messages from load_tts_engine and load_voice_style will be informative if files are missing. The frontend's TTSContext guards against calling this before download completes.

Comment on lines +656 to +704

let url = format!("{}/{}", HUGGINGFACE_BASE_URL, url_path);
log::info!("Downloading TTS model: {}", file_name);

let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to download {}: {}", file_name, e))?;

if !response.status().is_success() {
return Err(format!(
"Failed to download {}: HTTP {}",
file_name,
response.status()
));
}

let mut file = File::create(&file_path)
.map_err(|e| format!("Failed to create file {}: {}", file_name, e))?;

let mut stream = response.bytes_stream();
let mut file_downloaded: u64 = 0;

while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Download error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Write error: {}", e))?;

file_downloaded += chunk.len() as u64;
let current_total = total_downloaded + file_downloaded;

let _ = app.emit(
"tts-download-progress",
DownloadProgress {
downloaded: current_total,
total: TOTAL_MODEL_SIZE,
file_name: file_name.to_string(),
percent: (current_total as f64 / TOTAL_MODEL_SIZE as f64) * 100.0,
},
);
}

total_downloaded += expected_size;
log::info!("Downloaded TTS model: {}", file_name);
}

Ok(())
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add request timeout and cleanup for partial downloads.

Two reliability concerns:

  1. No timeout on HTTP requests (line 660-664): If the HuggingFace server hangs, the download will block indefinitely.

  2. Partial files on failure: If the download fails mid-stream, a partial file remains on disk. The next tts_get_status call will see the file exists and report models as downloaded.

+ use std::time::Duration;

  let client = reqwest::Client::builder()
+     .timeout(Duration::from_secs(300))
+     .connect_timeout(Duration::from_secs(30))
      .build()
      .map_err(|e| format!("Failed to create HTTP client: {}", e))?;

For partial file cleanup:

+ let temp_path = file_path.with_extension("part");
- let mut file = File::create(&file_path)
+ let mut file = File::create(&temp_path)
      .map_err(|e| format!("Failed to create file {}: {}", file_name, e))?;

  // ... download loop ...

+ // Rename temp file to final path on success
+ fs::rename(&temp_path, &file_path)
+     .map_err(|e| format!("Failed to finalize {}: {}", file_name, e))?;

This prevents corrupted state where models_downloaded returns true for incomplete files.

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +73 to +227
const audioRef = useRef<HTMLAudioElement | null>(null);
const unlistenRef = useRef<(() => void) | null>(null);

// Check TTS status from Rust backend
const checkStatus = useCallback(async () => {
if (!isTauriEnv) {
setStatus("not_available");
return;
}

try {
const result = await invoke<TTSStatusResponse>("tts_get_status");
setTotalSizeMB(result.total_size_mb);

if (result.models_loaded) {
setStatus("ready");
} else if (result.models_downloaded) {
// Models downloaded but not loaded - load them
setStatus("loading");
try {
await invoke("tts_load_models");
setStatus("ready");
} catch (loadErr) {
console.error("Failed to load TTS models:", loadErr);
setStatus("error");
setError(loadErr instanceof Error ? loadErr.message : "Failed to load TTS models");
}
} else {
setStatus("not_downloaded");
}
} catch (err) {
console.error("Failed to check TTS status:", err);
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to check TTS status");
}
}, [isTauriEnv]);

// Auto-check status on mount if in Tauri
useEffect(() => {
if (isTauriEnv) {
checkStatus();
}
}, [isTauriEnv, checkStatus]);

const startDownload = useCallback(async () => {
if (!isTauriEnv) return;

try {
setStatus("downloading");
setDownloadProgress(0);
setDownloadDetail("Starting download...");
setError(null);

// Set up event listener for progress
const unlisten = await listen<DownloadProgress>("tts-download-progress", (event) => {
const { percent, file_name } = event.payload;
setDownloadProgress(percent);
setDownloadDetail(`Downloading ${file_name}...`);
});
unlistenRef.current = unlisten;

// Start the download
await invoke("tts_download_models");

// Clean up listener
unlisten();
unlistenRef.current = null;

// Load the models after download
setStatus("loading");
setDownloadDetail("Loading models...");
await invoke("tts_load_models");

setStatus("ready");
setDownloadDetail("");
} catch (err) {
console.error("TTS download failed:", err);
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to download TTS models");

// Clean up listener on error
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
}
}, [isTauriEnv]);

const speak = useCallback(
async (text: string, messageId: string) => {
if (!isTauriEnv || status !== "ready") return;

// Stop any currently playing audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}

try {
setIsPlaying(true);
setCurrentPlayingId(messageId);

const result = await invoke<TTSSynthesizeResponse>("tts_synthesize", { text });

// Create audio from base64
const audioBlob = base64ToBlob(result.audio_base64, "audio/wav");
const audioUrl = URL.createObjectURL(audioBlob);

const audio = new Audio(audioUrl);
audioRef.current = audio;

audio.onended = () => {
setIsPlaying(false);
setCurrentPlayingId(null);
URL.revokeObjectURL(audioUrl);
audioRef.current = null;
};

audio.onerror = () => {
setIsPlaying(false);
setCurrentPlayingId(null);
URL.revokeObjectURL(audioUrl);
audioRef.current = null;
};

await audio.play();
} catch (err) {
console.error("TTS synthesis failed:", err);
setIsPlaying(false);
setCurrentPlayingId(null);
}
},
[isTauriEnv, status]
);

const stop = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setIsPlaying(false);
setCurrentPlayingId(null);
}, []);

// Clean up on unmount
useEffect(() => {
return () => {
if (unlistenRef.current) {
unlistenRef.current();
}
if (audioRef.current) {
audioRef.current.pause();
}
};
}, []);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix audio object URL memory leak on stop or unmount

The speak callback creates an audio blob and object URL via URL.createObjectURL(), but only revokes it in the onended and onerror handlers. If stop() is called mid-playback or the provider unmounts while audio is playing, the URL is never revoked, causing memory leaks over repeated play/stop cycles.

Fix by tracking the URL in a ref and cleaning up properly in stop() and the unmount effect:

  const audioRef = useRef<HTMLAudioElement | null>(null);
+  const audioUrlRef = useRef<string | null>(null);
  const unlistenRef = useRef<(() => void) | null>(null);

In speak, store the URL and consolidate cleanup logic:

        const audioBlob = base64ToBlob(result.audio_base64, "audio/wav");
        const audioUrl = URL.createObjectURL(audioBlob);
+        audioUrlRef.current = audioUrl;

-        audio.onended = () => {
-          setIsPlaying(false);
-          setCurrentPlayingId(null);
-          URL.revokeObjectURL(audioUrl);
-          audioRef.current = null;
-        };
-
-        audio.onerror = () => {
-          setIsPlaying(false);
-          setCurrentPlayingId(null);
-          URL.revokeObjectURL(audioUrl);
-          audioRef.current = null;
-        };
+        const cleanupAudio = () => {
+          setIsPlaying(false);
+          setCurrentPlayingId(null);
+          if (audioUrlRef.current) {
+            URL.revokeObjectURL(audioUrlRef.current);
+            audioUrlRef.current = null;
+          }
+          audioRef.current = null;
+        };
+
+        audio.onended = cleanupAudio;
+        audio.onerror = cleanupAudio;

In stop(), revoke the URL before clearing state:

  const stop = useCallback(() => {
    if (audioRef.current) {
      audioRef.current.pause();
      audioRef.current = null;
    }
+    if (audioUrlRef.current) {
+      URL.revokeObjectURL(audioUrlRef.current);
+      audioUrlRef.current = null;
+    }
    setIsPlaying(false);
    setCurrentPlayingId(null);
  }, []);

In the unmount cleanup effect, also revoke the URL:

  useEffect(() => {
    return () => {
      if (unlistenRef.current) {
        unlistenRef.current();
      }
      if (audioRef.current) {
        audioRef.current.pause();
      }
+      if (audioUrlRef.current) {
+        URL.revokeObjectURL(audioUrlRef.current);
+        audioUrlRef.current = null;
+      }
    };
  }, []);

Optionally, also call invoke("tts_unload_models") in the unmount cleanup to free ONNX models from memory if long-lived app sessions or conditional mounting of this provider are expected.

🤖 Prompt for AI Agents
In frontend/src/services/tts/TTSContext.tsx around lines 73 to 227, the audio
object URL created in speak via URL.createObjectURL is only revoked in the
audio.onended/onerror handlers, which leaks memory when stop() or unmount
occurs; fix by storing the generated audioUrl in a ref (e.g., audioUrlRef),
centralize revocation logic into a small helper that revokes audioUrlRef.current
if set and clears it, call that helper inside stop() before clearing audioRef
and state, call it in the unmount cleanup effect (after pausing audio) and
ensure onended/onerror also use the same helper instead of directly revoking,
and optionally invoke("tts_unload_models") in the unmount cleanup to free ONNX
models.

Comment on lines +76 to +116
// Check TTS status from Rust backend
const checkStatus = useCallback(async () => {
if (!isTauriEnv) {
setStatus("not_available");
return;
}

try {
const result = await invoke<TTSStatusResponse>("tts_get_status");
setTotalSizeMB(result.total_size_mb);

if (result.models_loaded) {
setStatus("ready");
} else if (result.models_downloaded) {
// Models downloaded but not loaded - load them
setStatus("loading");
try {
await invoke("tts_load_models");
setStatus("ready");
} catch (loadErr) {
console.error("Failed to load TTS models:", loadErr);
setStatus("error");
setError(loadErr instanceof Error ? loadErr.message : "Failed to load TTS models");
}
} else {
setStatus("not_downloaded");
}
} catch (err) {
console.error("Failed to check TTS status:", err);
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to check TTS status");
}
}, [isTauriEnv]);

// Auto-check status on mount if in Tauri
useEffect(() => {
if (isTauriEnv) {
checkStatus();
}
}, [isTauriEnv, checkStatus]);

Copy link

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:

find . -name "lib.rs" -type f | head -20

Repository: OpenSecretCloud/Maple

Length of output: 96


🏁 Script executed:

find . -name "TTSContext.tsx" -type f

Repository: OpenSecretCloud/Maple

Length of output: 107


🏁 Script executed:

find . -type f -name "*.rs" | grep -E "(lib|main)" | head -20

Repository: OpenSecretCloud/Maple

Length of output: 129


🏁 Script executed:

cat -n ./frontend/src-tauri/src/lib.rs

Repository: OpenSecretCloud/Maple

Length of output: 20913


🏁 Script executed:

wc -l ./frontend/src-tauri/src/main.rs

Repository: OpenSecretCloud/Maple

Length of output: 99


🏁 Script executed:

head -100 ./frontend/src-tauri/src/main.rs

Repository: OpenSecretCloud/Maple

Length of output: 243


🏁 Script executed:

cat -n ./frontend/src/services/tts/TTSContext.tsx

Repository: OpenSecretCloud/Maple

Length of output: 9037


🏁 Script executed:

cat -n ./frontend/src-tauri/src/lib.rs

Repository: OpenSecretCloud/Maple

Length of output: 20913


🏁 Script executed:

find . -name "platform*" -type f | grep -E "\.(ts|tsx|js)$"

Repository: OpenSecretCloud/Maple

Length of output: 97


🏁 Script executed:

find ./frontend/src/utils -type f | head -20

Repository: OpenSecretCloud/Maple

Length of output: 300


🏁 Script executed:

cat -n ./frontend/src/utils/platform.ts

Repository: OpenSecretCloud/Maple

Length of output: 10878


🏁 Script executed:

cat -n ./frontend/src/utils/platform/index.ts

Repository: OpenSecretCloud/Maple

Length of output: 1419


🏁 Script executed:

rg -t ts -t tsx "isTauri|tts_get_status|tts_synthesize" ./frontend/src --max-count=50

Repository: OpenSecretCloud/Maple

Length of output: 92


🏁 Script executed:

rg "isTauri|tts_get_status|tts_synthesize" ./frontend/src -t ts --max-count=50

Repository: OpenSecretCloud/Maple

Length of output: 8101


🏁 Script executed:

rg "isTauri|tts_get_status|tts_synthesize" ./frontend/src -A 2 -B 2

Repository: OpenSecretCloud/Maple

Length of output: 32222


Replace isTauri() with isTauriDesktop() to prevent mobile TTS invocation errors

TTS commands (tts_get_status, tts_download_models, tts_load_models, tts_synthesize, tts_unload_models) are only registered in the desktop builder in main.rs (lines 40-54), while the mobile builder (lines 262-264) only exposes extract_document_content. Currently, TTSProvider uses isTauri() which returns true for both desktop and mobile Tauri environments. This causes tts_get_status to be invoked on mobile, resulting in "unknown command" errors that surface as an "error" status instead of "not_available".

Replace the isTauri() check with isTauriDesktop() throughout TTSContext.tsx (lines 62, 78, 112, 118, 163). The platform utility already provides this distinction and is used consistently by other desktop-only features like ProxyConfigSection.

🤖 Prompt for AI Agents
In frontend/src/services/tts/TTSContext.tsx around lines 62, 78, 112, 118 and
163, the code currently uses isTauri() which returns true for both desktop and
mobile Tauri and causes desktop-only TTS commands to be invoked on mobile;
replace all uses of isTauri() in this file with isTauriDesktop() so the TTS
checks, invokes (tts_get_status, tts_load_models, tts_download_models,
tts_synthesize, tts_unload_models) and auto-run effects only execute in the
desktop Tauri environment; ensure you import isTauriDesktop from the same
platform utility if not already imported and update the dependency arrays for
hooks if needed to reference the new identifier.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (4)
frontend/src/services/tts/TTSContext.tsx (2)

10-10: Use isTauriDesktop() instead of isTauri() to prevent mobile TTS errors.

According to past review comments, TTS commands are only registered in the desktop builder. Using isTauri() returns true for both desktop and mobile Tauri environments, which would cause "unknown command" errors on mobile when invoking tts_get_status.

Replace isTauri with isTauriDesktop throughout this file (lines 10, 62, 78, 112, 118, 163).

-import { isTauri } from "@/utils/platform";
+import { isTauriDesktop } from "@/utils/platform";

And update usage:

-  const isTauriEnv = isTauri();
+  const isTauriEnv = isTauriDesktop();

208-215: Memory leak: Audio object URL not revoked on stop() or unmount.

When stop() is called or the provider unmounts during playback, the object URL created in speak() is never revoked, causing memory leaks over repeated play/stop cycles.

Add a ref to track the URL and revoke it in stop() and the unmount effect:

  const audioRef = useRef<HTMLAudioElement | null>(null);
+  const audioUrlRef = useRef<string | null>(null);

In stop():

  const stop = useCallback(() => {
    if (audioRef.current) {
      audioRef.current.pause();
      audioRef.current = null;
    }
+    if (audioUrlRef.current) {
+      URL.revokeObjectURL(audioUrlRef.current);
+      audioUrlRef.current = null;
+    }
    setIsPlaying(false);
    setCurrentPlayingId(null);
  }, []);

And store the URL in speak():

  const audioUrl = URL.createObjectURL(audioBlob);
+  audioUrlRef.current = audioUrl;
frontend/src-tauri/src/tts.rs (2)

295-347: Sentence splitting loses punctuation marks.

The regex pattern r"([.!?])\s+" uses a capture group, but split() discards captured groups, causing sentences to lose their ending punctuation.

Consider using split_inclusive or adjusting the pattern:

-    let sentence_re = Regex::new(r"([.!?])\s+").unwrap();
+    let sentence_re = Regex::new(r"(?<=[.!?])\s+").unwrap();

Or manually preserve punctuation by iterating matches instead of splitting. This affects the naturalness of TTS output since sentences will be synthesized without proper endings.


650-668: Add request timeout and handle partial downloads.

Two reliability concerns from past reviews still apply:

  1. No HTTP timeout: If the server hangs, the download blocks indefinitely.

  2. Partial file on failure: If download fails mid-stream, a partial file remains. Next tts_get_status will report models as downloaded.

+ use std::time::Duration;

  let client = reqwest::Client::builder()
+     .timeout(Duration::from_secs(300))
+     .connect_timeout(Duration::from_secs(30))
      .build()
-     .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
+     .unwrap_or_else(|_| reqwest::Client::new());

For partial files, download to a .part file and rename on success:

+ let temp_path = file_path.with_extension("part");
- let mut file = File::create(&file_path)
+ let mut file = File::create(&temp_path)
      ...
+ // After successful download:
+ fs::rename(&temp_path, &file_path)?;
🧹 Nitpick comments (1)
frontend/src/components/UnifiedChat.tsx (1)

626-633: Consider extracting the text content logic to avoid duplication.

The same text extraction logic is duplicated between CopyButton and TTSButton. While acceptable for now, consider extracting this to a helper if more buttons are added.

const messageText = message.content
  .filter((p) => "text" in p && p.text)
  .map((p) => ("text" in p ? p.text : ""))
  .join("");
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fc94dfa and e607185.

⛔ Files ignored due to path filters (1)
  • frontend/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • frontend/src-tauri/Cargo.toml (1 hunks)
  • frontend/src-tauri/src/lib.rs (7 hunks)
  • frontend/src-tauri/src/pdf_extractor.rs (1 hunks)
  • frontend/src-tauri/src/tts.rs (1 hunks)
  • frontend/src/app.tsx (2 hunks)
  • frontend/src/components/TTSDownloadDialog.tsx (1 hunks)
  • frontend/src/components/UnifiedChat.tsx (8 hunks)
  • frontend/src/services/tts/TTSContext.tsx (1 hunks)
  • frontend/src/services/tts/index.ts (1 hunks)
  • justfile (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • frontend/src/app.tsx
  • frontend/src/services/tts/index.ts
  • justfile
  • frontend/src-tauri/Cargo.toml
  • frontend/src-tauri/src/pdf_extractor.rs
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js,jsx}: Use 2-space indentation, double quotes, and a 100-character line limit for formatting
Use camelCase for variable and function names
Use try/catch with specific error types for error handling

Files:

  • frontend/src/components/TTSDownloadDialog.tsx
  • frontend/src/components/UnifiedChat.tsx
  • frontend/src/services/tts/TTSContext.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use strict TypeScript typing and avoid any when possible

Files:

  • frontend/src/components/TTSDownloadDialog.tsx
  • frontend/src/components/UnifiedChat.tsx
  • frontend/src/services/tts/TTSContext.tsx
🧠 Learnings (1)
📚 Learning: 2025-07-19T21:31:44.925Z
Learnt from: CR
Repo: OpenSecretCloud/Maple PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-07-19T21:31:44.925Z
Learning: Applies to src/**/*.{tsx} : Use React context for global state management when needed

Applied to files:

  • frontend/src/services/tts/TTSContext.tsx
🧬 Code graph analysis (3)
frontend/src/components/TTSDownloadDialog.tsx (3)
frontend/src/services/tts/TTSContext.tsx (1)
  • useTTS (251-257)
frontend/src/components/ui/dialog.tsx (6)
  • Dialog (92-92)
  • DialogContent (97-97)
  • DialogHeader (98-98)
  • DialogTitle (100-100)
  • DialogDescription (101-101)
  • DialogFooter (99-99)
frontend/src/components/ui/button.tsx (1)
  • Button (62-62)
frontend/src/services/tts/TTSContext.tsx (1)
frontend/src/services/tts/index.ts (3)
  • TTSStatus (2-2)
  • TTSProvider (1-1)
  • useTTS (1-1)
frontend/src-tauri/src/lib.rs (1)
frontend/src-tauri/src/tts.rs (8)
  • init_tts_backend (20-30)
  • new (100-102)
  • new (355-360)
  • tts_get_status (616-634)
  • tts_download_models (645-718)
  • tts_load_models (721-739)
  • tts_synthesize (749-785)
  • tts_unload_models (788-794)
⏰ 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). (5)
  • GitHub Check: build-ios
  • GitHub Check: build-android
  • GitHub Check: build-macos (universal-apple-darwin)
  • GitHub Check: build-linux
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (11)
frontend/src/components/UnifiedChat.tsx (2)

179-233: TTSButton implementation looks solid.

The component properly:

  • Guards rendering with isTauriEnv check
  • Handles all TTS status states appropriately
  • Provides clear visual feedback (spinner during loading states, stop icon during playback)
  • Uses the correct disabled states during transitional operations

3023-3024: TTS dialog integration is clean.

The dialog is properly controlled via state and placed appropriately in the component tree.

frontend/src/services/tts/TTSContext.tsx (1)

259-267: Helper function implementation is correct.

The base64ToBlob function correctly decodes base64 and creates a Blob with the appropriate MIME type.

frontend/src/components/TTSDownloadDialog.tsx (3)

18-24: Dialog implementation is well-structured.

The component properly handles all TTS status states with appropriate UI feedback. The handleDownload wrapper is a simple passthrough, which is fine but could be inlined if no additional logic is needed.


70-78: Error display is correctly guarded.

The condition hasError && error ensures the error panel only shows when there's an actual error message to display, preventing empty error states.


93-105: Progress bar behavior is well-designed.

The progress bar correctly shows 100% during the loading phase (after download completes), and the percentage text is appropriately hidden during non-download states.

frontend/src-tauri/src/lib.rs (2)

396-407: Minor formatting changes to emit call.

These are style/formatting adjustments with no functional impact.


264-273: > Likely an incorrect or invalid review comment.

frontend/src-tauri/src/tts.rs (3)

765-767: Synthesis parameters are hardcoded as per design.

The hardcoded values (10 inference steps, 1.2x speed) match the PR description. Consider exposing these as optional parameters in the future for user customization.


550-580: TTS engine loading is straightforward and correct.

The ONNX session creation uses default builder settings. Consider adding thread configuration or optimization options if performance tuning is needed later.


18-30: Platform-specific TTS backend initialization is correct.

The conditional compilation properly selects tract for iOS/Android and ONNX Runtime for desktop. This aligns with the cross-platform architecture.

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

10 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

10 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +674 to +700
let mut file = File::create(&file_path)
.map_err(|e| format!("Failed to create file {}: {}", file_name, e))?;

let mut stream = response.bytes_stream();
let mut file_downloaded: u64 = 0;

while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Download error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Write error: {}", e))?;

file_downloaded += chunk.len() as u64;
let current_total = total_downloaded + file_downloaded;

let _ = app.emit(
"tts-download-progress",
DownloadProgress {
downloaded: current_total,
total: TOTAL_MODEL_SIZE,
file_name: file_name.to_string(),
percent: (current_total as f64 / TOTAL_MODEL_SIZE as f64) * 100.0,
},
);
}

total_downloaded += expected_size;
log::info!("Downloaded TTS model: {}", file_name);
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: File not flushed after download. If download completes but file buffer isn't flushed to disk before checking file_path.exists() on retry, partially written files could be skipped.

Add explicit flush:

Suggested change
let mut file = File::create(&file_path)
.map_err(|e| format!("Failed to create file {}: {}", file_name, e))?;
let mut stream = response.bytes_stream();
let mut file_downloaded: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Download error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Write error: {}", e))?;
file_downloaded += chunk.len() as u64;
let current_total = total_downloaded + file_downloaded;
let _ = app.emit(
"tts-download-progress",
DownloadProgress {
downloaded: current_total,
total: TOTAL_MODEL_SIZE,
file_name: file_name.to_string(),
percent: (current_total as f64 / TOTAL_MODEL_SIZE as f64) * 100.0,
},
);
}
total_downloaded += expected_size;
log::info!("Downloaded TTS model: {}", file_name);
let mut file = File::create(&file_path)
.map_err(|e| format!("Failed to create file {}: {}", file_name, e))?;
let mut stream = response.bytes_stream();
let mut file_downloaded: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Download error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Write error: {}", e))?;
file_downloaded += chunk.len() as u64;
let current_total = total_downloaded + file_downloaded;
let _ = app.emit(
"tts-download-progress",
DownloadProgress {
downloaded: current_total,
total: TOTAL_MODEL_SIZE,
file_name: file_name.to_string(),
percent: (current_total as f64 / TOTAL_MODEL_SIZE as f64) * 100.0,
},
);
}
file.flush().map_err(|e| format!("Failed to flush file {}: {}", file_name, e))?;
total_downloaded += expected_size;
log::info!("Downloaded TTS model: {}", file_name);
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src-tauri/src/tts.rs
Line: 674:700

Comment:
**logic:** File not flushed after download. If download completes but file buffer isn't flushed to disk before checking `file_path.exists()` on retry, partially written files could be skipped.

Add explicit flush:
```suggestion
        let mut file = File::create(&file_path)
            .map_err(|e| format!("Failed to create file {}: {}", file_name, e))?;

        let mut stream = response.bytes_stream();
        let mut file_downloaded: u64 = 0;

        while let Some(chunk) = stream.next().await {
            let chunk = chunk.map_err(|e| format!("Download error: {}", e))?;
            file.write_all(&chunk)
                .map_err(|e| format!("Write error: {}", e))?;

            file_downloaded += chunk.len() as u64;
            let current_total = total_downloaded + file_downloaded;

            let _ = app.emit(
                "tts-download-progress",
                DownloadProgress {
                    downloaded: current_total,
                    total: TOTAL_MODEL_SIZE,
                    file_name: file_name.to_string(),
                    percent: (current_total as f64 / TOTAL_MODEL_SIZE as f64) * 100.0,
                },
            );
        }

        file.flush().map_err(|e| format!("Failed to flush file {}: {}", file_name, e))?;

        total_downloaded += expected_size;
        log::info!("Downloaded TTS model: {}", file_name);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +165 to +169
// Stop any currently playing audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Memory leak: audioUrl from previous audio not revoked when stopping. The object URL created at line 179 is never cleaned up when interrupted.

Track and revoke previous URL:

Suggested change
// Stop any currently playing audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Stop any currently playing audio
if (audioRef.current) {
const oldUrl = audioRef.current.src;
audioRef.current.pause();
audioRef.current = null;
if (oldUrl && oldUrl.startsWith('blob:')) {
URL.revokeObjectURL(oldUrl);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/services/tts/TTSContext.tsx
Line: 165:169

Comment:
**logic:** Memory leak: `audioUrl` from previous audio not revoked when stopping. The object URL created at line 179 is never cleaned up when interrupted.

Track and revoke previous URL:
```suggestion
      // Stop any currently playing audio
      if (audioRef.current) {
        const oldUrl = audioRef.current.src;
        audioRef.current.pause();
        audioRef.current = null;
        if (oldUrl && oldUrl.startsWith('blob:')) {
          URL.revokeObjectURL(oldUrl);
        }
      }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +199 to +203
} catch (err) {
console.error("TTS synthesis failed:", err);
setIsPlaying(false);
setCurrentPlayingId(null);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Memory leak in catch block: audioUrl created at line 179 is not revoked when synthesis succeeds but audio playback fails.

Suggested change
} catch (err) {
console.error("TTS synthesis failed:", err);
setIsPlaying(false);
setCurrentPlayingId(null);
}
} catch (err) {
console.error("TTS synthesis failed:", err);
setIsPlaying(false);
setCurrentPlayingId(null);
// Clean up URL if it was created
if (audioRef.current?.src) {
URL.revokeObjectURL(audioRef.current.src);
}
audioRef.current = null;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/services/tts/TTSContext.tsx
Line: 199:203

Comment:
**logic:** Memory leak in catch block: `audioUrl` created at line 179 is not revoked when synthesis succeeds but audio playback fails.

```suggestion
      } catch (err) {
        console.error("TTS synthesis failed:", err);
        setIsPlaying(false);
        setCurrentPlayingId(null);
        // Clean up URL if it was created
        if (audioRef.current?.src) {
          URL.revokeObjectURL(audioRef.current.src);
        }
        audioRef.current = null;
      }
```

How can I resolve this? If you propose a fix, please make it concise.

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