feat: persist transcripts permanently

This commit is contained in:
2026-01-29 13:21:51 -08:00
parent 43a544a886
commit 9bf92d3365
4 changed files with 457 additions and 42 deletions
+83
View File
@@ -12,11 +12,13 @@ use tauri::{Emitter, Manager, State};
use tracing::{debug, info};
pub mod ml;
pub mod storage;
use ml::summarizer::{get_model_filename, LlamaSummarizer};
use ml::transcriber::{TranscriptSegment, WhisperTranscriber};
use ml::vad::SpeakerSeparator;
use ml::audio::AudioCapture;
use storage::{RecordingStorage, StoredRecording};
/// Application state containing the ML models and audio capture.
struct AppState {
@@ -25,6 +27,7 @@ struct AppState {
speaker_separator: Mutex<Option<SpeakerSeparator>>,
audio_capture: Mutex<Option<AudioCapture>>,
logs: Arc<Mutex<Vec<String>>>,
storage: Mutex<Option<RecordingStorage>>,
}
impl AppState {
@@ -35,6 +38,7 @@ impl AppState {
speaker_separator: Mutex::new(None),
audio_capture: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
storage: Mutex::new(None),
}
}
}
@@ -273,6 +277,20 @@ async fn initialize_models(
}
}
// Initialize storage
emit_log(&app_handle, &logs, "[Init] Initializing recording storage...");
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
match RecordingStorage::new(&app_data_dir) {
Ok(storage) => {
*state.storage.lock() = Some(storage);
emit_log(&app_handle, &logs, "[Init] Recording storage initialized successfully");
}
Err(e) => {
emit_log(&app_handle, &logs, &format!("[Init WARNING] Storage initialization failed: {}", e));
}
}
}
emit_log(&app_handle, &logs, "[Init] Model initialization complete");
Ok("Models initialized".to_string())
}
@@ -501,6 +519,67 @@ fn check_ready(state: State<'_, AppState>) -> Result<bool, String> {
Ok(ready)
}
/// Save a recording to persistent storage.
#[tauri::command]
fn save_recording(
state: State<'_, AppState>,
recording: StoredRecording,
) -> Result<(), String> {
let storage_guard = state.storage.lock();
let storage = storage_guard.as_ref()
.ok_or("Storage not initialized")?;
storage.save_recording(&recording)
.map_err(|e| format!("Failed to save recording: {}", e))?;
Ok(())
}
/// Load all recordings from persistent storage.
#[tauri::command]
fn load_recordings(
state: State<'_, AppState>,
) -> Result<Vec<StoredRecording>, String> {
let storage_guard = state.storage.lock();
let storage = storage_guard.as_ref()
.ok_or("Storage not initialized")?;
storage.load_all_recordings()
.map_err(|e| format!("Failed to load recordings: {}", e))
}
/// Delete a recording from persistent storage.
#[tauri::command]
fn delete_recording(
state: State<'_, AppState>,
recording_id: String,
) -> Result<(), String> {
let storage_guard = state.storage.lock();
let storage = storage_guard.as_ref()
.ok_or("Storage not initialized")?;
storage.delete_recording(&recording_id)
.map_err(|e| format!("Failed to delete recording: {}", e))?;
Ok(())
}
/// Update a recording (e.g., to add summary).
#[tauri::command]
fn update_recording(
state: State<'_, AppState>,
recording: StoredRecording,
) -> Result<(), String> {
let storage_guard = state.storage.lock();
let storage = storage_guard.as_ref()
.ok_or("Storage not initialized")?;
storage.update_recording(&recording)
.map_err(|e| format!("Failed to update recording: {}", e))?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize tracing
@@ -523,6 +602,10 @@ pub fn run() {
summarize,
get_backend_logs,
check_ready,
save_recording,
load_recordings,
delete_recording,
update_recording,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+182
View File
@@ -0,0 +1,182 @@
//! Storage module for persisting recording history.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{debug, info, warn};
#[derive(Error, Debug)]
pub enum StorageError {
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Recording not found: {0}")]
RecordingNotFound(String),
}
/// A transcript segment with timing information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredTranscriptSegment {
pub start: f64,
pub end: f64,
pub text: String,
pub speaker: String,
}
/// A stored recording with all its data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredRecording {
pub id: String,
pub timestamp: String, // ISO 8601 timestamp
pub duration: f64,
pub transcript_segments: Vec<StoredTranscriptSegment>,
pub summary: Option<String>,
}
/// Storage manager for recordings.
pub struct RecordingStorage {
storage_dir: PathBuf,
}
impl RecordingStorage {
/// Create a new storage manager.
pub fn new(app_data_dir: &Path) -> Result<Self, StorageError> {
let storage_dir = app_data_dir.join("recordings");
// Ensure the directory exists
fs::create_dir_all(&storage_dir)?;
info!("Recording storage initialized at: {}", storage_dir.display());
Ok(Self { storage_dir })
}
/// Save a recording to disk.
pub fn save_recording(&self, recording: &StoredRecording) -> Result<(), StorageError> {
let file_path = self.storage_dir.join(format!("{}.json", recording.id));
debug!("Saving recording {} to {}", recording.id, file_path.display());
let json_data = serde_json::to_string_pretty(recording)?;
fs::write(&file_path, json_data)?;
info!("Recording {} saved successfully", recording.id);
Ok(())
}
/// Load all recordings from disk.
pub fn load_all_recordings(&self) -> Result<Vec<StoredRecording>, StorageError> {
let mut recordings = Vec::new();
debug!("Loading recordings from {}", self.storage_dir.display());
// Read all JSON files in the directory
if let Ok(entries) = fs::read_dir(&self.storage_dir) {
for entry in entries.flatten() {
let path = entry.path();
// Skip non-JSON files
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
// Try to load the recording
match fs::read_to_string(&path) {
Ok(contents) => {
match serde_json::from_str::<StoredRecording>(&contents) {
Ok(recording) => {
recordings.push(recording);
}
Err(e) => {
warn!("Failed to parse recording file {}: {}", path.display(), e);
}
}
}
Err(e) => {
warn!("Failed to read recording file {}: {}", path.display(), e);
}
}
}
}
// Sort by timestamp (newest first)
recordings.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
info!("Loaded {} recordings", recordings.len());
Ok(recordings)
}
/// Delete a specific recording.
pub fn delete_recording(&self, recording_id: &str) -> Result<(), StorageError> {
let file_path = self.storage_dir.join(format!("{}.json", recording_id));
if !file_path.exists() {
return Err(StorageError::RecordingNotFound(recording_id.to_string()));
}
debug!("Deleting recording {} at {}", recording_id, file_path.display());
fs::remove_file(&file_path)?;
info!("Recording {} deleted successfully", recording_id);
Ok(())
}
/// Update an existing recording (e.g., to add summary).
pub fn update_recording(&self, recording: &StoredRecording) -> Result<(), StorageError> {
// For now, just overwrite the file
self.save_recording(recording)
}
/// Check if a recording exists.
pub fn recording_exists(&self, recording_id: &str) -> bool {
let file_path = self.storage_dir.join(format!("{}.json", recording_id));
file_path.exists()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_storage_operations() {
let temp_dir = TempDir::new().unwrap();
let storage = RecordingStorage::new(temp_dir.path()).unwrap();
// Create a test recording
let recording = StoredRecording {
id: "test123".to_string(),
timestamp: "2024-01-01T12:00:00Z".to_string(),
duration: 60.0,
transcript_segments: vec![
StoredTranscriptSegment {
start: 0.0,
end: 5.0,
text: "Hello world".to_string(),
speaker: "Speaker 1".to_string(),
},
],
summary: Some("Test summary".to_string()),
};
// Save it
storage.save_recording(&recording).unwrap();
assert!(storage.recording_exists("test123"));
// Load it back
let recordings = storage.load_all_recordings().unwrap();
assert_eq!(recordings.len(), 1);
assert_eq!(recordings[0].id, "test123");
// Delete it
storage.delete_recording("test123").unwrap();
assert!(!storage.recording_exists("test123"));
// Verify it's gone
let recordings = storage.load_all_recordings().unwrap();
assert_eq!(recordings.len(), 0);
}
}