-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add on-device TTS using Supertonic #335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
- 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>
|
Note Other AI code review bot(s) detectedCodeRabbit 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. WalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes
Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
Deploying maple with
|
| 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 |
Greptile OverviewGreptile SummaryAdds on-device text-to-speech using Supertonic ONNX models with HuggingFace downloads, ONNX Runtime inference, and audio playback in the desktop app. Key Changes
Issues Found
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
Important Files ChangedFile Analysis
Sequence DiagramsequenceDiagram
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
|
There was a problem hiding this 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
| 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) { |
There was a problem hiding this comment.
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.
| 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.| 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; |
There was a problem hiding this comment.
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.There was a problem hiding this 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
handleClickfunction:The
speakcall 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_textfunction creates multipleRegexobjects on every call. Since this function is called for each text chunk during synthesis, this causes repeated compilation overhead.Consider using
lazy_staticoronce_cell::sync::Lazyto 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_textmay 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::runonly 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_blockingto 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
⛔ Files ignored due to path filters (1)
frontend/src-tauri/Cargo.lockis 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.tsfrontend/src/components/TTSDownloadDialog.tsxfrontend/src/app.tsxfrontend/src/services/tts/TTSContext.tsxfrontend/src/components/UnifiedChat.tsx
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use strict TypeScript typing and avoid
anywhen possible
Files:
frontend/src/services/tts/index.tsfrontend/src/components/TTSDownloadDialog.tsxfrontend/src/app.tsxfrontend/src/services/tts/TTSContext.tsxfrontend/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 usefulThe 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 solidThe 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 appropriateWrapping
BillingServiceProvider(and thusProxyEventListener,UpdateEventListener,DeepLinkHandler, andInnerApp) withTTSProviderensures all consumers ofuseTTSsit 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 versionsThe added crates (ONNX Runtime via
ort,ndarraywithrayon,reqwestwith 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 safeThe 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 usefulRe-exporting
TTSProvider,useTTS, andTTSStatusfrom 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 correctRegistering
mod tts;, managingtts::TTSState::new(), and adding the TTS commands to the desktopinvoke_handlercleanly 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 clearThe
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 wellThe dialog cleanly maps
TTSStatusinto UX states (not available, error, processing, ready, initial), wiresstartDownloadappropriately, 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
onTTSSetupOpencallback 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
TTSStatedesign withMutex<Self>is appropriate here. While usingstd::sync::Mutexin async contexts can be problematic if held across.awaitpoints, 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 aspreprocess_textapplies.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_engineandload_voice_stylewill be informative if files are missing. The frontend'sTTSContextguards against calling this before download completes.
|
|
||
| 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(()) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add request timeout and cleanup for partial downloads.
Two reliability concerns:
-
No timeout on HTTP requests (line 660-664): If the HuggingFace server hangs, the download will block indefinitely.
-
Partial files on failure: If the download fails mid-stream, a partial file remains on disk. The next
tts_get_statuscall 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.
| 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(); | ||
| } | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| // 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]); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -name "lib.rs" -type f | head -20Repository: OpenSecretCloud/Maple
Length of output: 96
🏁 Script executed:
find . -name "TTSContext.tsx" -type fRepository: OpenSecretCloud/Maple
Length of output: 107
🏁 Script executed:
find . -type f -name "*.rs" | grep -E "(lib|main)" | head -20Repository: OpenSecretCloud/Maple
Length of output: 129
🏁 Script executed:
cat -n ./frontend/src-tauri/src/lib.rsRepository: OpenSecretCloud/Maple
Length of output: 20913
🏁 Script executed:
wc -l ./frontend/src-tauri/src/main.rsRepository: OpenSecretCloud/Maple
Length of output: 99
🏁 Script executed:
head -100 ./frontend/src-tauri/src/main.rsRepository: OpenSecretCloud/Maple
Length of output: 243
🏁 Script executed:
cat -n ./frontend/src/services/tts/TTSContext.tsxRepository: OpenSecretCloud/Maple
Length of output: 9037
🏁 Script executed:
cat -n ./frontend/src-tauri/src/lib.rsRepository: 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 -20Repository: OpenSecretCloud/Maple
Length of output: 300
🏁 Script executed:
cat -n ./frontend/src/utils/platform.tsRepository: OpenSecretCloud/Maple
Length of output: 10878
🏁 Script executed:
cat -n ./frontend/src/utils/platform/index.tsRepository: OpenSecretCloud/Maple
Length of output: 1419
🏁 Script executed:
rg -t ts -t tsx "isTauri|tts_get_status|tts_synthesize" ./frontend/src --max-count=50Repository: OpenSecretCloud/Maple
Length of output: 92
🏁 Script executed:
rg "isTauri|tts_get_status|tts_synthesize" ./frontend/src -t ts --max-count=50Repository: OpenSecretCloud/Maple
Length of output: 8101
🏁 Script executed:
rg "isTauri|tts_get_status|tts_synthesize" ./frontend/src -A 2 -B 2Repository: 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.
fc94dfa to
e607185
Compare
There was a problem hiding this 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: UseisTauriDesktop()instead ofisTauri()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 invokingtts_get_status.Replace
isTauriwithisTauriDesktopthroughout 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 inspeak()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, butsplit()discards captured groups, causing sentences to lose their ending punctuation.Consider using
split_inclusiveor 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:
No HTTP timeout: If the server hangs, the download blocks indefinitely.
Partial file on failure: If download fails mid-stream, a partial file remains. Next
tts_get_statuswill 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
.partfile 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
⛔ Files ignored due to path filters (1)
frontend/src-tauri/Cargo.lockis 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.tsxfrontend/src/components/UnifiedChat.tsxfrontend/src/services/tts/TTSContext.tsx
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use strict TypeScript typing and avoid
anywhen possible
Files:
frontend/src/components/TTSDownloadDialog.tsxfrontend/src/components/UnifiedChat.tsxfrontend/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
isTauriEnvcheck- 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
base64ToBlobfunction 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
handleDownloadwrapper 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 && errorensures 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.
There was a problem hiding this 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
e607185 to
fc94dfa
Compare
There was a problem hiding this 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
| 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); |
There was a problem hiding this comment.
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:
| 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.| // Stop any currently playing audio | ||
| if (audioRef.current) { | ||
| audioRef.current.pause(); | ||
| audioRef.current = null; | ||
| } |
There was a problem hiding this comment.
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:
| // 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.| } catch (err) { | ||
| console.error("TTS synthesis failed:", err); | ||
| setIsPlaying(false); | ||
| setCurrentPlayingId(null); | ||
| } |
There was a problem hiding this comment.
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.
| } 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.
Summary
Adds text-to-speech functionality to Maple desktop app using Supertonic, a fast on-device TTS engine.
Features
Technical Details
Testing
Notes
~/Library/Application Support/cloud.opensecret.maple/tts_models/on macOSSummary by CodeRabbit
New Features
Chores
UI
✏️ Tip: You can customize this high-level summary in your review settings.