diff --git a/FUTURE_ROADMAPS.md b/FUTURE_ROADMAPS.md index e955c69..a29f028 100644 --- a/FUTURE_ROADMAPS.md +++ b/FUTURE_ROADMAPS.md @@ -10,7 +10,7 @@ > numbers / missing files. Verify against the real repo with absolute > `C:\dev\Briefli\…` paths or terminal `Select-String`. -Last updated: 2026-07-17 +Last updated: 2026-07-18 --- @@ -31,12 +31,17 @@ Checked directly against `C:\dev\Briefli` on 2026-07-17. --- -## 2. Verified performance findings +## 2. Verified performance and recording reliability findings | Severity | Finding | Location | Notes | |----------|---------|----------|-------| | High | Transcript search is a full table scan | `src-tauri/src/database/repositories/transcript.rs` → `search_transcripts` uses `LOWER(t.transcript) LIKE '%q%'`, no index, `fetch_all` | Fix with SQLite **FTS5** — also unlocks the cross-meeting-memory moat. | | High | Audio buffer cloning in hot path | `src-tauri/src/audio/vad.rs` (`samples.to_vec()` + `drain().collect()` per chunk); `src-tauri/src/audio/incremental_saver.rs` (clones all buffered audio per checkpoint) | Allocation churn during long recordings. Prefer slices / reused buffers. | +| P0 — fixed 2026-07-18 | Failed audio finalization was reported as success | `audio/recording_manager.rs`, `recording_commands.rs`, `incremental_saver.rs`; `hooks/useRecordingStop.ts` | Save/FFmpeg/disk failures now propagate as typed recoverable errors; checkpoints survive until the full save succeeds; FFmpeg is cancellable and publishes atomically; transcript persistence continues with an error toast and durable audio-recovery record. | +| High — fixed 2026-07-18 | Transcription timeout detached the worker | `audio/recording_commands.rs` shutdown wait | Timed-out transcription worker is now `abort()`-ed and joined before model unload / finalization, so it can no longer run detached and race the save. | +| High — fixed 2026-07-18 | Frontend could skip SQLite save after transcription timeout | `hooks/useRecordingStop.ts` save gate | Save now proceeds whenever transcripts exist (`transcriptionComplete \|\| hasTranscripts`); a distinct warning toast flags possibly-incomplete transcription instead of silently dropping the meeting. | +| High — fixed 2026-07-18 | Stop could drop tail audio (fixed sleep) | `audio/recording_saver.rs` accumulation worker | Worker is now a stored `JoinHandle`; stop drains by awaiting worker after the pipeline closes the channel (30s cap), replacing the `is_saving` flag + 200ms sleep. Transcript-only recordings also finalize `metadata.json` status. | +| Medium — fixed 2026-07-18 | FFmpeg concat paths not escaped | `audio/incremental_saver.rs` | `ffmpeg_concat_escape` escapes single quotes at both concat call sites, so meeting names with apostrophes no longer fail finalize/recovery (unit-tested). | | Low (corrected) | `console.log` on every render | `components/TranscriptView.tsx` ~L111 | **Dead code** — `TranscriptView` is never rendered (no `, + sample_rate: u32, + channels: u16, + output_path: PathBuf, +) -> anyhow::Result<()> { + if data.is_empty() { + return Err(anyhow::anyhow!("No audio data provided for encoding")); + } + + let ffmpeg_path = find_ffmpeg_path().ok_or_else(|| { + anyhow::anyhow!("FFmpeg not found. Please install FFmpeg to save recordings.") + })?; + let output_name = output_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid checkpoint output path"))?; + let temp_output_path = output_path.with_file_name(format!(".{}.finalizing", output_name)); + let _ = std::fs::remove_file(&temp_output_path); + + let mut command = tokio::process::Command::new(ffmpeg_path); + command + .arg("-f") + .arg("f32le") + .arg("-ar") + .arg(sample_rate.to_string()) + .arg("-ac") + .arg(channels.to_string()) + .arg("-i") + .arg("pipe:0") + .arg("-c:a") + .arg("aac") + .arg("-b:a") + .arg("192k") + .arg("-profile:a") + .arg("aac_low") + .arg("-movflags") + .arg("+faststart") + .arg("-f") + .arg("mp4") + .arg(&temp_output_path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.as_std_mut().creation_flags(CREATE_NO_WINDOW); + } + + let mut ffmpeg = command.spawn()?; + let mut stdin = ffmpeg + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("Failed to open FFmpeg stdin"))?; + + if let Err(write_error) = stdin.write_all(bytemuck::cast_slice(&data)).await { + let _ = ffmpeg.kill().await; + let _ = std::fs::remove_file(&temp_output_path); + return Err(write_error.into()); + } + drop(stdin); + + let output = ffmpeg.wait_with_output().await?; + if !output.status.success() { + let _ = std::fs::remove_file(&temp_output_path); + return Err(anyhow::anyhow!( + "FFmpeg process failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let output_metadata = std::fs::metadata(&temp_output_path)?; + if output_metadata.len() == 0 { + let _ = std::fs::remove_file(&temp_output_path); + return Err(anyhow::anyhow!("FFmpeg produced an empty checkpoint")); + } + + if output_path.exists() { + std::fs::remove_file(&output_path)?; + } + std::fs::rename(&temp_output_path, &output_path)?; + + Ok(()) +} diff --git a/frontend/src-tauri/src/audio/incremental_saver.rs b/frontend/src-tauri/src/audio/incremental_saver.rs index dee17b0..2620a2a 100644 --- a/frontend/src-tauri/src/audio/incremental_saver.rs +++ b/frontend/src-tauri/src/audio/incremental_saver.rs @@ -1,4 +1,4 @@ -use super::encode::encode_single_audio; +use super::encode::encode_single_audio_async; use super::recording_state::AudioChunk; use anyhow::{anyhow, Result}; use log::{error, info, warn}; @@ -7,6 +7,63 @@ use std::path::PathBuf; use super::ffmpeg::find_ffmpeg_path; +/// Escape a path for use inside an FFmpeg concat demuxer list line: `file ''`. +/// The concat parser treats single quotes specially, so any embedded quote must be +/// escaped as `'\''` to keep otherwise-valid paths (e.g. names containing apostrophes) +/// working. +fn ffmpeg_concat_escape(path: &str) -> String { + path.replace('\'', "'\\''") +} + +async fn merge_checkpoint_files(concat_file_path: PathBuf, output_path: PathBuf) -> Result<()> { + let ffmpeg_path = find_ffmpeg_path().ok_or_else(|| { + anyhow!("FFmpeg not found. Please install FFmpeg to finalize recordings.") + })?; + let temp_output_path = output_path.with_file_name(".audio.finalizing.mp4"); + let _ = std::fs::remove_file(&temp_output_path); + + let mut command = tokio::process::Command::new(ffmpeg_path); + command + .arg("-f") + .arg("concat") + .arg("-safe") + .arg("0") + .arg("-i") + .arg(&concat_file_path) + .arg("-c") + .arg("copy") + .arg("-y") + .arg(&temp_output_path) + .kill_on_drop(true); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.as_std_mut().creation_flags(CREATE_NO_WINDOW); + } + + let ffmpeg_output = command.output().await?; + if !ffmpeg_output.status.success() { + let _ = std::fs::remove_file(&temp_output_path); + let stderr = String::from_utf8_lossy(&ffmpeg_output.stderr); + return Err(anyhow!("FFmpeg concat failed: {}", stderr)); + } + + let output_metadata = std::fs::metadata(&temp_output_path)?; + if output_metadata.len() == 0 { + let _ = std::fs::remove_file(&temp_output_path); + return Err(anyhow!("FFmpeg produced an empty audio file")); + } + + if output_path.exists() { + std::fs::remove_file(&output_path)?; + } + std::fs::rename(&temp_output_path, &output_path)?; + + Ok(()) +} + /// Audio data without device type (we only store mixed audio) #[derive(Clone)] struct AudioData { @@ -54,7 +111,7 @@ impl IncrementalAudioSaver { /// Add an audio chunk to the buffer /// Automatically saves a checkpoint when buffer reaches 30 seconds - pub fn add_chunk(&mut self, chunk: AudioChunk) -> Result<()> { + pub async fn add_chunk(&mut self, chunk: AudioChunk) -> Result<()> { let audio_data = AudioData { data: chunk.data, // sample_rate: chunk.sample_rate, @@ -67,7 +124,7 @@ impl IncrementalAudioSaver { // Save checkpoint when buffer reaches threshold (30 seconds) if total_samples >= self.checkpoint_interval_samples { - self.save_checkpoint()?; + self.save_checkpoint().await?; self.checkpoint_buffer.clear(); } @@ -75,7 +132,7 @@ impl IncrementalAudioSaver { } /// Save current buffer as a checkpoint file - fn save_checkpoint(&mut self) -> Result<()> { + async fn save_checkpoint(&mut self) -> Result<()> { // Concatenate all chunks in buffer let audio_data: Vec = self .checkpoint_buffer @@ -88,6 +145,7 @@ impl IncrementalAudioSaver { warn!("Attempted to save empty checkpoint, skipping"); return Ok(()); } + let sample_count = audio_data.len(); // Generate checkpoint filename let checkpoint_path = self @@ -95,27 +153,27 @@ impl IncrementalAudioSaver { .join(format!("audio_chunk_{:03}.mp4", self.checkpoint_count)); // Encode and save checkpoint - encode_single_audio( - bytemuck::cast_slice(&audio_data), + encode_single_audio_async( + audio_data, self.sample_rate, 1, // mono - &checkpoint_path, - )?; + checkpoint_path, + ) + .await?; - let duration_seconds = audio_data.len() as f32 / self.sample_rate as f32; + let duration_seconds = sample_count as f32 / self.sample_rate as f32; self.checkpoint_count += 1; info!( "Saved checkpoint {}: {:.2}s of audio ({} samples)", - self.checkpoint_count, - duration_seconds, - audio_data.len() + self.checkpoint_count, duration_seconds, sample_count ); Ok(()) } - /// Finalize the recording: save final checkpoint, merge all checkpoints, cleanup + /// Finalize the recording: save the final checkpoint and merge all checkpoints. + /// Checkpoints remain until the caller completes all related file writes. /// /// Returns the path to the final merged audio.mp4 file pub async fn finalize(&mut self) -> Result { @@ -127,7 +185,7 @@ impl IncrementalAudioSaver { "Saving final checkpoint with remaining {} chunks", self.checkpoint_buffer.len() ); - self.save_checkpoint()?; + self.save_checkpoint().await?; self.checkpoint_buffer.clear(); } @@ -141,18 +199,18 @@ impl IncrementalAudioSaver { let final_audio_path = self.meeting_folder.join("audio.mp4"); self.merge_checkpoints(&final_audio_path).await?; - // Clean up checkpoints directory - info!("Cleaning up {} checkpoint files", self.checkpoint_count); - if let Err(e) = std::fs::remove_dir_all(&self.checkpoints_dir) { - warn!("Failed to clean up checkpoints directory: {}", e); - // Non-fatal - user can manually delete - } - info!("Finalized recording: {}", final_audio_path.display()); Ok(final_audio_path) } + pub fn cleanup_checkpoints(&self) -> Result<()> { + if self.checkpoints_dir.exists() { + std::fs::remove_dir_all(&self.checkpoints_dir)?; + } + Ok(()) + } + /// Merge all checkpoint files into final audio.mp4 using FFmpeg concat /// Uses concat demuxer for fast merging without re-encoding async fn merge_checkpoints(&self, output: &PathBuf) -> Result<()> { @@ -180,57 +238,15 @@ impl IncrementalAudioSaver { // Use absolute path for FFmpeg (required for safe mode) let abs_path = checkpoint_path.canonicalize()?; - list_content.push_str(&format!("file '{}'\n", abs_path.display())); + list_content.push_str(&format!( + "file '{}'\n", + ffmpeg_concat_escape(&abs_path.to_string_lossy()) + )); } std::fs::write(&list_file, list_content)?; - let ffmpeg_path = find_ffmpeg_path().ok_or_else(|| { - anyhow!("FFmpeg not found. Please install FFmpeg to finalize recordings.") - })?; - info!("Using FFmpeg at: {:?}", ffmpeg_path); - - // Run FFmpeg concat command - // Using concat demuxer with copy codec for fast merging (no re-encoding) - - let mut command = std::process::Command::new(ffmpeg_path); - - command.args(&[ - "-f", - "concat", // Use concat demuxer - "-safe", - "0", // Allow absolute paths - "-i", - list_file.to_str().unwrap(), - "-c", - "copy", // Copy codec - no re-encoding! - "-y", // Overwrite output file - output.to_str().unwrap(), - ]); - - // Hide console window on Windows to prevent CMD popup during finalization - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - command.creation_flags(CREATE_NO_WINDOW); - } - - let ffmpeg_output = command.output()?; - - if !ffmpeg_output.status.success() { - let stderr = String::from_utf8_lossy(&ffmpeg_output.stderr); - error!("FFmpeg merge failed: {}", stderr); - return Err(anyhow!("FFmpeg concat failed: {}", stderr)); - } - - // Verify output file was created - if !output.exists() { - return Err(anyhow!( - "Merged audio file was not created: {}", - output.display() - )); - } + merge_checkpoint_files(list_file, output.clone()).await?; info!( "Successfully merged {} checkpoints → {}", @@ -330,7 +346,10 @@ pub async fn recover_audio_from_checkpoints( .path() .canonicalize() .map_err(|e| format!("Failed to canonicalize path: {}", e))?; - concat_content.push_str(&format!("file '{}'\n", path.display())); + concat_content.push_str(&format!( + "file '{}'\n", + ffmpeg_concat_escape(&path.to_string_lossy()) + )); } std::fs::write(&concat_file_path, concat_content) @@ -338,57 +357,22 @@ pub async fn recover_audio_from_checkpoints( // Run FFmpeg to merge chunks let output_path = folder_path.join("audio.mp4"); - let output_path_str = output_path - .to_str() - .ok_or("Invalid output path")? - .to_string(); - - let ffmpeg_path = find_ffmpeg_path() - .ok_or_else(|| "FFmpeg not found. Please install FFmpeg to recover audio.".to_string())?; - info!("Using FFmpeg at: {:?}", ffmpeg_path); - - let mut command = std::process::Command::new(ffmpeg_path); - - command.args(&[ - "-f", - "concat", - "-safe", - "0", - "-i", - concat_file_path.to_str().unwrap(), - "-c", - "copy", - "-y", // Overwrite if exists - &output_path_str, - ]); - - // Hide console window on Windows - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - command.creation_flags(CREATE_NO_WINDOW); - } - - let ffmpeg_result = command.output(); - - match ffmpeg_result { - Ok(output) if output.status.success() => { + match merge_checkpoint_files(concat_file_path.clone(), output_path.clone()).await { + Ok(()) => { // Clean up concat file let _ = std::fs::remove_file(concat_file_path); - info!("Successfully recovered audio: {}", output_path_str); + info!("Successfully recovered audio: {}", output_path.display()); Ok(AudioRecoveryStatus { status: "success".to_string(), chunk_count, estimated_duration_seconds: estimated_duration, - audio_file_path: Some(output_path_str), + audio_file_path: Some(output_path.to_string_lossy().to_string()), message: format!("Successfully recovered {} audio chunks", chunk_count), }) } - Ok(output) => { - let error = String::from_utf8_lossy(&output.stderr); + Err(error) => { error!("FFmpeg recovery failed: {}", error); Ok(AudioRecoveryStatus { status: "failed".to_string(), @@ -398,16 +382,6 @@ pub async fn recover_audio_from_checkpoints( message: format!("FFmpeg failed: {}", error), }) } - Err(e) => { - error!("Failed to run FFmpeg: {}", e); - Ok(AudioRecoveryStatus { - status: "failed".to_string(), - chunk_count, - estimated_duration_seconds: estimated_duration, - audio_file_path: None, - message: format!("Failed to run FFmpeg: {}", e), - }) - } } } @@ -478,7 +452,7 @@ mod tests { chunk_id: i as u64, device_type: DeviceType::Microphone, }; - saver.add_chunk(chunk).unwrap(); + saver.add_chunk(chunk).await.unwrap(); } // Verify 2 checkpoints created @@ -488,7 +462,8 @@ mod tests { let final_path = saver.finalize().await.unwrap(); assert!(final_path.exists()); - // Verify checkpoints directory deleted + assert!(meeting_folder.join(".checkpoints").exists()); + saver.cleanup_checkpoints().unwrap(); assert!(!meeting_folder.join(".checkpoints").exists()); } @@ -509,4 +484,32 @@ mod tests { .to_string() .contains("No audio checkpoints")); } + + #[test] + fn concat_escape_handles_single_quotes() { + // A path with no quotes is unchanged. + assert_eq!(ffmpeg_concat_escape("C:/meetings/audio.mp4"), "C:/meetings/audio.mp4"); + // A single quote is escaped as '\'' so the concat line stays valid. + assert_eq!( + ffmpeg_concat_escape("C:/meetings/Karan's Review/audio.mp4"), + "C:/meetings/Karan'\\''s Review/audio.mp4" + ); + } + + #[tokio::test] + async fn failed_finalization_preserves_checkpoints() { + let temp_dir = tempdir().unwrap(); + let meeting_folder = temp_dir.path().join("Failed_Finalization"); + let checkpoints_dir = meeting_folder.join(".checkpoints"); + std::fs::create_dir_all(&checkpoints_dir).unwrap(); + std::fs::write(checkpoints_dir.join("audio_chunk_000.mp4"), b"checkpoint").unwrap(); + + let mut saver = IncrementalAudioSaver::new(meeting_folder, 48000).unwrap(); + saver.checkpoint_count = 2; + + let result = saver.finalize().await; + + assert!(result.is_err()); + assert!(checkpoints_dir.join("audio_chunk_000.mp4").exists()); + } } diff --git a/frontend/src-tauri/src/audio/recording_commands.rs b/frontend/src-tauri/src/audio/recording_commands.rs index 7073b49..3ad6482 100644 --- a/frontend/src-tauri/src/audio/recording_commands.rs +++ b/frontend/src-tauri/src/audio/recording_commands.rs @@ -58,6 +58,36 @@ pub struct TranscriptionStatus { pub last_activity_ms: u64, } +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum StopRecordingError { + StopFailed { + message: String, + }, + RecordingSaveFailed { + message: String, + folder_path: Option, + recovery_available: bool, + audio_file_available: bool, + }, +} + +impl StopRecordingError { + pub fn recording_stopped(&self) -> bool { + matches!(self, Self::RecordingSaveFailed { .. }) + } +} + +impl std::fmt::Display for StopRecordingError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StopFailed { message } | Self::RecordingSaveFailed { message, .. } => { + formatter.write_str(message) + } + } + } +} + // ============================================================================ // RECORDING COMMANDS // ============================================================================ @@ -506,7 +536,7 @@ pub async fn start_recording_with_devices_and_meeting( pub async fn stop_recording( app: AppHandle, _args: RecordingArgs, -) -> Result<(), String> { +) -> Result<(), StopRecordingError> { info!( "🛑 Starting optimized recording shutdown - ensuring ALL transcript chunks are preserved" ); @@ -553,7 +583,9 @@ pub async fn stop_recording( } Err(e) => { error!("❌ Failed to stop audio streams: {}", e); - return Err(format!("Failed to stop audio streams: {}", e)); + return Err(StopRecordingError::StopFailed { + message: format!("Failed to stop audio streams: {}", e), + }); } } @@ -583,7 +615,7 @@ pub async fn stop_recording( global_task.take() }; - if let Some(task_handle) = transcription_task { + if let Some(mut task_handle) = transcription_task { info!("⏳ Waiting for ALL transcription chunks to be processed (no timeout - preserving every chunk)"); // Enhanced progress monitoring during shutdown @@ -612,7 +644,7 @@ pub async fn stop_recording( // Wait up to 10 minutes for transcription completion to prevent indefinite hangs match tokio::time::timeout( tokio::time::Duration::from_secs(600), // 10 minutes max - task_handle, + &mut task_handle, ) .await { @@ -624,8 +656,11 @@ pub async fn stop_recording( // Continue anyway - the worker may have processed most chunks } Err(_) => { - warn!("⏱️ Transcription timeout (10 minutes) reached, continuing shutdown to prevent indefinite hang"); - // Continue shutdown even on timeout - better to lose some chunks than hang forever + warn!("⏱️ Transcription timeout (10 minutes) reached; aborting the transcription worker before continuing shutdown"); + // Abort and join so the worker cannot keep running (and racing model + // unload / finalization) after we proceed. + task_handle.abort(); + let _ = task_handle.await; } } @@ -845,40 +880,42 @@ pub async fn stop_recording( ); // Perform final cleanup with the manager if available - let (meeting_folder, meeting_name) = if let Some(mut manager) = manager_for_cleanup { - info!("🧹 Performing final cleanup and saving recording data"); - - // Extract meeting info BEFORE async operations - let meeting_folder = manager.get_meeting_folder(); - let meeting_name = manager.get_meeting_name(); - - match tokio::time::timeout( - tokio::time::Duration::from_secs(300), // 5 minutes max for file I/O - manager.save_recording_only(&app), - ) - .await - { - Ok(Ok(_)) => { - info!("✅ Recording data saved successfully during cleanup"); - } - Ok(Err(e)) => { - warn!( - "⚠️ Error during recording cleanup (transcripts preserved): {}", - e - ); - // Don't fail shutdown - transcripts are already preserved - } - Err(_) => { - warn!("⏱️ File I/O timeout (5 minutes) reached during save, continuing shutdown"); - // Don't fail shutdown - transcripts are already preserved - } - } + let (meeting_folder, meeting_name, audio_save_error) = + if let Some(mut manager) = manager_for_cleanup { + info!("🧹 Performing final cleanup and saving recording data"); + + // Extract meeting info BEFORE async operations + let meeting_folder = manager.get_meeting_folder(); + let meeting_name = manager.get_meeting_name(); + + let audio_save_error = match tokio::time::timeout( + tokio::time::Duration::from_secs(300), // 5 minutes max for file I/O + manager.save_recording_only(&app), + ) + .await + { + Ok(Ok(_)) => { + info!("✅ Recording data saved successfully during cleanup"); + None + } + Ok(Err(e)) => { + error!( + "❌ Recording audio finalization failed; checkpoints preserved: {}", + e + ); + Some(e.to_string()) + } + Err(_) => { + error!("⏱️ Recording audio finalization timed out; checkpoints preserved"); + Some("Audio finalization timed out after 5 minutes".to_string()) + } + }; - (meeting_folder, meeting_name) - } else { - info!("ℹ️ No recording manager available for cleanup"); - (None, None) - }; + (meeting_folder, meeting_name, audio_save_error) + } else { + info!("ℹ️ No recording manager available for cleanup"); + (None, None, None) + }; // Set recording flag to false info!("🔍 Setting IS_RECORDING to false"); @@ -892,6 +929,28 @@ pub async fn stop_recording( _ => (None, None), }; + let recovery_available = meeting_folder + .as_ref() + .map(|folder| { + let checkpoints_dir = folder.join(".checkpoints"); + std::fs::read_dir(checkpoints_dir) + .map(|entries| { + entries.filter_map(|entry| entry.ok()).any(|entry| { + entry + .path() + .extension() + .and_then(|extension| extension.to_str()) + == Some("mp4") + }) + }) + .unwrap_or(false) + }) + .unwrap_or(false); + let audio_file_available = meeting_folder + .as_ref() + .map(|folder| folder.join("audio.mp4").is_file()) + .unwrap_or(false); + info!("📤 Preparing recording metadata for frontend save"); info!(" folder_path: {:?}", folder_path_str); info!(" meeting_name: {:?}", meeting_name_str); @@ -899,30 +958,59 @@ pub async fn stop_recording( // Database save removed - frontend will handle this after receiving all transcripts info!("ℹ️ Skipping database save in Rust - frontend will save after all transcripts received"); - // Step 5: Complete shutdown - let _ = app.emit( - "recording-shutdown-progress", + // Step 5: Complete shutdown, accurately reporting any final save failure + let progress_payload = if audio_save_error.is_some() { + serde_json::json!({ + "stage": "save_failed", + "message": "Recording stopped, but the audio file could not be finalized", + "progress": 100, + "recovery_available": recovery_available + }) + } else { serde_json::json!({ "stage": "complete", "message": "Recording stopped successfully", "progress": 100 - }), - ); + }) + }; + let _ = app.emit("recording-shutdown-progress", progress_payload); // Emit final stop event with folder_path and meeting_name for frontend to save app.emit( "recording-stopped", serde_json::json!({ - "message": "Recording stopped - frontend will save after all transcripts received", + "message": if audio_save_error.is_some() { + "Recording stopped, but audio finalization failed" + } else { + "Recording stopped - frontend will save after all transcripts received" + }, "folder_path": folder_path_str, - "meeting_name": meeting_name_str + "meeting_name": meeting_name_str, + "audio_save_error": audio_save_error, + "recovery_available": recovery_available, + "audio_file_available": audio_file_available }), ) - .map_err(|e| e.to_string())?; + .map_err(|e| StopRecordingError::StopFailed { + message: e.to_string(), + })?; // Update tray menu to reflect stopped state crate::tray::update_tray_menu(&app); + if let Some(message) = audio_save_error { + error!( + "Recording stopped with a recoverable save failure: {}", + message + ); + return Err(StopRecordingError::RecordingSaveFailed { + message, + folder_path: folder_path_str, + recovery_available, + audio_file_available, + }); + } + info!("🎉 Recording stopped successfully with ZERO transcript chunks lost"); Ok(()) } diff --git a/frontend/src-tauri/src/audio/recording_manager.rs b/frontend/src-tauri/src/audio/recording_manager.rs index 8a8a812..7b5609b 100644 --- a/frontend/src-tauri/src/audio/recording_manager.rs +++ b/frontend/src-tauri/src/audio/recording_manager.rs @@ -329,7 +329,7 @@ impl RecordingManager { } Err(e) => { error!("Failed to save recording: {}", e); - // Don't fail the stop operation if saving fails + return Err(anyhow::anyhow!(e)); } } @@ -375,7 +375,7 @@ impl RecordingManager { } Err(e) => { error!("Failed to save recording: {}", e); - // Don't fail the stop operation if saving fails + return Err(anyhow::anyhow!(e)); } } diff --git a/frontend/src-tauri/src/audio/recording_saver.rs b/frontend/src-tauri/src/audio/recording_saver.rs index 665af1e..c9eea30 100644 --- a/frontend/src-tauri/src/audio/recording_saver.rs +++ b/frontend/src-tauri/src/audio/recording_saver.rs @@ -54,7 +54,7 @@ pub struct RecordingSaver { metadata: Option, transcript_segments: Arc>>, chunk_receiver: Option>, - is_saving: Arc>, + accumulation_task: Option>, } impl RecordingSaver { @@ -66,7 +66,7 @@ impl RecordingSaver { metadata: None, transcript_segments: Arc::new(Mutex::new(Vec::new())), chunk_receiver: None, - is_saving: Arc::new(Mutex::new(false)), + accumulation_task: None, } } @@ -188,54 +188,43 @@ impl RecordingSaver { } } - // Start accumulation task - let is_saving_clone = self.is_saving.clone(); + // Start accumulation task. + // + // The worker processes every chunk it receives and stops only when the channel + // closes. On shutdown the pipeline is torn down first, which drops the recording + // sender and closes this channel, so awaiting the worker guarantees all queued + // audio is drained before finalization instead of racing a boolean flag. let incremental_saver_arc = self.incremental_saver.clone(); let save_audio = auto_save; if let Some(mut receiver) = self.chunk_receiver.take() { - tokio::spawn(async move { + let handle = tokio::spawn(async move { info!( "Recording saver accumulation task started (save_audio: {})", save_audio ); while let Some(chunk) = receiver.recv().await { - // Check if we should continue - let should_continue = if let Ok(is_saving) = is_saving_clone.lock() { - *is_saving - } else { - false - }; - - if !should_continue { - break; - } - // Only process audio chunks if auto_save is enabled if save_audio { // Add chunk to incremental saver if let Some(saver_arc) = &incremental_saver_arc { let mut saver_guard = saver_arc.lock().await; - if let Err(e) = saver_guard.add_chunk(chunk) { + if let Err(e) = saver_guard.add_chunk(chunk).await { error!("Failed to add chunk to incremental saver: {}", e); } } else { error!("Incremental saver not available while accumulating"); } - } else { - // auto_save is false: discard audio chunk (no-op) - // Transcription already happened in the pipeline before this point } + // else auto_save disabled: chunk is discarded (transcription already + // happened earlier in the pipeline). } info!("Recording saver accumulation task ended"); }); - } - // Set saving flag - if let Ok(mut is_saving) = self.is_saving.lock() { - *is_saving = true; + self.accumulation_task = Some(handle); } sender @@ -394,6 +383,39 @@ impl RecordingSaver { } } + /// Update the on-disk metadata to a completed state with the final duration. + /// + /// Writes `metadata.json` only; the in-memory copy is intentionally left untouched + /// because the saver is dropped right after stopping. + fn finalize_metadata_status(&self, recording_duration: Option) -> Result<(), String> { + if let (Some(folder), Some(mut metadata)) = (&self.meeting_folder, self.metadata.clone()) { + metadata.status = "completed".to_string(); + metadata.completed_at = Some(chrono::Utc::now().to_rfc3339()); + + // Use actual recording duration from RecordingState (more accurate than transcript segments) + // Falls back to last transcript segment if duration not provided + metadata.duration_seconds = recording_duration.or_else(|| { + if let Ok(segments) = self.transcript_segments.lock() { + segments.last().map(|seg| seg.audio_end_time) + } else { + None + } + }); + + if let Err(e) = self.write_metadata(folder, &metadata) { + error!("❌ Failed to update metadata to completed: {}", e); + return Err(format!("Failed to update metadata: {}", e)); + } + + info!( + "✅ Metadata updated with duration: {:?}s", + metadata.duration_seconds + ); + } + + Ok(()) + } + /// Stop and save using incremental saving approach /// /// # Arguments @@ -406,19 +428,30 @@ impl RecordingSaver { ) -> Result, String> { info!("Stopping recording saver"); - // Stop accumulation - if let Ok(mut is_saving) = self.is_saving.lock() { - *is_saving = false; + // Drain the accumulation worker. The pipeline sender was already dropped during + // stream/pipeline shutdown, so the channel is closed; awaiting the worker + // guarantees every queued chunk was written before we finalize. This replaces a + // fragile fixed-duration sleep that could drop tail audio. + if let Some(task) = self.accumulation_task.take() { + match tokio::time::timeout(tokio::time::Duration::from_secs(30), task).await { + Ok(Ok(())) => info!("✅ Accumulation worker drained all queued audio chunks"), + Ok(Err(e)) => warn!("⚠️ Accumulation worker join error: {}", e), + Err(_) => { + warn!("⏱️ Accumulation worker did not finish draining within 30s; continuing") + } + } } - // Give time for final chunks - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - // Check if incremental saver exists (indicates auto_save was enabled) let should_save_audio = self.incremental_saver.is_some(); if !should_save_audio { info!("⚠️ No audio saver initialized (auto-save was disabled) - skipping audio finalization"); + // Even without audio, finalize the on-disk metadata so transcript-only + // recordings are not left permanently in the "recording" state. + if let Err(e) = self.finalize_metadata_status(recording_duration) { + warn!("Transcript-only recording saved, but metadata finalization failed: {}", e); + } info!("✅ Transcripts and metadata already saved incrementally"); return Ok(None); } @@ -464,30 +497,7 @@ impl RecordingSaver { } // Update metadata to completed status with actual recording duration - if let (Some(folder), Some(mut metadata)) = (&self.meeting_folder, self.metadata.clone()) { - metadata.status = "completed".to_string(); - metadata.completed_at = Some(chrono::Utc::now().to_rfc3339()); - - // Use actual recording duration from RecordingState (more accurate than transcript segments) - // Falls back to last transcript segment if duration not provided - metadata.duration_seconds = recording_duration.or_else(|| { - if let Ok(segments) = self.transcript_segments.lock() { - segments.last().map(|seg| seg.audio_end_time) - } else { - None - } - }); - - if let Err(e) = self.write_metadata(folder, &metadata) { - error!("❌ Failed to update metadata to completed: {}", e); - return Err(format!("Failed to update metadata: {}", e)); - } - - info!( - "✅ Metadata updated with duration: {:?}s", - metadata.duration_seconds - ); - } + self.finalize_metadata_status(recording_duration)?; // Emit save event with audio and transcript paths let save_event = serde_json::json!({ @@ -508,6 +518,16 @@ impl RecordingSaver { segments.clear(); } + if let Some(saver_arc) = &self.incremental_saver { + let saver = saver_arc.lock().await; + if let Err(e) = saver.cleanup_checkpoints() { + warn!( + "Final recording saved, but checkpoint cleanup failed: {}", + e + ); + } + } + Ok(Some(final_audio_path.to_string_lossy().to_string())) } diff --git a/frontend/src-tauri/src/tray.rs b/frontend/src-tauri/src/tray.rs index 7f74655..b5448e8 100644 --- a/frontend/src-tauri/src/tray.rs +++ b/frontend/src-tauri/src/tray.rs @@ -99,8 +99,17 @@ fn toggle_recording_handler(app: &AppHandle) { } Err(e) => { log::error!("Tray toggle: Failed to stop recording: {}", e); - // Revert tray state on error - update_tray_menu_async(&app_clone).await; + if e.recording_stopped() { + if let Err(emit_error) = app_clone.emit("recording-stop-complete", true) { + log::error!( + "Tray toggle: Failed to emit post-processing event after save failure: {}", + emit_error + ); + } + } else { + // Revert tray state only when audio capture itself could not stop + update_tray_menu_async(&app_clone).await; + } } } } else { @@ -195,8 +204,17 @@ fn stop_recording_handler(app: &AppHandle) { } Err(e) => { log::error!("Tray: Failed to stop recording: {}", e); - // Revert tray state on error - update_tray_menu_async(&app_clone).await; + if e.recording_stopped() { + if let Err(emit_error) = app_clone.emit("recording-stop-complete", true) { + log::error!( + "Tray: Failed to emit post-processing event after save failure: {}", + emit_error + ); + } + } else { + // Revert tray state only when audio capture itself could not stop + update_tray_menu_async(&app_clone).await; + } } } }); diff --git a/frontend/src/components/RecordingControls.tsx b/frontend/src/components/RecordingControls.tsx index 39a7bd3..87635fb 100644 --- a/frontend/src/components/RecordingControls.tsx +++ b/frontend/src/components/RecordingControls.tsx @@ -11,6 +11,15 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import Analytics from '@/lib/analytics'; import { useRecordingState } from '@/contexts/RecordingStateContext'; +const isRecordingSaveFailure = (error: unknown): boolean => { + if (typeof error === 'object' && error !== null && 'kind' in error) { + return (error as { kind?: string }).kind === 'recording_save_failed'; + } + + const message = error instanceof Error ? error.message : String(error); + return message.includes('recording_save_failed'); +}; + interface RecordingControlsProps { isRecording: boolean; barHeights: string[]; @@ -160,6 +169,7 @@ export const RecordingControls: React.FC = ({ onRecordingStop(true); } catch (error) { console.error('Failed to stop recording:', error); + const recordingStoppedWithSaveFailure = isRecordingSaveFailure(error); if (error instanceof Error) { console.error('Error details:', { message: error.message, @@ -177,7 +187,7 @@ export const RecordingControls: React.FC = ({ } } setIsProcessing(false); - onRecordingStop(false); + onRecordingStop(recordingStoppedWithSaveFailure); } finally { setIsStopping(false); } diff --git a/frontend/src/hooks/useRecordingStop.ts b/frontend/src/hooks/useRecordingStop.ts index d16c663..3f0d803 100644 --- a/frontend/src/hooks/useRecordingStop.ts +++ b/frontend/src/hooks/useRecordingStop.ts @@ -7,6 +7,8 @@ import { useSidebar } from '@/components/Sidebar/SidebarProvider'; import { useRecordingState, RecordingStatus } from '@/contexts/RecordingStateContext'; import { storageService } from '@/services/storageService'; import { transcriptService } from '@/services/transcriptService'; +import { indexedDBService } from '@/services/indexedDBService'; +import { RecordingStoppedPayload } from '@/services/recordingService'; import Analytics from '@/lib/analytics'; import { applyPinnedSummaryLanguageToMeeting, @@ -57,6 +59,7 @@ export function useRecordingStop( clearTranscripts, meetingTitle, markMeetingAsSaved, + currentMeetingId, } = useTranscripts(); const { @@ -74,32 +77,42 @@ export function useRecordingStop( // Promise to track recording-stopped event data (fixes race condition with recording-stop-complete) const recordingStoppedDataRef = useRef | null>(null); + const resolveRecordingStoppedDataRef = useRef<(() => void) | null>(null); + const recordingStoppedPayloadRef = useRef(null); // Set up recording-stopped listener for meeting navigation useEffect(() => { let unlistenFn: (() => void) | undefined; + let unlistenStartedFn: (() => void) | undefined; + + const prepareForRecordingStopped = () => { + recordingStoppedPayloadRef.current = null; + sessionStorage.removeItem('last_recording_folder_path'); + sessionStorage.removeItem('last_recording_meeting_name'); + recordingStoppedDataRef.current = new Promise((resolve) => { + resolveRecordingStoppedDataRef.current = resolve; + }); + }; + + prepareForRecordingStopped(); const setupRecordingStoppedListener = async () => { try { console.log('Setting up recording-stopped listener for navigation...'); - unlistenFn = await listen<{ - message: string; - folder_path?: string; - meeting_name?: string; - }>('recording-stopped', async (event) => { - // Create promise that resolves when sessionStorage is set (prevents race condition) - recordingStoppedDataRef.current = (async () => { - const { folder_path, meeting_name } = event.payload; - - // Store folder_path and meeting_name for later use in handleRecordingStop - if (folder_path) { - sessionStorage.setItem('last_recording_folder_path', folder_path); - } - if (meeting_name) { - sessionStorage.setItem('last_recording_meeting_name', meeting_name); - } - })(); + unlistenStartedFn = await listen('recording-started', prepareForRecordingStopped); + unlistenFn = await listen('recording-stopped', (event) => { + recordingStoppedPayloadRef.current = event.payload; + const { folder_path, meeting_name } = event.payload; + if (folder_path) { + sessionStorage.setItem('last_recording_folder_path', folder_path); + } + if (meeting_name) { + sessionStorage.setItem('last_recording_meeting_name', meeting_name); + } + + resolveRecordingStoppedDataRef.current?.(); + resolveRecordingStoppedDataRef.current = null; }); console.log('Recording stopped listener setup complete'); } catch (error) { @@ -114,14 +127,21 @@ export function useRecordingStop( if (unlistenFn) { unlistenFn(); } + if (unlistenStartedFn) { + unlistenStartedFn(); + } }; }, [router]); // Main recording stop handler const handleRecordingStop = useCallback(async (isCallApi: boolean) => { - if (recordingStoppedDataRef.current) { - await recordingStoppedDataRef.current; + if (!recordingStoppedPayloadRef.current && recordingStoppedDataRef.current) { + await Promise.race([ + recordingStoppedDataRef.current, + new Promise((resolve) => setTimeout(resolve, 5000)), + ]); } + const recordingStoppedPayload = recordingStoppedPayloadRef.current; // Guard: prevent duplicate/concurrent stop calls if (stopInProgressRef.current) { @@ -231,9 +251,11 @@ export function useRecordingStop( await new Promise(resolve => setTimeout(resolve, 500)); // Save to SQLite - // NOTE: enabled to save COMPLETE transcripts after frontend receives all updates - // This ensures user sees all transcripts streaming in before database save - if (isCallApi && transcriptionComplete == true) { + // Persist whenever we have transcripts, even if transcription completion was not + // observed (poll error / timeout / missed event). Skipping the save here would + // silently drop the meeting from normal history and rely solely on recovery. + const hasTranscripts = transcriptsRef.current.length > 0; + if (isCallApi && (transcriptionComplete || hasTranscripts)) { setStatus(RecordingStatus.SAVING, 'Saving meeting to database...'); @@ -293,8 +315,24 @@ export function useRecordingStop( console.log(' Transcripts:', freshTranscripts.length); console.log(' folder_path:', folderPath); - // Mark meeting as saved in IndexedDB (for recovery system) + const indexedDbMeetingId = currentMeetingId + || sessionStorage.getItem('indexeddb_current_meeting_id'); + + // Mark transcript persistence complete, then retain a lightweight repair record + // only when checkpoint audio still needs recovery. await markMeetingAsSaved(); + if ( + recordingStoppedPayload?.audio_save_error + && recordingStoppedPayload.recovery_available + && indexedDbMeetingId + ) { + await indexedDBService.markMeetingNeedsAudioRecovery( + indexedDbMeetingId, + meetingId, + recordingStoppedPayload.audio_save_error, + recordingStoppedPayload.folder_path + ); + } // Clean up session storage sessionStorage.removeItem('last_recording_folder_path'); @@ -319,31 +357,58 @@ export function useRecordingStop( setCurrentMeeting({ id: meetingId, title: savedMeetingName || meetingTitle || 'New Meeting' }); } - // Mark as completed - setStatus(RecordingStatus.COMPLETED); - - // Show success toast with navigation option - toast.success('Recording saved successfully!', { - description: `${freshTranscripts.length} transcript segments saved.`, - action: { - label: 'View Meeting', - onClick: () => { - router.push(`/meeting-details?id=${meetingId}`); - Analytics.trackButtonClick('view_meeting_from_toast', 'recording_complete'); + const viewMeetingAction = { + label: 'View Meeting', + onClick: () => { + router.push(`/meeting-details?id=${meetingId}`); + Analytics.trackButtonClick('view_meeting_from_toast', 'recording_complete'); + } + }; + + if (recordingStoppedPayload?.audio_save_error) { + setStatus(RecordingStatus.ERROR, 'Audio could not be finalized'); + toast.error( + recordingStoppedPayload.recovery_available + ? 'Audio save needs recovery' + : 'Audio could not be saved', + { + description: recordingStoppedPayload.recovery_available + ? 'The transcript is saved. Audio checkpoints were preserved for recovery.' + : recordingStoppedPayload.audio_file_available + ? 'The audio file exists, but the meeting files could not be fully finalized.' + : 'The transcript is saved, but no recoverable audio checkpoint was available.', + action: viewMeetingAction, + duration: 15000, } - }, - duration: 10000, - }); + ); + } else if (!transcriptionComplete) { + setStatus(RecordingStatus.COMPLETED); + toast.warning('Meeting saved (transcription may be incomplete)', { + description: `${freshTranscripts.length} transcript segments saved. Some audio may still have been processing when the recording stopped.`, + action: viewMeetingAction, + duration: 12000, + }); + } else { + setStatus(RecordingStatus.COMPLETED); + toast.success('Recording saved successfully!', { + description: `${freshTranscripts.length} transcript segments saved.`, + action: viewMeetingAction, + duration: 10000, + }); + } - // Auto-navigate after a short delay with source parameter + // Successful saves navigate automatically. Save failures remain on the home + // screen so the retained checkpoint can appear in the recovery dialog. setTimeout(() => { - router.push(`/meeting-details?id=${meetingId}&source=recording`); + if (!recordingStoppedPayload?.audio_save_error) { + router.push(`/meeting-details?id=${meetingId}&source=recording`); + Analytics.trackPageView('meeting_details'); + } clearTranscripts() - Analytics.trackPageView('meeting_details'); // Reset to IDLE after navigation setStatus(RecordingStatus.IDLE); - }, 2000); + }, recordingStoppedPayload?.audio_save_error ? 2500 : 2000); // Track meeting completion analytics try { // Calculate meeting duration from transcript timestamps @@ -419,6 +484,9 @@ export function useRecordingStop( } finally { // Always reset the guard flag when done stopInProgressRef.current = false; + recordingStoppedDataRef.current = null; + resolveRecordingStoppedDataRef.current = null; + recordingStoppedPayloadRef.current = null; } }, [ setIsRecording, @@ -429,6 +497,7 @@ export function useRecordingStop( clearTranscripts, meetingTitle, markMeetingAsSaved, + currentMeetingId, refetchMeetings, setCurrentMeeting, setMeetings, diff --git a/frontend/src/hooks/useTranscriptRecovery.ts b/frontend/src/hooks/useTranscriptRecovery.ts index 146e1d7..8a9114c 100644 --- a/frontend/src/hooks/useTranscriptRecovery.ts +++ b/frontend/src/hooks/useTranscriptRecovery.ts @@ -43,8 +43,8 @@ export function useTranscriptRecovery(): UseTranscriptRecoveryReturn { try { const meetings = await indexedDBService.getAllMeetings(); - // Filter out meetings older than 7 days and newer than 15 seconds - // The 15 seconds threshold prevents showing meetings from the current session(jus in case) + // Filter out meetings older than 7 days and newer than 2 seconds. + // The short delay prevents showing a meeting while stop processing is still finishing. // where recording just stopped but hasn't been fully saved yet const cutoffTime = Date.now() - (7 * 24 * 60 * 60 * 1000); const secondsAgo = Date.now() - (2 * 1000); @@ -123,18 +123,9 @@ export function useTranscriptRecovery(): UseTranscriptRecoveryReturn { } // 3. Check for folder path - let folderPath = metadata.folderPath; + const folderPath = metadata.folderPath; - if (!folderPath) { - // Try to get from backend (might exist if only app crashed, not system) - try { - folderPath = await invoke('get_meeting_folder_path'); - } catch (error) { - folderPath = undefined; - } - } - // 4. Attempt audio recovery if folder path exists let audioRecoveryStatus: AudioRecoveryStatus | null = null; if (folderPath) { @@ -175,34 +166,52 @@ export function useTranscriptRecovery(): UseTranscriptRecoveryReturn { duration: (t as any).duration, })); - // 6. Save to backend database using existing save utilities - const saveResponse = await storageService.saveMeeting( - metadata.title, - formattedTranscripts, - folderPath ?? null - ); + // 6. Reuse a meeting already saved during a failed finalization. Crash recovery + // still creates a new SQLite meeting because none exists yet. + let savedMeetingId = metadata.sqliteMeetingId; + if (!metadata.savedToSQLite || !savedMeetingId) { + const saveResponse = await storageService.saveMeeting( + metadata.title, + formattedTranscripts, + folderPath ?? null + ); + savedMeetingId = saveResponse.meeting_id; + + try { + await applyPinnedSummaryLanguageToMeeting(savedMeetingId); + } catch (error) { + console.warn('Failed to apply pinned summary language to recovered meeting:', error); + toast.warning('Could not apply default summary language', { + description: 'The recovered meeting was saved, but the default summary language was not applied.', + }); + } + } - const savedMeetingId = saveResponse.meeting_id; + const audioRecoveryFailed = Boolean( + folderPath + && audioRecoveryStatus + && audioRecoveryStatus.status !== 'success' + && (metadata.needsAudioRecovery || audioRecoveryStatus.status === 'failed') + ); - try { - await applyPinnedSummaryLanguageToMeeting(savedMeetingId); - } catch (error) { - console.warn('Failed to apply pinned summary language to recovered meeting:', error); - toast.warning('Could not apply default summary language', { - description: 'The recovered meeting was saved, but the default summary language was not applied.', - }); + if (audioRecoveryFailed) { + await indexedDBService.markMeetingNeedsAudioRecovery( + meetingId, + savedMeetingId, + audioRecoveryStatus?.message || 'Audio recovery did not produce an audio file' + ); + throw new Error(audioRecoveryStatus?.message || 'Audio recovery failed; checkpoints were preserved'); } - // 7. Mark as saved in IndexedDB + // 7. Mark as saved only after any required audio recovery succeeds. await indexedDBService.markMeetingSaved(meetingId); - - // 8. Clean up checkpoint files - if (folderPath) { + // 8. Clean up checkpoints only after a verified successful merge. + if (folderPath && audioRecoveryStatus?.status === 'success') { try { await invoke('cleanup_checkpoints', { meetingFolder: folderPath }); } catch (error) { - // Non-fatal - don't fail recovery if cleanup fails + // The final audio exists, so leftover checkpoints are only a disk-space concern. console.warn('Checkpoint cleanup failed (non-fatal):', error); } } diff --git a/frontend/src/services/indexedDBService.ts b/frontend/src/services/indexedDBService.ts index c88cd41..02a74b3 100644 --- a/frontend/src/services/indexedDBService.ts +++ b/frontend/src/services/indexedDBService.ts @@ -13,6 +13,9 @@ export interface MeetingMetadata { transcriptCount: number; // Number of transcript segments savedToSQLite: boolean; // Flag: saved to backend DB folderPath?: string; // Path to recording folder + sqliteMeetingId?: string; // Existing backend meeting to reuse during audio-only recovery + needsAudioRecovery?: boolean; + audioSaveError?: string; } export interface StoredTranscript { @@ -150,8 +153,10 @@ class IndexedDBService { const request = store.getAll(); request.onsuccess = () => { const allMeetings = request.result as MeetingMetadata[]; - // Filter for unsaved meetings (savedToSQLite = false) - const unsavedMeetings = allMeetings.filter(m => m.savedToSQLite === false); + // Include meetings whose transcript is saved but whose audio still needs recovery. + const unsavedMeetings = allMeetings.filter( + m => m.savedToSQLite === false || m.needsAudioRecovery === true + ); // Sort by most recent first unsavedMeetings.sort((a, b) => b.lastUpdated - a.lastUpdated); @@ -181,6 +186,8 @@ class IndexedDBService { const meeting = getRequest.result; if (meeting) { meeting.savedToSQLite = true; + meeting.needsAudioRecovery = false; + delete meeting.audioSaveError; meeting.lastUpdated = Date.now(); const putRequest = store.put(meeting); putRequest.onsuccess = () => resolve(); @@ -196,6 +203,51 @@ class IndexedDBService { } } + /** + * Keep a completed SQLite meeting discoverable until its checkpoint audio is recovered. + */ + async markMeetingNeedsAudioRecovery( + meetingId: string, + sqliteMeetingId: string, + audioSaveError: string, + folderPath?: string + ): Promise { + try { + if (!this.db) await this.init(); + + const transaction = this.db!.transaction(['meetings'], 'readwrite'); + const store = transaction.objectStore('meetings'); + + await new Promise((resolve, reject) => { + const getRequest = store.get(meetingId); + getRequest.onsuccess = () => { + const meeting = getRequest.result as MeetingMetadata | undefined; + if (!meeting) { + resolve(); + return; + } + + meeting.savedToSQLite = true; + meeting.sqliteMeetingId = sqliteMeetingId; + meeting.needsAudioRecovery = true; + meeting.audioSaveError = audioSaveError; + if (folderPath) { + meeting.folderPath = folderPath; + } + meeting.lastUpdated = Date.now(); + + const putRequest = store.put(meeting); + putRequest.onsuccess = () => resolve(); + putRequest.onerror = () => reject(putRequest.error); + }; + getRequest.onerror = () => reject(getRequest.error); + }); + } catch (error) { + console.warn('Failed to retain meeting for audio recovery:', error); + throw error; + } + } + /** * Delete meeting and all its transcripts */ diff --git a/frontend/src/services/recordingService.ts b/frontend/src/services/recordingService.ts index 4ffb9d2..7853480 100644 --- a/frontend/src/services/recordingService.ts +++ b/frontend/src/services/recordingService.ts @@ -20,6 +20,9 @@ export interface RecordingStoppedPayload { message: string; folder_path?: string; meeting_name?: string; + audio_save_error?: string | null; + recovery_available?: boolean; + audio_file_available?: boolean; } /**