generated from nhcarrigan/template
feat: persist transcripts permanently
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user