generated from nhcarrigan/template
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e98b115104
|
|||
|
a5d6119ee0
|
|||
|
1918ffb868
|
|||
|
f9cb13f53f
|
|||
|
23519c0502
|
|||
|
76d851ad11
|
|||
|
4ed7462a17
|
|||
|
9efda8ded6
|
|||
|
9bf92d3365
|
|||
|
43a544a886
|
|||
|
a6843cb3f1
|
|||
|
e6c19b589e
|
+13
-1
@@ -24,7 +24,11 @@ jobs:
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev
|
||||
libayatana-appindicator3-dev \
|
||||
libasound2-dev \
|
||||
pkg-config \
|
||||
libclang-dev \
|
||||
cmake
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -94,6 +98,10 @@ jobs:
|
||||
patchelf \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libasound2-dev \
|
||||
pkg-config \
|
||||
libclang-dev \
|
||||
cmake \
|
||||
xdg-utils
|
||||
|
||||
- name: Setup pnpm
|
||||
@@ -145,6 +153,10 @@ jobs:
|
||||
patchelf \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libasound2-dev \
|
||||
pkg-config \
|
||||
libclang-dev \
|
||||
cmake \
|
||||
clang \
|
||||
lld \
|
||||
llvm \
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"preview": "vite preview",
|
||||
|
||||
Generated
+156
-1
@@ -519,16 +519,19 @@ name = "chronara"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"dasp",
|
||||
"futures-util",
|
||||
"hound",
|
||||
"llama-cpp-2",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustfft",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -813,12 +816,125 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7381b67da416b639690ac77c73b86a7b5e64a29e31d1f75fb3b1102301ef355a"
|
||||
dependencies = [
|
||||
"dasp_envelope",
|
||||
"dasp_frame",
|
||||
"dasp_interpolate",
|
||||
"dasp_peak",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_rms",
|
||||
"dasp_sample",
|
||||
"dasp_signal",
|
||||
"dasp_slice",
|
||||
"dasp_window",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_envelope"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_peak",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_rms",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_frame"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_interpolate"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_peak"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_ring_buffer"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1"
|
||||
|
||||
[[package]]
|
||||
name = "dasp_rms"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "dasp_signal"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7"
|
||||
dependencies = [
|
||||
"dasp_envelope",
|
||||
"dasp_frame",
|
||||
"dasp_interpolate",
|
||||
"dasp_peak",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_rms",
|
||||
"dasp_sample",
|
||||
"dasp_window",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_slice"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_window"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -3222,6 +3338,15 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@@ -3351,7 +3476,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3639,6 +3764,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
@@ -4142,6 +4281,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -4945,6 +5090,16 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.21.3"
|
||||
|
||||
@@ -34,6 +34,10 @@ hound = "3.5" # WAV file handling
|
||||
# Voice activity detection
|
||||
voice_activity_detector = "0.2"
|
||||
|
||||
# Audio analysis for speaker detection
|
||||
dasp = "0.11" # Digital audio signal processing
|
||||
rustfft = "6.2" # FFT for frequency analysis
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -63,6 +67,9 @@ windows = { version = "0.62", features = [
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
[patch.crates-io]
|
||||
# Fix cross-compilation: use CARGO_CFG_TARGET_OS instead of cfg!(windows)
|
||||
llama-cpp-sys-2 = { path = "../patches/llama-cpp-sys-2" }
|
||||
|
||||
+167
-40
@@ -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,21 @@ 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())
|
||||
}
|
||||
@@ -316,64 +335,39 @@ fn start_recording(
|
||||
Ok("Recording started".to_string())
|
||||
}
|
||||
|
||||
/// Stop recording and return the transcript.
|
||||
/// Stop recording (for real-time mode, transcription already happened during recording).
|
||||
#[tauri::command]
|
||||
async fn stop_recording(
|
||||
state: State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<TranscriptSegment>, String> {
|
||||
) -> Result<String, String> {
|
||||
let logs = Arc::clone(&state.logs);
|
||||
|
||||
emit_log(&app_handle, &logs, "[Audio] Stopping recording...");
|
||||
|
||||
// Get the audio samples
|
||||
let audio_samples = {
|
||||
// Stop the recording and get the duration
|
||||
let duration = {
|
||||
let mut audio_guard = state.audio_capture.lock();
|
||||
if let Some(ref mut capture) = *audio_guard {
|
||||
capture.stop_recording()
|
||||
let samples = capture.stop_recording();
|
||||
samples.len() as f32 / 16000.0
|
||||
} else {
|
||||
return Err("No active recording".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let duration = audio_samples.len() as f32 / 16000.0;
|
||||
emit_log(&app_handle, &logs, &format!("[Audio] Captured {:.1}s of audio", duration));
|
||||
|
||||
if audio_samples.is_empty() {
|
||||
return Err("No audio captured".to_string());
|
||||
emit_log(&app_handle, &logs, &format!("[Audio] Recording stopped. Total duration: {:.1}s", duration));
|
||||
Ok("Recording stopped".to_string())
|
||||
}
|
||||
|
||||
// Transcribe the audio
|
||||
emit_log(&app_handle, &logs, "[Transcribe] Starting transcription...");
|
||||
|
||||
let mut segments = {
|
||||
let transcriber = state.transcriber.lock();
|
||||
if !transcriber.is_loaded() {
|
||||
emit_log(&app_handle, &logs, "[Transcribe ERROR] Whisper model not loaded");
|
||||
return Err("Whisper model not loaded. Please ensure the model is downloaded.".to_string());
|
||||
}
|
||||
|
||||
transcriber.transcribe(&audio_samples)
|
||||
.map_err(|e| format!("Transcription failed: {}", e))?
|
||||
};
|
||||
|
||||
emit_log(&app_handle, &logs, &format!("[Transcribe] Got {} segments", segments.len()));
|
||||
|
||||
// Apply speaker labels using VAD
|
||||
if let Some(ref mut separator) = *state.speaker_separator.lock() {
|
||||
emit_log(&app_handle, &logs, "[Speaker] Applying speaker labels...");
|
||||
segments = separator.apply_speaker_labels(&audio_samples, segments)
|
||||
.map_err(|e| format!("Speaker separation failed: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
/// Transcribe a chunk of audio (for real-time transcription).
|
||||
#[tauri::command]
|
||||
async fn transcribe_chunk(
|
||||
state: State<'_, AppState>,
|
||||
audio_data: Vec<f32>,
|
||||
app_handle: tauri::AppHandle,
|
||||
chunk_start_time: f64,
|
||||
) -> Result<Vec<TranscriptSegment>, String> {
|
||||
let transcriber = state.transcriber.lock();
|
||||
|
||||
@@ -381,19 +375,75 @@ async fn transcribe_chunk(
|
||||
return Err("Whisper model not loaded".to_string());
|
||||
}
|
||||
|
||||
let segments = transcriber.transcribe(&audio_data)
|
||||
// Clone the app handle for the closure
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
let mut segments = transcriber.transcribe_with_progress(&audio_data, move |progress| {
|
||||
// Emit progress event to frontend
|
||||
let _ = app_handle_clone.emit("transcription-progress", progress);
|
||||
})
|
||||
.map_err(|e| format!("Transcription failed: {}", e))?;
|
||||
|
||||
// Apply speaker detection using enhanced VAD
|
||||
let mut separator_guard = state.speaker_separator.lock();
|
||||
if let Some(ref mut separator) = *separator_guard {
|
||||
// Process this chunk for speaker detection
|
||||
segments = separator.apply_speaker_labels_to_chunk(&audio_data, segments, chunk_start_time)
|
||||
.map_err(|e| format!("Speaker detection failed: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
/// Get the next chunk of audio for real-time transcription.
|
||||
/// Returns the audio chunk and the new offset to use for the next call.
|
||||
#[tauri::command]
|
||||
async fn get_audio_chunk(
|
||||
state: State<'_, AppState>,
|
||||
last_offset: usize,
|
||||
) -> Result<(Vec<f32>, usize), String> {
|
||||
let audio_guard = state.audio_capture.lock();
|
||||
if let Some(ref capture) = *audio_guard {
|
||||
Ok(capture.extract_chunk(last_offset))
|
||||
} else {
|
||||
Err("No active recording".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get remaining audio without modifying the buffer (for final processing).
|
||||
#[tauri::command]
|
||||
async fn get_remaining_audio(
|
||||
state: State<'_, AppState>,
|
||||
last_offset: usize,
|
||||
) -> Result<Vec<f32>, String> {
|
||||
let audio_guard = state.audio_capture.lock();
|
||||
if let Some(ref capture) = *audio_guard {
|
||||
Ok(capture.get_remaining_audio(last_offset))
|
||||
} else {
|
||||
Err("No active recording".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract title from the summary text.
|
||||
fn extract_title_from_summary(summary: &str) -> Option<String> {
|
||||
// Look for **Title:** pattern and extract the line
|
||||
if let Some(title_start) = summary.find("**Title:**") {
|
||||
let title_line_start = title_start + "**Title:**".len();
|
||||
if let Some(title_end) = summary[title_line_start..].find("\n") {
|
||||
let title = summary[title_line_start..title_line_start + title_end].trim();
|
||||
return Some(title.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Generate a summary from a transcript.
|
||||
#[tauri::command]
|
||||
async fn summarize(
|
||||
state: State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
transcript: String,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<(String, Option<String>), String> {
|
||||
let logs = Arc::clone(&state.logs);
|
||||
|
||||
emit_log(&app_handle, &logs, "[Summary] Generating summary...");
|
||||
@@ -406,12 +456,22 @@ async fn summarize(
|
||||
return Err("LLaMA model not loaded".to_string());
|
||||
}
|
||||
|
||||
let summary = summarizer.summarize(&transcript)
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let summary = summarizer.summarize_with_progress(&transcript, move |progress| {
|
||||
// Emit progress event to frontend
|
||||
let _ = app_handle_clone.emit("summary-progress", progress);
|
||||
})
|
||||
.map_err(|e| format!("Summarization failed: {}", e))?;
|
||||
|
||||
emit_log(&app_handle, &logs, &format!("[Summary] Generated {} character summary", summary.len()));
|
||||
|
||||
Ok(summary)
|
||||
// Extract title from the summary
|
||||
let title = extract_title_from_summary(&summary);
|
||||
if let Some(ref t) = title {
|
||||
emit_log(&app_handle, &logs, &format!("[Summary] Extracted title: {}", t));
|
||||
}
|
||||
|
||||
Ok((summary, title))
|
||||
}
|
||||
|
||||
/// Get backend logs.
|
||||
@@ -427,11 +487,72 @@ fn check_ready(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
|
||||
// At minimum, we need the summarizer loaded
|
||||
// Whisper can be loaded on first use
|
||||
let ready = summarizer.as_ref().map_or(false, |s| s.is_loaded());
|
||||
let ready = summarizer.as_ref().is_some_and(|s| s.is_loaded());
|
||||
|
||||
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
|
||||
@@ -449,9 +570,15 @@ pub fn run() {
|
||||
start_recording,
|
||||
stop_recording,
|
||||
transcribe_chunk,
|
||||
get_audio_chunk,
|
||||
get_remaining_audio,
|
||||
summarize,
|
||||
get_backend_logs,
|
||||
check_ready,
|
||||
save_recording,
|
||||
load_recordings,
|
||||
delete_recording,
|
||||
update_recording,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -196,6 +196,51 @@ impl AudioCapture {
|
||||
let sample_count = self.buffer.lock().len();
|
||||
sample_count as f32 / WHISPER_SAMPLE_RATE as f32
|
||||
}
|
||||
|
||||
/// Extract audio chunk for real-time processing.
|
||||
/// This method retrieves audio starting from the given offset and returns only
|
||||
/// the new samples, keeping a small overlap for context.
|
||||
pub fn extract_chunk(&self, from_sample: usize) -> (Vec<f32>, usize) {
|
||||
let mut buffer = self.buffer.lock();
|
||||
let current_len = buffer.len();
|
||||
|
||||
// If we don't have enough new samples, return empty
|
||||
if from_sample >= current_len {
|
||||
return (Vec::new(), current_len);
|
||||
}
|
||||
|
||||
// Extract new samples
|
||||
let chunk: Vec<f32> = buffer[from_sample..].to_vec();
|
||||
|
||||
// Keep only the last 30 seconds of audio (at 16kHz) to prevent memory exhaustion
|
||||
// This provides enough overlap for context while limiting memory usage
|
||||
const MAX_BUFFER_SECONDS: usize = 30;
|
||||
const MAX_BUFFER_SAMPLES: usize = WHISPER_SAMPLE_RATE as usize * MAX_BUFFER_SECONDS;
|
||||
|
||||
if buffer.len() > MAX_BUFFER_SAMPLES {
|
||||
// Calculate how many samples to remove from the beginning
|
||||
let samples_to_remove = buffer.len() - MAX_BUFFER_SAMPLES;
|
||||
buffer.drain(..samples_to_remove);
|
||||
|
||||
// Return the chunk and adjust the offset
|
||||
return (chunk, current_len - samples_to_remove);
|
||||
}
|
||||
|
||||
// Return the chunk and the new offset
|
||||
(chunk, current_len)
|
||||
}
|
||||
|
||||
/// Get all audio samples from the given offset without modifying the buffer.
|
||||
/// This is used when stopping recording to get any remaining audio.
|
||||
pub fn get_remaining_audio(&self, from_sample: usize) -> Vec<f32> {
|
||||
let buffer = self.buffer.lock();
|
||||
|
||||
if from_sample >= buffer.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
buffer[from_sample..].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioCapture {
|
||||
|
||||
@@ -71,6 +71,14 @@ impl LlamaSummarizer {
|
||||
|
||||
/// Generate a meeting summary from the transcript.
|
||||
pub fn summarize(&self, transcript: &str) -> Result<String, SummarizerError> {
|
||||
self.summarize_with_progress(transcript, |_| {})
|
||||
}
|
||||
|
||||
/// Generate a meeting summary with progress callback.
|
||||
pub fn summarize_with_progress<F>(&self, transcript: &str, mut progress_fn: F) -> Result<String, SummarizerError>
|
||||
where
|
||||
F: FnMut(f64),
|
||||
{
|
||||
let model = self
|
||||
.model
|
||||
.as_ref()
|
||||
@@ -78,20 +86,26 @@ impl LlamaSummarizer {
|
||||
|
||||
let prompt = format!(
|
||||
"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n\
|
||||
You are a helpful assistant that creates concise meeting summaries. Focus on:\n\
|
||||
- Key decisions made\n\
|
||||
- Action items and who owns them\n\
|
||||
- Important discussions and their outcomes\n\
|
||||
- Next steps\n\n\
|
||||
Keep the summary structured and easy to scan.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n\
|
||||
You are a helpful assistant that creates structured meeting summaries. \
|
||||
Format your response using the following template:\n\n\
|
||||
**Title:** A concise, descriptive title for the meeting (5-10 words max).\n\n\
|
||||
**Summary:** A high level overview of the meeting.\n\n\
|
||||
**Key decisions:** Any important resolutions that the meeting reached.\n\n\
|
||||
**Action Items:**\n\
|
||||
- list of things that must be addressed\n\n\
|
||||
**Discussion points:**\n\
|
||||
- a list of each topic/argument/counterpoint brought up in the meeting.\n\n\
|
||||
Be concise but comprehensive. Focus on capturing all important information.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n\
|
||||
Please summarize this meeting transcript:\n\n\
|
||||
{}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n\
|
||||
Meeting Summary:\n",
|
||||
{}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n",
|
||||
transcript
|
||||
);
|
||||
|
||||
debug!("Generating summary for {} character transcript", transcript.len());
|
||||
|
||||
// Report initial progress
|
||||
progress_fn(0.0);
|
||||
|
||||
// Create context for inference
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(NonZeroU32::new(8192));
|
||||
@@ -100,6 +114,9 @@ impl LlamaSummarizer {
|
||||
.new_context(self.backend.as_ref().unwrap(), ctx_params)
|
||||
.map_err(|e| SummarizerError::ContextError(e.to_string()))?;
|
||||
|
||||
// Report progress after context creation
|
||||
progress_fn(0.1);
|
||||
|
||||
// Tokenize the prompt
|
||||
let tokens = model
|
||||
.str_to_token(&prompt, llama_cpp_2::model::AddBos::Always)
|
||||
@@ -107,6 +124,9 @@ impl LlamaSummarizer {
|
||||
|
||||
debug!("Prompt tokenized to {} tokens", tokens.len());
|
||||
|
||||
// Report progress after tokenization
|
||||
progress_fn(0.2);
|
||||
|
||||
// Create batch and add tokens
|
||||
let mut batch = LlamaBatch::new(tokens.len(), 1);
|
||||
|
||||
@@ -120,6 +140,9 @@ impl LlamaSummarizer {
|
||||
ctx.decode(&mut batch)
|
||||
.map_err(|e| SummarizerError::SummarizationError(e.to_string()))?;
|
||||
|
||||
// Report progress after prompt decode
|
||||
progress_fn(0.3);
|
||||
|
||||
// Generate response tokens
|
||||
let mut output_tokens = Vec::new();
|
||||
let max_tokens = 1024;
|
||||
@@ -132,10 +155,10 @@ impl LlamaSummarizer {
|
||||
LlamaSampler::dist(42),
|
||||
]);
|
||||
|
||||
for _ in 0..max_tokens {
|
||||
for i in 0..max_tokens {
|
||||
// Sample the next token using the sampler
|
||||
// The sampler.sample() takes context and the index of the last token in the batch
|
||||
let token = sampler.sample(&ctx, (batch.n_tokens() - 1) as i32);
|
||||
let token = sampler.sample(&ctx, batch.n_tokens() - 1);
|
||||
|
||||
// Check for end of generation
|
||||
if model.is_eog_token(token) {
|
||||
@@ -144,6 +167,10 @@ impl LlamaSummarizer {
|
||||
|
||||
output_tokens.push(token);
|
||||
|
||||
// Report progress during generation (from 30% to 90%)
|
||||
let generation_progress = i as f64 / max_tokens as f64;
|
||||
progress_fn(0.3 + (generation_progress * 0.6));
|
||||
|
||||
// Prepare next batch
|
||||
batch.clear();
|
||||
batch.add(token, n_cur as i32, &[0], true)
|
||||
@@ -162,6 +189,9 @@ impl LlamaSummarizer {
|
||||
.filter_map(|t| model.token_to_str(*t, llama_cpp_2::model::Special::Tokenize).ok())
|
||||
.collect::<String>();
|
||||
|
||||
// Report completion
|
||||
progress_fn(1.0);
|
||||
|
||||
info!("Summary generated: {} characters", output_text.len());
|
||||
Ok(output_text.trim().to_string())
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ impl WhisperTranscriber {
|
||||
|
||||
/// Transcribe audio samples (expected to be 16kHz mono f32).
|
||||
pub fn transcribe(&self, audio: &[f32]) -> Result<Vec<TranscriptSegment>, TranscriberError> {
|
||||
self.transcribe_with_progress(audio, |_| {})
|
||||
}
|
||||
|
||||
/// Transcribe audio samples with progress callback.
|
||||
pub fn transcribe_with_progress<F>(&self, audio: &[f32], mut progress_fn: F) -> Result<Vec<TranscriptSegment>, TranscriberError>
|
||||
where
|
||||
F: FnMut(f64),
|
||||
{
|
||||
let context = self
|
||||
.context
|
||||
.as_ref()
|
||||
@@ -74,11 +82,17 @@ impl WhisperTranscriber {
|
||||
audio.len(),
|
||||
audio.len() as f64 / 16000.0);
|
||||
|
||||
// Report initial progress
|
||||
progress_fn(0.0);
|
||||
|
||||
// Create a state for this transcription
|
||||
let mut state = context
|
||||
.create_state()
|
||||
.map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?;
|
||||
|
||||
// Report progress after state creation
|
||||
progress_fn(0.2);
|
||||
|
||||
// Set up transcription parameters
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
|
||||
@@ -96,6 +110,9 @@ impl WhisperTranscriber {
|
||||
.full(params, audio)
|
||||
.map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?;
|
||||
|
||||
// Report progress after transcription
|
||||
progress_fn(0.8);
|
||||
|
||||
// Extract segments
|
||||
let num_segments = state.full_n_segments()
|
||||
.map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?;
|
||||
@@ -125,6 +142,9 @@ impl WhisperTranscriber {
|
||||
});
|
||||
}
|
||||
|
||||
// Report completion
|
||||
progress_fn(1.0);
|
||||
|
||||
info!("Transcription complete: {} segments", segments.len());
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
+255
-45
@@ -1,10 +1,12 @@
|
||||
//! Voice Activity Detection for basic speaker separation.
|
||||
//! Enhanced Voice Activity Detection for speaker separation in group meetings.
|
||||
//!
|
||||
//! This module uses the Silero VAD model to detect speech segments
|
||||
//! and provides basic speaker separation based on silence gaps.
|
||||
//! This module uses the Silero VAD model combined with voice characteristics
|
||||
//! to provide improved speaker separation for meetings with multiple speakers.
|
||||
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, info, warn};
|
||||
use voice_activity_detector::VoiceActivityDetector;
|
||||
|
||||
use super::transcriber::TranscriptSegment;
|
||||
@@ -20,12 +22,41 @@ pub enum VadError {
|
||||
ProcessingError(String),
|
||||
}
|
||||
|
||||
/// A detected speech segment with timing information.
|
||||
/// Voice characteristics for speaker fingerprinting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceFingerprint {
|
||||
/// Average pitch (fundamental frequency) in Hz
|
||||
pub avg_pitch: f32,
|
||||
/// Pitch variance
|
||||
pub pitch_variance: f32,
|
||||
/// Average energy level
|
||||
pub avg_energy: f32,
|
||||
/// Spectral centroid (brightness indicator)
|
||||
pub spectral_centroid: f32,
|
||||
}
|
||||
|
||||
impl VoiceFingerprint {
|
||||
/// Calculate similarity between two voice fingerprints (0.0 to 1.0)
|
||||
pub fn similarity(&self, other: &VoiceFingerprint) -> f32 {
|
||||
// Normalize differences for each feature
|
||||
let pitch_diff = (self.avg_pitch - other.avg_pitch).abs() / 200.0; // Normalize by typical pitch range
|
||||
let variance_diff = (self.pitch_variance - other.pitch_variance).abs() / 50.0;
|
||||
let energy_diff = (self.avg_energy - other.avg_energy).abs() / 0.5;
|
||||
let centroid_diff = (self.spectral_centroid - other.spectral_centroid).abs() / 1000.0;
|
||||
|
||||
// Calculate weighted similarity
|
||||
let diff = pitch_diff * 0.4 + variance_diff * 0.2 + energy_diff * 0.2 + centroid_diff * 0.2;
|
||||
(1.0 - diff.min(1.0)).max(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A detected speech segment with timing information and voice characteristics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpeechSegment {
|
||||
pub start_sample: usize,
|
||||
pub end_sample: usize,
|
||||
pub speaker_id: u32,
|
||||
pub fingerprint: Option<VoiceFingerprint>,
|
||||
}
|
||||
|
||||
impl SpeechSegment {
|
||||
@@ -45,19 +76,27 @@ impl SpeechSegment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Voice Activity Detector for speaker separation.
|
||||
/// Voice Activity Detector for speaker separation with enhanced tracking.
|
||||
pub struct SpeakerSeparator {
|
||||
vad: VoiceActivityDetector,
|
||||
/// Minimum silence duration (in seconds) to consider a speaker change
|
||||
min_silence_for_speaker_change: f64,
|
||||
_min_silence_for_speaker_change: f64,
|
||||
/// Minimum speech duration (in seconds) to consider a valid segment
|
||||
min_speech_duration: f64,
|
||||
/// Known speakers and their voice fingerprints
|
||||
known_speakers: HashMap<u32, VoiceFingerprint>,
|
||||
/// Next speaker ID to assign
|
||||
next_speaker_id: u32,
|
||||
/// Similarity threshold for matching speakers (0.0 to 1.0)
|
||||
similarity_threshold: f32,
|
||||
/// FFT planner for frequency analysis
|
||||
fft_planner: FftPlanner<f32>,
|
||||
}
|
||||
|
||||
impl SpeakerSeparator {
|
||||
/// Create a new speaker separator with default settings.
|
||||
pub fn new() -> Result<Self, VadError> {
|
||||
Self::with_settings(1.5, 0.3)
|
||||
Self::with_settings(0.8, 0.3) // Reduced silence threshold for group meetings
|
||||
}
|
||||
|
||||
/// Create a new speaker separator with custom settings.
|
||||
@@ -75,86 +114,211 @@ impl SpeakerSeparator {
|
||||
.build()
|
||||
.map_err(|e: voice_activity_detector::Error| VadError::InitError(e.to_string()))?;
|
||||
|
||||
info!("VAD initialized with sample_rate={}, min_silence={}s, min_speech={}s",
|
||||
info!("Enhanced VAD initialized with sample_rate={}, min_silence={}s, min_speech={}s",
|
||||
VAD_SAMPLE_RATE, min_silence_for_speaker_change, min_speech_duration);
|
||||
|
||||
Ok(Self {
|
||||
vad,
|
||||
min_silence_for_speaker_change,
|
||||
_min_silence_for_speaker_change: min_silence_for_speaker_change,
|
||||
min_speech_duration,
|
||||
known_speakers: HashMap::new(),
|
||||
next_speaker_id: 0,
|
||||
similarity_threshold: 0.7,
|
||||
fft_planner: FftPlanner::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect speech segments and assign speaker IDs based on silence gaps.
|
||||
/// Extract voice fingerprint from an audio segment.
|
||||
fn extract_fingerprint(&mut self, audio: &[f32]) -> Option<VoiceFingerprint> {
|
||||
if audio.len() < 2048 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate average energy
|
||||
let avg_energy = audio.iter().map(|s| s.abs()).sum::<f32>() / audio.len() as f32;
|
||||
|
||||
// Estimate pitch using autocorrelation (simplified)
|
||||
let pitch = self.estimate_pitch(audio);
|
||||
|
||||
// Calculate spectral centroid
|
||||
let spectral_centroid = self.calculate_spectral_centroid(audio);
|
||||
|
||||
// Calculate pitch variance over windows
|
||||
let pitch_variance = self.calculate_pitch_variance(audio);
|
||||
|
||||
Some(VoiceFingerprint {
|
||||
avg_pitch: pitch,
|
||||
pitch_variance,
|
||||
avg_energy,
|
||||
spectral_centroid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Estimate pitch using zero-crossing rate (simplified approach).
|
||||
fn estimate_pitch(&self, audio: &[f32]) -> f32 {
|
||||
let mut zero_crossings = 0;
|
||||
let mut prev_sign = audio[0] >= 0.0;
|
||||
|
||||
for sample in audio.iter().skip(1) {
|
||||
let current_sign = *sample >= 0.0;
|
||||
if current_sign != prev_sign {
|
||||
zero_crossings += 1;
|
||||
}
|
||||
prev_sign = current_sign;
|
||||
}
|
||||
|
||||
// Convert zero-crossing rate to approximate frequency
|
||||
let zcr = zero_crossings as f32 / audio.len() as f32;
|
||||
zcr * VAD_SAMPLE_RATE as f32 / 2.0
|
||||
}
|
||||
|
||||
/// Calculate spectral centroid (center of mass of spectrum).
|
||||
fn calculate_spectral_centroid(&mut self, audio: &[f32]) -> f32 {
|
||||
let fft_size = 2048.min(audio.len());
|
||||
let mut input: Vec<Complex<f32>> = audio[..fft_size]
|
||||
.iter()
|
||||
.map(|&s| Complex::new(s, 0.0))
|
||||
.collect();
|
||||
|
||||
let fft = self.fft_planner.plan_fft_forward(fft_size);
|
||||
fft.process(&mut input);
|
||||
|
||||
let mut weighted_sum = 0.0;
|
||||
let mut magnitude_sum = 0.0;
|
||||
|
||||
for (i, complex) in input.iter().enumerate().take(fft_size / 2) {
|
||||
let magnitude = complex.norm();
|
||||
let frequency = i as f32 * VAD_SAMPLE_RATE as f32 / fft_size as f32;
|
||||
weighted_sum += frequency * magnitude;
|
||||
magnitude_sum += magnitude;
|
||||
}
|
||||
|
||||
if magnitude_sum > 0.0 {
|
||||
weighted_sum / magnitude_sum
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate pitch variance across multiple windows.
|
||||
fn calculate_pitch_variance(&self, audio: &[f32]) -> f32 {
|
||||
let window_size = 1024;
|
||||
let num_windows = audio.len() / window_size;
|
||||
|
||||
if num_windows < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut pitches = Vec::new();
|
||||
for i in 0..num_windows {
|
||||
let start = i * window_size;
|
||||
let end = (i + 1) * window_size;
|
||||
let pitch = self.estimate_pitch(&audio[start..end]);
|
||||
pitches.push(pitch);
|
||||
}
|
||||
|
||||
// Calculate variance
|
||||
let mean = pitches.iter().sum::<f32>() / pitches.len() as f32;
|
||||
let variance = pitches.iter()
|
||||
.map(|p| (p - mean).powi(2))
|
||||
.sum::<f32>() / pitches.len() as f32;
|
||||
|
||||
variance.sqrt()
|
||||
}
|
||||
|
||||
/// Find or assign speaker ID based on voice fingerprint.
|
||||
fn find_or_assign_speaker(&mut self, fingerprint: &VoiceFingerprint) -> u32 {
|
||||
// Try to match with known speakers
|
||||
let mut best_match = None;
|
||||
let mut best_similarity = 0.0;
|
||||
|
||||
for (speaker_id, known_fp) in &self.known_speakers {
|
||||
let similarity = fingerprint.similarity(known_fp);
|
||||
if similarity > best_similarity && similarity >= self.similarity_threshold {
|
||||
best_similarity = similarity;
|
||||
best_match = Some(*speaker_id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(speaker_id) = best_match {
|
||||
debug!("Matched to existing speaker {} with similarity {:.2}", speaker_id, best_similarity);
|
||||
speaker_id
|
||||
} else {
|
||||
// New speaker
|
||||
let speaker_id = self.next_speaker_id;
|
||||
self.next_speaker_id += 1;
|
||||
self.known_speakers.insert(speaker_id, fingerprint.clone());
|
||||
debug!("New speaker {} detected", speaker_id);
|
||||
speaker_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect speech segments and assign speaker IDs based on voice characteristics.
|
||||
///
|
||||
/// This is a simple heuristic: if there's a long enough silence gap,
|
||||
/// we assume a different speaker might be talking.
|
||||
/// This enhanced version uses voice fingerprinting to track speakers across
|
||||
/// overlapping speech and short interruptions, making it suitable for group meetings.
|
||||
pub fn detect_speakers(&mut self, audio: &[f32]) -> Result<Vec<SpeechSegment>, VadError> {
|
||||
let chunk_size = 512;
|
||||
let mut segments = Vec::new();
|
||||
let mut raw_segments = Vec::new();
|
||||
let mut current_segment_start: Option<usize> = None;
|
||||
let mut last_speech_end: Option<usize> = None;
|
||||
let mut current_speaker_id = 0u32;
|
||||
|
||||
let min_silence_samples = (self.min_silence_for_speaker_change * VAD_SAMPLE_RATE as f64) as usize;
|
||||
let min_speech_samples = (self.min_speech_duration * VAD_SAMPLE_RATE as f64) as usize;
|
||||
|
||||
debug!("Processing {} samples for VAD", audio.len());
|
||||
debug!("Processing {} samples for enhanced VAD", audio.len());
|
||||
|
||||
// First pass: detect raw speech segments
|
||||
for (chunk_idx, chunk) in audio.chunks(chunk_size).enumerate() {
|
||||
if chunk.len() < chunk_size {
|
||||
// Skip incomplete chunks at the end
|
||||
continue;
|
||||
}
|
||||
|
||||
let sample_offset = chunk_idx * chunk_size;
|
||||
|
||||
// Predict voice activity for this chunk
|
||||
let probability = self.vad.predict(chunk.iter().copied());
|
||||
|
||||
let is_speech = probability > 0.5;
|
||||
|
||||
if is_speech {
|
||||
if current_segment_start.is_none() {
|
||||
// Check if we should change speaker
|
||||
if let Some(last_end) = last_speech_end {
|
||||
let silence_duration = sample_offset - last_end;
|
||||
if silence_duration >= min_silence_samples {
|
||||
current_speaker_id += 1;
|
||||
debug!("Speaker change detected at sample {} (silence: {}ms)",
|
||||
sample_offset, silence_duration * 1000 / VAD_SAMPLE_RATE as usize);
|
||||
}
|
||||
}
|
||||
current_segment_start = Some(sample_offset);
|
||||
}
|
||||
} else if let Some(start) = current_segment_start {
|
||||
// Speech ended
|
||||
let segment_duration = sample_offset - start;
|
||||
|
||||
if segment_duration >= min_speech_samples {
|
||||
segments.push(SpeechSegment {
|
||||
start_sample: start,
|
||||
end_sample: sample_offset,
|
||||
speaker_id: current_speaker_id,
|
||||
});
|
||||
raw_segments.push((start, sample_offset));
|
||||
}
|
||||
|
||||
last_speech_end = Some(sample_offset);
|
||||
current_segment_start = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where speech continues to the end
|
||||
// Handle speech continuing to the end
|
||||
if let Some(start) = current_segment_start {
|
||||
let segment_duration = audio.len() - start;
|
||||
if segment_duration >= min_speech_samples {
|
||||
raw_segments.push((start, audio.len()));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: analyze voice characteristics and assign speakers
|
||||
let mut segments = Vec::new();
|
||||
for (start, end) in raw_segments {
|
||||
let segment_audio = &audio[start..end];
|
||||
|
||||
// Extract voice fingerprint
|
||||
let fingerprint = self.extract_fingerprint(segment_audio);
|
||||
|
||||
let speaker_id = if let Some(ref fp) = fingerprint {
|
||||
self.find_or_assign_speaker(fp)
|
||||
} else {
|
||||
// Fallback: assign based on position
|
||||
warn!("Could not extract fingerprint for segment at {}s", start as f64 / VAD_SAMPLE_RATE as f64);
|
||||
self.next_speaker_id - 1 // Use last assigned speaker
|
||||
};
|
||||
|
||||
segments.push(SpeechSegment {
|
||||
start_sample: start,
|
||||
end_sample: audio.len(),
|
||||
speaker_id: current_speaker_id,
|
||||
end_sample: end,
|
||||
speaker_id,
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
info!("Detected {} speech segments with {} speakers",
|
||||
segments.len(),
|
||||
@@ -188,6 +352,51 @@ impl SpeakerSeparator {
|
||||
|
||||
Ok(transcript)
|
||||
}
|
||||
|
||||
/// Apply speaker labels to a chunk of transcript segments (for real-time processing).
|
||||
///
|
||||
/// This method is designed to work with the real-time transcription system,
|
||||
/// maintaining speaker consistency across chunks.
|
||||
pub fn apply_speaker_labels_to_chunk(
|
||||
&mut self,
|
||||
audio: &[f32],
|
||||
mut transcript: Vec<TranscriptSegment>,
|
||||
_chunk_start_time: f64,
|
||||
) -> Result<Vec<TranscriptSegment>, VadError> {
|
||||
// Detect speakers in this chunk
|
||||
let speech_segments = self.detect_speakers(audio)?;
|
||||
|
||||
for segment in &mut transcript {
|
||||
// Adjust times relative to chunk start
|
||||
let segment_mid = (segment.start + segment.end) / 2.0;
|
||||
|
||||
// Find matching speech segment
|
||||
for speech in &speech_segments {
|
||||
let speech_start = speech.start_seconds();
|
||||
let speech_end = speech.end_seconds();
|
||||
|
||||
if segment_mid >= speech_start && segment_mid <= speech_end {
|
||||
// Use consistent speaker numbering across chunks
|
||||
segment.speaker = format!("Speaker {}", speech.speaker_id + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old speakers if we have too many (helps with memory in long meetings)
|
||||
if self.known_speakers.len() > 10 {
|
||||
warn!("Many speakers detected ({}), consider adjusting similarity threshold", self.known_speakers.len());
|
||||
}
|
||||
|
||||
Ok(transcript)
|
||||
}
|
||||
|
||||
/// Reset speaker tracking (useful between different meetings).
|
||||
pub fn reset_speakers(&mut self) {
|
||||
self.known_speakers.clear();
|
||||
self.next_speaker_id = 0;
|
||||
info!("Speaker tracking reset");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SpeakerSeparator {
|
||||
@@ -206,6 +415,7 @@ mod tests {
|
||||
start_sample: 16000,
|
||||
end_sample: 32000,
|
||||
speaker_id: 0,
|
||||
fingerprint: None,
|
||||
};
|
||||
|
||||
assert!((segment.start_seconds() - 1.0).abs() < 0.001);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 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>,
|
||||
pub title: 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()),
|
||||
title: Some("Test Meeting".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);
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,7 @@
|
||||
"installerIcon": "icons/icon.ico"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
"resources/"
|
||||
],
|
||||
"resources": ["resources/"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+337
-53
@@ -4,16 +4,28 @@
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
--primary-color: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--secondary-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--bg-color: #ffffff;
|
||||
--surface-color: #f9fafb;
|
||||
--text-color: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
/* Witchy Purple Rose Palette */
|
||||
--witch-purple: #2b1b3d;
|
||||
--witch-plum: #44275a;
|
||||
--witch-rose: #a8577e;
|
||||
--witch-mauve: #d4a5c7;
|
||||
--witch-lavender: #e8d5e8;
|
||||
--witch-black: #0a0009;
|
||||
--witch-silver: #c0c0c0;
|
||||
--witch-moon: #f5f5f5;
|
||||
--witch-shadow: rgba(10, 0, 9, 0.7);
|
||||
|
||||
/* Theme mappings */
|
||||
--primary-color: var(--witch-rose);
|
||||
--primary-hover: var(--witch-plum);
|
||||
--secondary-color: var(--witch-mauve);
|
||||
--danger-color: #d4658e;
|
||||
--bg-color: var(--witch-black);
|
||||
--surface-color: var(--witch-purple);
|
||||
--text-color: var(--witch-moon);
|
||||
--text-secondary: var(--witch-silver);
|
||||
--border-color: var(--witch-plum);
|
||||
--shadow: 0 4px 6px -1px var(--witch-shadow), 0 2px 4px -1px var(--witch-shadow);
|
||||
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
@@ -61,14 +73,38 @@ body {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: var(--witch-plum);
|
||||
color: var(--witch-lavender);
|
||||
border: 1px solid var(--witch-rose);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 4px var(--witch-shadow);
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.app-version:hover {
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
border-color: var(--witch-mauve);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px var(--witch-shadow);
|
||||
}
|
||||
|
||||
/* Warning Banner */
|
||||
.warning-banner {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
background-color: var(--witch-plum);
|
||||
color: var(--witch-lavender);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--witch-rose);
|
||||
}
|
||||
|
||||
/* App Content */
|
||||
@@ -99,9 +135,9 @@ body {
|
||||
font-size: 1.25rem;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 2px solid transparent;
|
||||
border: 2px solid var(--witch-rose);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
color: var(--witch-moon);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@@ -109,11 +145,10 @@ body {
|
||||
}
|
||||
|
||||
.record-button:hover {
|
||||
background-color: var(--primary-hover);
|
||||
background-color: var(--witch-mauve);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 6px 12px -2px var(--witch-shadow);
|
||||
border-color: var(--witch-lavender);
|
||||
}
|
||||
|
||||
.record-button.recording {
|
||||
@@ -121,7 +156,7 @@ body {
|
||||
}
|
||||
|
||||
.record-button.recording:hover {
|
||||
background-color: #dc2626;
|
||||
background-color: #b94a6d;
|
||||
}
|
||||
|
||||
.recording-indicator {
|
||||
@@ -162,7 +197,7 @@ body {
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--danger-color);
|
||||
border: 1px solid var(--witch-rose);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@@ -175,6 +210,12 @@ body {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.real-time-indicator {
|
||||
font-size: 0.875rem;
|
||||
color: var(--secondary-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recording-duration {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -184,9 +225,9 @@ body {
|
||||
.stop-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid var(--danger-color);
|
||||
border: 2px solid var(--witch-rose);
|
||||
background-color: transparent;
|
||||
color: var(--danger-color);
|
||||
color: var(--witch-rose);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -194,8 +235,9 @@ body {
|
||||
}
|
||||
|
||||
.stop-button:hover {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
box-shadow: 0 2px 4px var(--witch-shadow);
|
||||
}
|
||||
|
||||
/* Transcribing Indicator */
|
||||
@@ -242,13 +284,16 @@ body {
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
background-color: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background-color: #059669;
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
@@ -257,13 +302,15 @@ body {
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background-color: var(--surface-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: transparent;
|
||||
color: var(--witch-lavender);
|
||||
border: 1px solid var(--witch-plum);
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
background-color: var(--border-color);
|
||||
background-color: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
@@ -290,6 +337,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 600px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.transcript-display h2,
|
||||
@@ -331,6 +379,7 @@ body {
|
||||
.speaker {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
@@ -353,15 +402,17 @@ body {
|
||||
.download-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--witch-plum);
|
||||
background-color: transparent;
|
||||
color: var(--witch-silver);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background-color: var(--surface-color);
|
||||
background-color: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
@@ -382,8 +433,9 @@ body {
|
||||
.spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border: 3px solid var(--witch-plum);
|
||||
border-top-color: var(--witch-rose);
|
||||
border-right-color: var(--witch-mauve);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -426,7 +478,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 -2px 10px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
@@ -462,10 +514,10 @@ body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
||||
font-size: 0.75rem;
|
||||
background-color: #1a1a2e;
|
||||
color: #d4d4d4;
|
||||
background-color: var(--witch-black);
|
||||
color: var(--witch-silver);
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
@@ -546,7 +598,7 @@ body {
|
||||
|
||||
.error-message {
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
background-color: rgba(212, 101, 142, 0.1);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--danger-color);
|
||||
@@ -564,19 +616,251 @@ body {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-color: #111827;
|
||||
--surface-color: #1f2937;
|
||||
--text-color: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--border-color: #374151;
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
/* Unified Witchy Purple Rose Theme - No Dark/Light Modes */
|
||||
|
||||
/* New layout styles */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 2rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background-color: #451a03;
|
||||
color: #fbbf24;
|
||||
.transcripts-list {
|
||||
width: 300px;
|
||||
background-color: var(--surface-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.transcripts-list h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.no-transcripts {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.transcript-items {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.transcript-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--witch-purple);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.transcript-content {
|
||||
flex: 1;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.transcript-item:hover {
|
||||
background-color: var(--witch-plum);
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.transcript-item.selected {
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.transcript-item.selected .transcript-time {
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.transcript-item.selected .transcript-status {
|
||||
color: var(--witch-lavender);
|
||||
}
|
||||
|
||||
.transcript-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.transcript-time {
|
||||
font-weight: 400;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.transcript-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.transcript-item .delete-button {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.transcript-item .delete-button:hover {
|
||||
background-color: var(--danger-color);
|
||||
border-color: var(--danger-color);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.transcript-item.selected .delete-button {
|
||||
border-color: var(--witch-lavender);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.transcript-item.selected .delete-button:hover {
|
||||
background-color: var(--danger-color);
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.transcript-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.transcript-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.transcript-header h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.transcript-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Summary display improvements */
|
||||
.summary-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-text strong {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(232, 213, 232, 0.2) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-top: 0.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Transcribing indicator update */
|
||||
.transcribing-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
+520
-78
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import "./App.css";
|
||||
import { TranscriptDisplay } from "./components/TranscriptDisplay";
|
||||
import { SummaryDisplay } from "./components/SummaryDisplay";
|
||||
import { BackendLogs } from "./components/BackendLogs";
|
||||
import { ProgressBar } from "./components/ProgressBar";
|
||||
|
||||
interface TranscriptSegment {
|
||||
start: number;
|
||||
@@ -12,37 +15,63 @@ interface TranscriptSegment {
|
||||
speaker: string;
|
||||
}
|
||||
|
||||
type AppState = "checking" | "downloading_models" | "initializing" | "ready" | "recording" | "transcribing" | "error";
|
||||
interface StoredTranscriptSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
speaker: string;
|
||||
}
|
||||
|
||||
interface StoredRecording {
|
||||
id: string;
|
||||
timestamp: string; // ISO 8601 string
|
||||
duration: number;
|
||||
transcript_segments: StoredTranscriptSegment[];
|
||||
summary: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
interface Recording {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
duration: number;
|
||||
transcriptSegments: TranscriptSegment[];
|
||||
summary: string | null;
|
||||
title: string | null;
|
||||
isGeneratingSummary: boolean;
|
||||
summaryProgress?: number;
|
||||
}
|
||||
|
||||
type AppState =
|
||||
| "checking"
|
||||
| "downloading_models"
|
||||
| "initializing"
|
||||
| "ready"
|
||||
| "recording"
|
||||
| "transcribing"
|
||||
| "error";
|
||||
|
||||
function App() {
|
||||
const [transcriptSegments, setTranscriptSegments] = useState<TranscriptSegment[]>([]);
|
||||
const [summary, setSummary] = useState<string | null>(null);
|
||||
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
|
||||
const [recordings, setRecordings] = useState<Recording[]>([]);
|
||||
const [activeRecording, setActiveRecording] = useState<Recording | null>(null);
|
||||
const [selectedRecordingId, setSelectedRecordingId] = useState<string | null>(null);
|
||||
const [appState, setAppState] = useState<AppState>("checking");
|
||||
const [statusMessage, setStatusMessage] = useState("Checking setup...");
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
|
||||
const [appVersion, setAppVersion] = useState<string>("");
|
||||
const initStarted = useRef(false);
|
||||
const recordingTimer = useRef<number | null>(null);
|
||||
const transcriptionTimer = useRef<number | null>(null);
|
||||
const audioOffset = useRef(0);
|
||||
const totalProcessedSamples = useRef(0);
|
||||
const transcriptionProgressUnlisten = useRef<UnlistenFn | null>(null);
|
||||
const summaryProgressUnlisten = useRef<UnlistenFn | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initStarted.current) return;
|
||||
initStarted.current = true;
|
||||
|
||||
initializeApp();
|
||||
}, []);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordingTimer.current) {
|
||||
clearInterval(recordingTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initializeApp = async () => {
|
||||
// Define initializeApp before using it
|
||||
const initializeApp = useCallback(async () => {
|
||||
try {
|
||||
setAppState("checking");
|
||||
setStatusMessage("Checking if models are present...");
|
||||
@@ -59,7 +88,9 @@ function App() {
|
||||
// Check again after download
|
||||
const modelsVerified = await invoke<boolean>("check_models");
|
||||
if (!modelsVerified) {
|
||||
throw new Error("Download completed but model file not found. This might be a path or permissions issue.");
|
||||
throw new Error(
|
||||
"Download completed but model file not found. This might be a path or permissions issue."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +101,6 @@ function App() {
|
||||
|
||||
await invoke("initialize_models");
|
||||
|
||||
// Check if models are ready
|
||||
const ready = await invoke<boolean>("check_ready");
|
||||
if (!ready) {
|
||||
throw new Error("Models failed to initialize. Check logs for details.");
|
||||
@@ -79,13 +109,103 @@ function App() {
|
||||
setAppState("ready");
|
||||
setStatusMessage("");
|
||||
setShowLogs(false);
|
||||
|
||||
// Load saved recordings
|
||||
try {
|
||||
const savedRecordings = await invoke<StoredRecording[]>("load_recordings");
|
||||
const loadedRecordings = savedRecordings.map(storedToFrontend);
|
||||
setRecordings(loadedRecordings);
|
||||
console.log(`Loaded ${loadedRecordings.length} transcripts from storage`);
|
||||
} catch (loadError) {
|
||||
console.error("Failed to load transcripts:", loadError);
|
||||
// Don't fail app init if we can't load transcripts
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Initialization failed:", error);
|
||||
setAppState("error");
|
||||
setErrorMessage(String(error));
|
||||
setShowLogs(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initStarted.current) return;
|
||||
initStarted.current = true;
|
||||
|
||||
// Get app version
|
||||
getVersion().then((version) => {
|
||||
setAppVersion(version);
|
||||
});
|
||||
|
||||
initializeApp();
|
||||
}, [initializeApp]);
|
||||
|
||||
// Helper function to convert stored recording to frontend format
|
||||
const storedToFrontend = (stored: StoredRecording): Recording => ({
|
||||
id: stored.id,
|
||||
timestamp: new Date(stored.timestamp),
|
||||
duration: stored.duration,
|
||||
transcriptSegments: stored.transcript_segments,
|
||||
summary: stored.summary,
|
||||
title: stored.title,
|
||||
isGeneratingSummary: false,
|
||||
summaryProgress: undefined,
|
||||
});
|
||||
|
||||
// Helper function to convert frontend recording to stored format
|
||||
const frontendToStored = (recording: Recording): StoredRecording => ({
|
||||
id: recording.id,
|
||||
timestamp: recording.timestamp.toISOString(),
|
||||
duration: recording.duration,
|
||||
transcript_segments: recording.transcriptSegments,
|
||||
summary: recording.summary,
|
||||
title: recording.title,
|
||||
});
|
||||
|
||||
// Cleanup timers and listeners on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordingTimer.current) {
|
||||
clearInterval(recordingTimer.current);
|
||||
}
|
||||
if (transcriptionTimer.current) {
|
||||
clearInterval(transcriptionTimer.current);
|
||||
}
|
||||
if (transcriptionProgressUnlisten.current) {
|
||||
transcriptionProgressUnlisten.current();
|
||||
}
|
||||
if (summaryProgressUnlisten.current) {
|
||||
summaryProgressUnlisten.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set up event listeners for progress updates
|
||||
useEffect(() => {
|
||||
const setupListeners = async () => {
|
||||
// Listen for transcription progress
|
||||
transcriptionProgressUnlisten.current = await listen<number>(
|
||||
"transcription-progress",
|
||||
(event) => {
|
||||
setTranscriptionProgress(event.payload * 100); // Convert 0-1 to 0-100
|
||||
}
|
||||
);
|
||||
|
||||
// Listen for summary progress
|
||||
summaryProgressUnlisten.current = await listen<number>("summary-progress", (event) => {
|
||||
const recordingId = selectedRecordingId || recordings[0]?.id;
|
||||
if (recordingId) {
|
||||
setRecordings((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === recordingId ? { ...r, summaryProgress: event.payload * 100 } : r
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupListeners();
|
||||
}, [selectedRecordingId, recordings]);
|
||||
|
||||
const retrySetup = () => {
|
||||
setErrorMessage(null);
|
||||
@@ -93,19 +213,82 @@ function App() {
|
||||
initializeApp();
|
||||
};
|
||||
|
||||
const processAudioChunk = async () => {
|
||||
try {
|
||||
// Get the next chunk of audio
|
||||
const [audioChunk, newOffset] = await invoke<[number[], number]>("get_audio_chunk", {
|
||||
lastOffset: audioOffset.current,
|
||||
});
|
||||
|
||||
// If we have enough audio (at least 5 seconds worth at 16kHz)
|
||||
if (audioChunk.length >= 5 * 16000) {
|
||||
// Transcribe the chunk
|
||||
const chunkStartTime = totalProcessedSamples.current / 16000;
|
||||
const newSegments = await invoke<TranscriptSegment[]>("transcribe_chunk", {
|
||||
audioData: audioChunk,
|
||||
chunkStartTime: chunkStartTime,
|
||||
});
|
||||
|
||||
if (newSegments.length > 0) {
|
||||
// Calculate timestamps based on total processed samples
|
||||
const baseTime = totalProcessedSamples.current / 16000;
|
||||
const adjustedSegments = newSegments.map((seg) => ({
|
||||
...seg,
|
||||
start: seg.start + baseTime,
|
||||
end: seg.end + baseTime,
|
||||
}));
|
||||
|
||||
setActiveRecording((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
transcriptSegments: [...prev.transcriptSegments, ...adjustedSegments],
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// Track total processed samples
|
||||
totalProcessedSamples.current += audioChunk.length;
|
||||
|
||||
// Update the offset for next time
|
||||
audioOffset.current = newOffset;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process audio chunk:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
setAppState("recording");
|
||||
setRecordingDuration(0);
|
||||
setTranscriptSegments([]);
|
||||
setSummary(null);
|
||||
audioOffset.current = 0;
|
||||
totalProcessedSamples.current = 0;
|
||||
|
||||
// Create a new active recording
|
||||
const newRecording: Recording = {
|
||||
id: Date.now().toString(),
|
||||
timestamp: new Date(),
|
||||
duration: 0,
|
||||
transcriptSegments: [],
|
||||
summary: null,
|
||||
title: null,
|
||||
isGeneratingSummary: false,
|
||||
};
|
||||
setActiveRecording(newRecording);
|
||||
|
||||
await invoke("start_recording");
|
||||
|
||||
// Start timer to show recording duration
|
||||
recordingTimer.current = window.setInterval(() => {
|
||||
setRecordingDuration(d => d + 1);
|
||||
setRecordingDuration((d) => d + 1);
|
||||
}, 1000);
|
||||
|
||||
// Start real-time transcription timer (every 5 seconds)
|
||||
transcriptionTimer.current = window.setInterval(() => {
|
||||
processAudioChunk();
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
setAppState("ready");
|
||||
@@ -115,49 +298,204 @@ function App() {
|
||||
|
||||
const stopRecording = async () => {
|
||||
try {
|
||||
// Stop the timer
|
||||
// Stop the timers first
|
||||
if (recordingTimer.current) {
|
||||
clearInterval(recordingTimer.current);
|
||||
recordingTimer.current = null;
|
||||
}
|
||||
if (transcriptionTimer.current) {
|
||||
clearInterval(transcriptionTimer.current);
|
||||
transcriptionTimer.current = null;
|
||||
}
|
||||
|
||||
setAppState("transcribing");
|
||||
setStatusMessage("Transcribing audio...");
|
||||
setStatusMessage("Processing final audio...");
|
||||
setTranscriptionProgress(0);
|
||||
|
||||
const segments = await invoke<TranscriptSegment[]>("stop_recording");
|
||||
setTranscriptSegments(segments);
|
||||
// First, process any audio that hasn't been processed yet
|
||||
try {
|
||||
const finalChunk = await invoke<number[]>("get_remaining_audio", {
|
||||
lastOffset: audioOffset.current,
|
||||
});
|
||||
|
||||
if (finalChunk.length > 0) {
|
||||
console.log(`Processing final chunk of ${finalChunk.length} samples`);
|
||||
const chunkStartTime = totalProcessedSamples.current / 16000;
|
||||
// The progress will be updated via events from the backend
|
||||
const finalSegments = await invoke<TranscriptSegment[]>("transcribe_chunk", {
|
||||
audioData: finalChunk,
|
||||
chunkStartTime: chunkStartTime,
|
||||
});
|
||||
|
||||
if (finalSegments.length > 0) {
|
||||
const baseTime = totalProcessedSamples.current / 16000;
|
||||
const adjustedSegments = finalSegments.map((seg) => ({
|
||||
...seg,
|
||||
start: seg.start + baseTime,
|
||||
end: seg.end + baseTime,
|
||||
}));
|
||||
|
||||
setActiveRecording((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
transcriptSegments: [...prev.transcriptSegments, ...adjustedSegments],
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (chunkError) {
|
||||
console.error("Error processing final chunk:", chunkError);
|
||||
// Continue with stopping even if final chunk fails
|
||||
}
|
||||
|
||||
// Now stop the recording
|
||||
await invoke<string>("stop_recording");
|
||||
|
||||
// Save the recording to history
|
||||
if (activeRecording) {
|
||||
const finalRecording = {
|
||||
...activeRecording,
|
||||
duration: recordingDuration,
|
||||
};
|
||||
setRecordings((prev) => [finalRecording, ...prev]);
|
||||
setSelectedRecordingId(finalRecording.id);
|
||||
setActiveRecording(null);
|
||||
|
||||
// Save to persistent storage
|
||||
try {
|
||||
await invoke("save_recording", {
|
||||
recording: frontendToStored(finalRecording),
|
||||
});
|
||||
console.log("Transcript saved to storage");
|
||||
} catch (saveError) {
|
||||
console.error("Failed to save transcript:", saveError);
|
||||
// Don't fail the whole operation if storage fails
|
||||
}
|
||||
}
|
||||
|
||||
// Brief delay to show completion
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setAppState("ready");
|
||||
setStatusMessage("");
|
||||
setTranscriptionProgress(0);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop recording:", error);
|
||||
setAppState("ready");
|
||||
setStatusMessage("");
|
||||
setErrorMessage(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const generateSummary = async () => {
|
||||
if (transcriptSegments.length === 0) return;
|
||||
const generateSummary = async (recordingId: string) => {
|
||||
const recording = recordings.find((r) => r.id === recordingId);
|
||||
if (!recording || recording.transcriptSegments.length === 0) return;
|
||||
|
||||
setIsGeneratingSummary(true);
|
||||
// Update the recording to show it's generating
|
||||
setRecordings((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === recordingId ? { ...r, isGeneratingSummary: true, summaryProgress: 0 } : r
|
||||
)
|
||||
);
|
||||
|
||||
const fullTranscript = transcriptSegments
|
||||
const fullTranscript = recording.transcriptSegments
|
||||
.map((seg) => `${seg.speaker}: ${seg.text}`)
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
const summaryResult = await invoke<string>("summarize", { transcript: fullTranscript });
|
||||
setSummary(summaryResult);
|
||||
// Progress will be updated via events from the backend
|
||||
const [summaryResult, titleResult] = await invoke<[string, string | null]>("summarize", {
|
||||
transcript: fullTranscript,
|
||||
});
|
||||
|
||||
// Update the recording with the summary and title
|
||||
const updatedRecording = recordings.find((r) => r.id === recordingId);
|
||||
if (updatedRecording) {
|
||||
const recordingWithSummary = {
|
||||
...updatedRecording,
|
||||
summary: summaryResult,
|
||||
title: titleResult,
|
||||
isGeneratingSummary: false,
|
||||
summaryProgress: 100,
|
||||
};
|
||||
|
||||
setRecordings((prev) => prev.map((r) => (r.id === recordingId ? recordingWithSummary : r)));
|
||||
|
||||
// Update in persistent storage
|
||||
try {
|
||||
await invoke("update_recording", {
|
||||
recording: frontendToStored(recordingWithSummary),
|
||||
});
|
||||
console.log("Transcript updated with summary");
|
||||
} catch (updateError) {
|
||||
console.error("Failed to update transcript:", updateError);
|
||||
}
|
||||
|
||||
// Clear progress after a brief delay
|
||||
setTimeout(() => {
|
||||
setRecordings((prev) =>
|
||||
prev.map((r) => (r.id === recordingId ? { ...r, summaryProgress: undefined } : r))
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate summary:", error);
|
||||
setErrorMessage(String(error));
|
||||
} finally {
|
||||
setIsGeneratingSummary(false);
|
||||
|
||||
// Reset generating state on error
|
||||
setRecordings((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === recordingId
|
||||
? { ...r, isGeneratingSummary: false, summaryProgress: undefined }
|
||||
: r
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTranscript = () => {
|
||||
const content = transcriptSegments
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// You could add a toast notification here
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRecording = async (recordingId: string) => {
|
||||
// Confirm deletion
|
||||
if (
|
||||
!confirm("Are you sure you want to delete this transcript? This action cannot be undone.")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete from backend storage
|
||||
await invoke("delete_recording", { recordingId });
|
||||
|
||||
// Remove from state
|
||||
setRecordings((prev) => prev.filter((r) => r.id !== recordingId));
|
||||
|
||||
// Clear selection if we deleted the selected recording
|
||||
if (selectedRecordingId === recordingId) {
|
||||
setSelectedRecordingId(null);
|
||||
}
|
||||
|
||||
console.log(`Transcript ${recordingId} deleted`);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete transcript:", error);
|
||||
alert(`Failed to delete transcript: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTranscript = (recordingId: string) => {
|
||||
const recording = recordings.find((r) => r.id === recordingId);
|
||||
if (!recording) return;
|
||||
|
||||
const content = recording.transcriptSegments
|
||||
.map((seg) => `[${formatTime(seg.start)}] ${seg.speaker}: ${seg.text}`)
|
||||
.join("\n");
|
||||
|
||||
@@ -165,19 +503,20 @@ function App() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `meeting-transcript-${new Date().toISOString().split("T")[0]}.txt`;
|
||||
a.download = `meeting-transcript-${recording.timestamp.toISOString().split("T")[0]}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadSummary = () => {
|
||||
if (!summary) return;
|
||||
const downloadSummary = (recordingId: string) => {
|
||||
const recording = recordings.find((r) => r.id === recordingId);
|
||||
if (!recording || !recording.summary) return;
|
||||
|
||||
const blob = new Blob([summary], { type: "text/plain" });
|
||||
const blob = new Blob([recording.summary], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `meeting-summary-${new Date().toISOString().split("T")[0]}.txt`;
|
||||
a.download = `meeting-summary-${recording.timestamp.toISOString().split("T")[0]}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -194,6 +533,11 @@ function App() {
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Get the currently selected recording or active recording
|
||||
const displayedRecording =
|
||||
activeRecording ||
|
||||
(selectedRecordingId ? recordings.find((r) => r.id === selectedRecordingId) : null);
|
||||
|
||||
const renderSetupScreen = () => (
|
||||
<div className="setup-screen">
|
||||
<div className="setup-content">
|
||||
@@ -227,9 +571,7 @@ function App() {
|
||||
<li>Whisper - Speech recognition</li>
|
||||
<li>VAD - Speaker detection</li>
|
||||
</ul>
|
||||
<p className="setup-note">
|
||||
This is a pure Rust backend - no Python required!
|
||||
</p>
|
||||
<p className="setup-note">This is a pure Rust backend - no Python required!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -242,9 +584,9 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(appState === "checking" || appState === "initializing" || appState === "downloading_models") && (
|
||||
<div className="loading-spinner" />
|
||||
)}
|
||||
{(appState === "checking" ||
|
||||
appState === "initializing" ||
|
||||
appState === "downloading_models") && <div className="loading-spinner" />}
|
||||
</div>
|
||||
|
||||
<BackendLogs isVisible={showLogs} onToggle={() => setShowLogs(!showLogs)} />
|
||||
@@ -255,7 +597,7 @@ function App() {
|
||||
<section className="controls-section">
|
||||
{appState === "ready" && (
|
||||
<button className="record-button" onClick={startRecording}>
|
||||
šļø Start Recording
|
||||
šļø Start Transcribing
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -264,44 +606,87 @@ function App() {
|
||||
<div className="recording-indicator">
|
||||
<span className="recording-dot" />
|
||||
Recording: {formatDuration(recordingDuration)}
|
||||
{activeRecording && activeRecording.transcriptSegments.length > 0 && (
|
||||
<span className="real-time-indicator"> (Real-time transcription active)</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="stop-button" onClick={stopRecording}>
|
||||
ā¹ļø Stop Recording
|
||||
ā¹ļø Stop Transcribing
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appState === "transcribing" && (
|
||||
<div className="transcribing-indicator">
|
||||
<div className="loading-spinner small" />
|
||||
<span>Transcribing audio...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appState === "ready" && transcriptSegments.length > 0 && (
|
||||
<div className="action-buttons">
|
||||
<button className="secondary-button" onClick={downloadTranscript}>
|
||||
š Download Transcript
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={generateSummary}
|
||||
disabled={isGeneratingSummary}
|
||||
>
|
||||
⨠Generate Summary
|
||||
</button>
|
||||
<ProgressBar progress={transcriptionProgress} label="Processing final audio..." />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderRecordingsList = () => (
|
||||
<aside className="transcripts-list">
|
||||
<h3>Transcript History</h3>
|
||||
{recordings.length === 0 ? (
|
||||
<p className="no-transcripts">No transcripts yet</p>
|
||||
) : (
|
||||
<ul className="transcript-items">
|
||||
{recordings.map((recording) => (
|
||||
<li
|
||||
key={recording.id}
|
||||
className={`transcript-item ${selectedRecordingId === recording.id ? "selected" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="transcript-content"
|
||||
onClick={() => setSelectedRecordingId(recording.id)}
|
||||
>
|
||||
<div className="transcript-title">
|
||||
{recording.title ||
|
||||
`${recording.timestamp.toLocaleTimeString()} - ${formatDuration(recording.duration)}`}
|
||||
</div>
|
||||
{recording.title && (
|
||||
<div className="transcript-time">
|
||||
{recording.timestamp.toLocaleString()} ⢠{formatDuration(recording.duration)}
|
||||
</div>
|
||||
)}
|
||||
<div className="transcript-status">
|
||||
{recording.summary
|
||||
? "ā Summary"
|
||||
: recording.isGeneratingSummary
|
||||
? "ā³ Summarizing..."
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="delete-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteRecording(recording.id);
|
||||
}}
|
||||
title="Delete transcript"
|
||||
>
|
||||
šļø
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
|
||||
// Show setup screen for non-ready states
|
||||
if (appState === "checking" || appState === "downloading_models" || appState === "initializing" || appState === "error") {
|
||||
if (
|
||||
appState === "checking" ||
|
||||
appState === "downloading_models" ||
|
||||
appState === "initializing" ||
|
||||
appState === "error"
|
||||
) {
|
||||
return (
|
||||
<main className="container">
|
||||
<header className="app-header">
|
||||
<h1>šļø Chronara</h1>
|
||||
<p>Local Meeting Transcription & Summarization</p>
|
||||
{appVersion && <span className="app-version">v{appVersion}</span>}
|
||||
</header>
|
||||
{renderSetupScreen()}
|
||||
</main>
|
||||
@@ -313,22 +698,79 @@ function App() {
|
||||
<header className="app-header">
|
||||
<h1>šļø Chronara</h1>
|
||||
<p>Local Meeting Transcription & Summarization</p>
|
||||
{appVersion && <span className="app-version">v{appVersion}</span>}
|
||||
</header>
|
||||
|
||||
<div className="app-content">
|
||||
<div className="app-layout">
|
||||
{/* Left sidebar with recordings list */}
|
||||
{renderRecordingsList()}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="main-content">
|
||||
{renderRecordingControls()}
|
||||
|
||||
{/* Display selected recording or active recording */}
|
||||
{displayedRecording && (
|
||||
<div className="transcript-details">
|
||||
<div className="transcript-header">
|
||||
<h2>
|
||||
{displayedRecording.title ||
|
||||
`Transcript from ${displayedRecording.timestamp.toLocaleString()}`}
|
||||
</h2>
|
||||
<div className="transcript-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => downloadTranscript(displayedRecording.id)}
|
||||
>
|
||||
š Download Transcript
|
||||
</button>
|
||||
{!displayedRecording.summary && !displayedRecording.isGeneratingSummary && (
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => generateSummary(displayedRecording.id)}
|
||||
>
|
||||
⨠Generate Summary
|
||||
</button>
|
||||
)}
|
||||
{displayedRecording.summary && (
|
||||
<>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => copyToClipboard(displayedRecording.summary!)}
|
||||
>
|
||||
š Copy Summary
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => downloadSummary(displayedRecording.id)}
|
||||
>
|
||||
š¾ Download Summary
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-grid">
|
||||
<TranscriptDisplay segments={transcriptSegments} />
|
||||
<TranscriptDisplay segments={displayedRecording.transcriptSegments} />
|
||||
<SummaryDisplay
|
||||
summary={summary}
|
||||
isLoading={isGeneratingSummary}
|
||||
onDownload={downloadSummary}
|
||||
summary={displayedRecording.summary}
|
||||
isLoading={displayedRecording.isGeneratingSummary}
|
||||
progress={displayedRecording.summaryProgress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!displayedRecording && recordings.length === 0 && appState === "ready" && (
|
||||
<div className="empty-state">
|
||||
<p>Click "Start Transcribing" to begin transcribing your first meeting!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackendLogs isVisible={showLogs} onToggle={() => setShowLogs(!showLogs)} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
interface ProgressBarProps {
|
||||
progress: number; // 0-100
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({ progress, label }: ProgressBarProps) {
|
||||
return (
|
||||
<div className="progress-container">
|
||||
{label && <div className="progress-label">{label}</div>}
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-text">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
interface SummaryDisplayProps {
|
||||
summary: string | null;
|
||||
isLoading: boolean;
|
||||
onDownload: () => void;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export function SummaryDisplay({ summary, isLoading, onDownload }: SummaryDisplayProps) {
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
|
||||
export function SummaryDisplay({ summary, isLoading, progress }: SummaryDisplayProps) {
|
||||
return (
|
||||
<div className="summary-display">
|
||||
<div className="summary-header">
|
||||
<h2>Meeting Summary</h2>
|
||||
{summary && (
|
||||
<button className="download-button" onClick={onDownload}>
|
||||
š„ Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-content">
|
||||
{isLoading ? (
|
||||
<div className="loading">
|
||||
{progress !== undefined ? (
|
||||
<ProgressBar progress={progress} label="Generating summary..." />
|
||||
) : (
|
||||
<>
|
||||
<div className="spinner"></div>
|
||||
<p>Generating summary...</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="summary-text">{summary}</div>
|
||||
|
||||
Reference in New Issue
Block a user