12 Commits

Author SHA1 Message Date
naomi e98b115104 pls i beg
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m53s
CI / Lint & Test (pull_request) Failing after 8m18s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
2026-01-29 18:21:03 -08:00
naomi a5d6119ee0 fix: more ci
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m53s
CI / Lint & Test (pull_request) Failing after 7m48s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
2026-01-29 17:58:27 -08:00
naomi 1918ffb868 fix: ci
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m9s
CI / Lint & Test (pull_request) Failing after 8m55s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
2026-01-29 17:39:59 -08:00
naomi f9cb13f53f fix: lints
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 3m4s
CI / Lint & Test (pull_request) Failing after 8m43s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
2026-01-29 17:23:40 -08:00
naomi 23519c0502 feat: display version number
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m4s
CI / Lint & Test (pull_request) Failing after 6m15s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
2026-01-29 16:43:23 -08:00
naomi 76d851ad11 feat: theme 2026-01-29 16:39:55 -08:00
naomi 4ed7462a17 feat: diarisation maybe 2026-01-29 15:34:04 -08:00
naomi 9efda8ded6 feat: create title for each meeting 2026-01-29 14:07:26 -08:00
naomi 9bf92d3365 feat: persist transcripts permanently 2026-01-29 13:21:51 -08:00
naomi 43a544a886 feat: display progress for finalising audio and summarising 2026-01-29 12:34:25 -08:00
naomi a6843cb3f1 feat: template 2026-01-29 11:41:03 -08:00
naomi e6c19b589e feat: handle transcription in real time 2026-01-29 10:31:40 -08:00
15 changed files with 1788 additions and 252 deletions
+13 -1
View File
@@ -24,7 +24,11 @@ jobs:
librsvg2-dev \ librsvg2-dev \
patchelf \ patchelf \
libgtk-3-dev \ libgtk-3-dev \
libayatana-appindicator3-dev libayatana-appindicator3-dev \
libasound2-dev \
pkg-config \
libclang-dev \
cmake
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -94,6 +98,10 @@ jobs:
patchelf \ patchelf \
libgtk-3-dev \ libgtk-3-dev \
libayatana-appindicator3-dev \ libayatana-appindicator3-dev \
libasound2-dev \
pkg-config \
libclang-dev \
cmake \
xdg-utils xdg-utils
- name: Setup pnpm - name: Setup pnpm
@@ -145,6 +153,10 @@ jobs:
patchelf \ patchelf \
libgtk-3-dev \ libgtk-3-dev \
libayatana-appindicator3-dev \ libayatana-appindicator3-dev \
libasound2-dev \
pkg-config \
libclang-dev \
cmake \
clang \ clang \
lld \ lld \
llvm \ llvm \
+2 -2
View File
@@ -6,8 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint .", "lint": "eslint src",
"lint:fix": "eslint . --fix", "lint:fix": "eslint src --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"preview": "vite preview", "preview": "vite preview",
+156 -1
View File
@@ -519,16 +519,19 @@ name = "chronara"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cpal", "cpal",
"dasp",
"futures-util", "futures-util",
"hound", "hound",
"llama-cpp-2", "llama-cpp-2",
"parking_lot", "parking_lot",
"reqwest", "reqwest",
"rustfft",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -813,12 +816,125 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "dasp_sample" name = "dasp_sample"
version = "0.11.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" 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]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -3222,6 +3338,15 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "1.3.1" version = "1.3.1"
@@ -3351,7 +3476,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -3639,6 +3764,20 @@ dependencies = [
"semver", "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]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.3" version = "1.1.3"
@@ -4142,6 +4281,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.8.9" version = "0.8.9"
@@ -4945,6 +5090,16 @@ dependencies = [
"tracing-log", "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]] [[package]]
name = "tray-icon" name = "tray-icon"
version = "0.21.3" version = "0.21.3"
+7
View File
@@ -34,6 +34,10 @@ hound = "3.5" # WAV file handling
# Voice activity detection # Voice activity detection
voice_activity_detector = "0.2" 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 # Async runtime
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@@ -63,6 +67,9 @@ windows = { version = "0.62", features = [
"Win32_UI_Shell_PropertiesSystem", "Win32_UI_Shell_PropertiesSystem",
] } ] }
[dev-dependencies]
tempfile = "3.10"
[patch.crates-io] [patch.crates-io]
# Fix cross-compilation: use CARGO_CFG_TARGET_OS instead of cfg!(windows) # Fix cross-compilation: use CARGO_CFG_TARGET_OS instead of cfg!(windows)
llama-cpp-sys-2 = { path = "../patches/llama-cpp-sys-2" } llama-cpp-sys-2 = { path = "../patches/llama-cpp-sys-2" }
+168 -41
View File
@@ -12,11 +12,13 @@ use tauri::{Emitter, Manager, State};
use tracing::{debug, info}; use tracing::{debug, info};
pub mod ml; pub mod ml;
pub mod storage;
use ml::summarizer::{get_model_filename, LlamaSummarizer}; use ml::summarizer::{get_model_filename, LlamaSummarizer};
use ml::transcriber::{TranscriptSegment, WhisperTranscriber}; use ml::transcriber::{TranscriptSegment, WhisperTranscriber};
use ml::vad::SpeakerSeparator; use ml::vad::SpeakerSeparator;
use ml::audio::AudioCapture; use ml::audio::AudioCapture;
use storage::{RecordingStorage, StoredRecording};
/// Application state containing the ML models and audio capture. /// Application state containing the ML models and audio capture.
struct AppState { struct AppState {
@@ -25,6 +27,7 @@ struct AppState {
speaker_separator: Mutex<Option<SpeakerSeparator>>, speaker_separator: Mutex<Option<SpeakerSeparator>>,
audio_capture: Mutex<Option<AudioCapture>>, audio_capture: Mutex<Option<AudioCapture>>,
logs: Arc<Mutex<Vec<String>>>, logs: Arc<Mutex<Vec<String>>>,
storage: Mutex<Option<RecordingStorage>>,
} }
impl AppState { impl AppState {
@@ -35,6 +38,7 @@ impl AppState {
speaker_separator: Mutex::new(None), speaker_separator: Mutex::new(None),
audio_capture: Mutex::new(None), audio_capture: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())), 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"); emit_log(&app_handle, &logs, "[Init] Model initialization complete");
Ok("Models initialized".to_string()) Ok("Models initialized".to_string())
} }
@@ -316,64 +335,39 @@ fn start_recording(
Ok("Recording started".to_string()) Ok("Recording started".to_string())
} }
/// Stop recording and return the transcript. /// Stop recording (for real-time mode, transcription already happened during recording).
#[tauri::command] #[tauri::command]
async fn stop_recording( async fn stop_recording(
state: State<'_, AppState>, state: State<'_, AppState>,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
) -> Result<Vec<TranscriptSegment>, String> { ) -> Result<String, String> {
let logs = Arc::clone(&state.logs); let logs = Arc::clone(&state.logs);
emit_log(&app_handle, &logs, "[Audio] Stopping recording..."); emit_log(&app_handle, &logs, "[Audio] Stopping recording...");
// Get the audio samples // Stop the recording and get the duration
let audio_samples = { let duration = {
let mut audio_guard = state.audio_capture.lock(); let mut audio_guard = state.audio_capture.lock();
if let Some(ref mut capture) = *audio_guard { if let Some(ref mut capture) = *audio_guard {
capture.stop_recording() let samples = capture.stop_recording();
samples.len() as f32 / 16000.0
} else { } else {
return Err("No active recording".to_string()); return Err("No active recording".to_string());
} }
}; };
let duration = audio_samples.len() as f32 / 16000.0; emit_log(&app_handle, &logs, &format!("[Audio] Recording stopped. Total duration: {:.1}s", duration));
emit_log(&app_handle, &logs, &format!("[Audio] Captured {:.1}s of audio", duration)); Ok("Recording stopped".to_string())
if audio_samples.is_empty() {
return Err("No audio captured".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). /// Transcribe a chunk of audio (for real-time transcription).
#[tauri::command] #[tauri::command]
async fn transcribe_chunk( async fn transcribe_chunk(
state: State<'_, AppState>, state: State<'_, AppState>,
audio_data: Vec<f32>, audio_data: Vec<f32>,
app_handle: tauri::AppHandle,
chunk_start_time: f64,
) -> Result<Vec<TranscriptSegment>, String> { ) -> Result<Vec<TranscriptSegment>, String> {
let transcriber = state.transcriber.lock(); let transcriber = state.transcriber.lock();
@@ -381,19 +375,75 @@ async fn transcribe_chunk(
return Err("Whisper model not loaded".to_string()); 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))?; .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) 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. /// Generate a summary from a transcript.
#[tauri::command] #[tauri::command]
async fn summarize( async fn summarize(
state: State<'_, AppState>, state: State<'_, AppState>,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
transcript: String, transcript: String,
) -> Result<String, String> { ) -> Result<(String, Option<String>), String> {
let logs = Arc::clone(&state.logs); let logs = Arc::clone(&state.logs);
emit_log(&app_handle, &logs, "[Summary] Generating summary..."); emit_log(&app_handle, &logs, "[Summary] Generating summary...");
@@ -406,12 +456,22 @@ async fn summarize(
return Err("LLaMA model not loaded".to_string()); 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))?; .map_err(|e| format!("Summarization failed: {}", e))?;
emit_log(&app_handle, &logs, &format!("[Summary] Generated {} character summary", summary.len())); 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. /// Get backend logs.
@@ -427,11 +487,72 @@ fn check_ready(state: State<'_, AppState>) -> Result<bool, String> {
// At minimum, we need the summarizer loaded // At minimum, we need the summarizer loaded
// Whisper can be loaded on first use // 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) 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize tracing // Initialize tracing
@@ -449,9 +570,15 @@ pub fn run() {
start_recording, start_recording,
stop_recording, stop_recording,
transcribe_chunk, transcribe_chunk,
get_audio_chunk,
get_remaining_audio,
summarize, summarize,
get_backend_logs, get_backend_logs,
check_ready, check_ready,
save_recording,
load_recordings,
delete_recording,
update_recording,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+45
View File
@@ -196,6 +196,51 @@ impl AudioCapture {
let sample_count = self.buffer.lock().len(); let sample_count = self.buffer.lock().len();
sample_count as f32 / WHISPER_SAMPLE_RATE as f32 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 { impl Default for AudioCapture {
+40 -10
View File
@@ -71,6 +71,14 @@ impl LlamaSummarizer {
/// Generate a meeting summary from the transcript. /// Generate a meeting summary from the transcript.
pub fn summarize(&self, transcript: &str) -> Result<String, SummarizerError> { 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 let model = self
.model .model
.as_ref() .as_ref()
@@ -78,20 +86,26 @@ impl LlamaSummarizer {
let prompt = format!( let prompt = format!(
"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n\ "<|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\ You are a helpful assistant that creates structured meeting summaries. \
- Key decisions made\n\ Format your response using the following template:\n\n\
- Action items and who owns them\n\ **Title:** A concise, descriptive title for the meeting (5-10 words max).\n\n\
- Important discussions and their outcomes\n\ **Summary:** A high level overview of the meeting.\n\n\
- Next steps\n\n\ **Key decisions:** Any important resolutions that the meeting reached.\n\n\
Keep the summary structured and easy to scan.<|eot_id|><|start_header_id|>user<|end_header_id|>\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\ Please summarize this meeting transcript:\n\n\
{}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n\ {}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n",
Meeting Summary:\n",
transcript transcript
); );
debug!("Generating summary for {} character transcript", transcript.len()); debug!("Generating summary for {} character transcript", transcript.len());
// Report initial progress
progress_fn(0.0);
// Create context for inference // Create context for inference
let ctx_params = LlamaContextParams::default() let ctx_params = LlamaContextParams::default()
.with_n_ctx(NonZeroU32::new(8192)); .with_n_ctx(NonZeroU32::new(8192));
@@ -100,6 +114,9 @@ impl LlamaSummarizer {
.new_context(self.backend.as_ref().unwrap(), ctx_params) .new_context(self.backend.as_ref().unwrap(), ctx_params)
.map_err(|e| SummarizerError::ContextError(e.to_string()))?; .map_err(|e| SummarizerError::ContextError(e.to_string()))?;
// Report progress after context creation
progress_fn(0.1);
// Tokenize the prompt // Tokenize the prompt
let tokens = model let tokens = model
.str_to_token(&prompt, llama_cpp_2::model::AddBos::Always) .str_to_token(&prompt, llama_cpp_2::model::AddBos::Always)
@@ -107,6 +124,9 @@ impl LlamaSummarizer {
debug!("Prompt tokenized to {} tokens", tokens.len()); debug!("Prompt tokenized to {} tokens", tokens.len());
// Report progress after tokenization
progress_fn(0.2);
// Create batch and add tokens // Create batch and add tokens
let mut batch = LlamaBatch::new(tokens.len(), 1); let mut batch = LlamaBatch::new(tokens.len(), 1);
@@ -120,6 +140,9 @@ impl LlamaSummarizer {
ctx.decode(&mut batch) ctx.decode(&mut batch)
.map_err(|e| SummarizerError::SummarizationError(e.to_string()))?; .map_err(|e| SummarizerError::SummarizationError(e.to_string()))?;
// Report progress after prompt decode
progress_fn(0.3);
// Generate response tokens // Generate response tokens
let mut output_tokens = Vec::new(); let mut output_tokens = Vec::new();
let max_tokens = 1024; let max_tokens = 1024;
@@ -132,10 +155,10 @@ impl LlamaSummarizer {
LlamaSampler::dist(42), LlamaSampler::dist(42),
]); ]);
for _ in 0..max_tokens { for i in 0..max_tokens {
// Sample the next token using the sampler // Sample the next token using the sampler
// The sampler.sample() takes context and the index of the last token in the batch // 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 // Check for end of generation
if model.is_eog_token(token) { if model.is_eog_token(token) {
@@ -144,6 +167,10 @@ impl LlamaSummarizer {
output_tokens.push(token); 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 // Prepare next batch
batch.clear(); batch.clear();
batch.add(token, n_cur as i32, &[0], true) 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()) .filter_map(|t| model.token_to_str(*t, llama_cpp_2::model::Special::Tokenize).ok())
.collect::<String>(); .collect::<String>();
// Report completion
progress_fn(1.0);
info!("Summary generated: {} characters", output_text.len()); info!("Summary generated: {} characters", output_text.len());
Ok(output_text.trim().to_string()) Ok(output_text.trim().to_string())
} }
+20
View File
@@ -65,6 +65,14 @@ impl WhisperTranscriber {
/// Transcribe audio samples (expected to be 16kHz mono f32). /// Transcribe audio samples (expected to be 16kHz mono f32).
pub fn transcribe(&self, audio: &[f32]) -> Result<Vec<TranscriptSegment>, TranscriberError> { 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 let context = self
.context .context
.as_ref() .as_ref()
@@ -74,11 +82,17 @@ impl WhisperTranscriber {
audio.len(), audio.len(),
audio.len() as f64 / 16000.0); audio.len() as f64 / 16000.0);
// Report initial progress
progress_fn(0.0);
// Create a state for this transcription // Create a state for this transcription
let mut state = context let mut state = context
.create_state() .create_state()
.map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?; .map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?;
// Report progress after state creation
progress_fn(0.2);
// Set up transcription parameters // Set up transcription parameters
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
@@ -96,6 +110,9 @@ impl WhisperTranscriber {
.full(params, audio) .full(params, audio)
.map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?; .map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?;
// Report progress after transcription
progress_fn(0.8);
// Extract segments // Extract segments
let num_segments = state.full_n_segments() let num_segments = state.full_n_segments()
.map_err(|e| TranscriberError::TranscriptionError(e.to_string()))?; .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()); info!("Transcription complete: {} segments", segments.len());
Ok(segments) Ok(segments)
} }
+257 -47
View File
@@ -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 //! This module uses the Silero VAD model combined with voice characteristics
//! and provides basic speaker separation based on silence gaps. //! to provide improved speaker separation for meetings with multiple speakers.
use rustfft::{FftPlanner, num_complex::Complex};
use std::collections::HashMap;
use thiserror::Error; use thiserror::Error;
use tracing::{debug, info}; use tracing::{debug, info, warn};
use voice_activity_detector::VoiceActivityDetector; use voice_activity_detector::VoiceActivityDetector;
use super::transcriber::TranscriptSegment; use super::transcriber::TranscriptSegment;
@@ -20,12 +22,41 @@ pub enum VadError {
ProcessingError(String), 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)] #[derive(Debug, Clone)]
pub struct SpeechSegment { pub struct SpeechSegment {
pub start_sample: usize, pub start_sample: usize,
pub end_sample: usize, pub end_sample: usize,
pub speaker_id: u32, pub speaker_id: u32,
pub fingerprint: Option<VoiceFingerprint>,
} }
impl SpeechSegment { 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 { pub struct SpeakerSeparator {
vad: VoiceActivityDetector, vad: VoiceActivityDetector,
/// Minimum silence duration (in seconds) to consider a speaker change /// 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 /// Minimum speech duration (in seconds) to consider a valid segment
min_speech_duration: f64, 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 { impl SpeakerSeparator {
/// Create a new speaker separator with default settings. /// Create a new speaker separator with default settings.
pub fn new() -> Result<Self, VadError> { 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. /// Create a new speaker separator with custom settings.
@@ -75,87 +114,212 @@ impl SpeakerSeparator {
.build() .build()
.map_err(|e: voice_activity_detector::Error| VadError::InitError(e.to_string()))?; .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); VAD_SAMPLE_RATE, min_silence_for_speaker_change, min_speech_duration);
Ok(Self { Ok(Self {
vad, vad,
min_silence_for_speaker_change, _min_silence_for_speaker_change: min_silence_for_speaker_change,
min_speech_duration, 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, /// This enhanced version uses voice fingerprinting to track speakers across
/// we assume a different speaker might be talking. /// overlapping speech and short interruptions, making it suitable for group meetings.
pub fn detect_speakers(&mut self, audio: &[f32]) -> Result<Vec<SpeechSegment>, VadError> { pub fn detect_speakers(&mut self, audio: &[f32]) -> Result<Vec<SpeechSegment>, VadError> {
let chunk_size = 512; 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 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; 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() { for (chunk_idx, chunk) in audio.chunks(chunk_size).enumerate() {
if chunk.len() < chunk_size { if chunk.len() < chunk_size {
// Skip incomplete chunks at the end
continue; continue;
} }
let sample_offset = chunk_idx * chunk_size; let sample_offset = chunk_idx * chunk_size;
// Predict voice activity for this chunk
let probability = self.vad.predict(chunk.iter().copied()); let probability = self.vad.predict(chunk.iter().copied());
let is_speech = probability > 0.5; let is_speech = probability > 0.5;
if is_speech { if is_speech {
if current_segment_start.is_none() { 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); current_segment_start = Some(sample_offset);
} }
} else if let Some(start) = current_segment_start { } else if let Some(start) = current_segment_start {
// Speech ended
let segment_duration = sample_offset - start; let segment_duration = sample_offset - start;
if segment_duration >= min_speech_samples { if segment_duration >= min_speech_samples {
segments.push(SpeechSegment { raw_segments.push((start, sample_offset));
start_sample: start,
end_sample: sample_offset,
speaker_id: current_speaker_id,
});
} }
last_speech_end = Some(sample_offset);
current_segment_start = None; 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 { if let Some(start) = current_segment_start {
let segment_duration = audio.len() - start; let segment_duration = audio.len() - start;
if segment_duration >= min_speech_samples { if segment_duration >= min_speech_samples {
segments.push(SpeechSegment { raw_segments.push((start, audio.len()));
start_sample: start,
end_sample: audio.len(),
speaker_id: current_speaker_id,
});
} }
} }
// 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: end,
speaker_id,
fingerprint,
});
}
info!("Detected {} speech segments with {} speakers", info!("Detected {} speech segments with {} speakers",
segments.len(), segments.len(),
segments.iter().map(|s| s.speaker_id).max().unwrap_or(0) + 1); segments.iter().map(|s| s.speaker_id).max().unwrap_or(0) + 1);
@@ -188,6 +352,51 @@ impl SpeakerSeparator {
Ok(transcript) 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 { impl Default for SpeakerSeparator {
@@ -206,6 +415,7 @@ mod tests {
start_sample: 16000, start_sample: 16000,
end_sample: 32000, end_sample: 32000,
speaker_id: 0, speaker_id: 0,
fingerprint: None,
}; };
assert!((segment.start_seconds() - 1.0).abs() < 0.001); assert!((segment.start_seconds() - 1.0).abs() < 0.001);
+184
View File
@@ -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);
}
}
+1 -3
View File
@@ -37,9 +37,7 @@
"installerIcon": "icons/icon.ico" "installerIcon": "icons/icon.ico"
} }
}, },
"resources": [ "resources": ["resources/"],
"resources/"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+338 -54
View File
@@ -4,16 +4,28 @@
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
--primary-color: #3b82f6; /* Witchy Purple Rose Palette */
--primary-hover: #2563eb; --witch-purple: #2b1b3d;
--secondary-color: #10b981; --witch-plum: #44275a;
--danger-color: #ef4444; --witch-rose: #a8577e;
--bg-color: #ffffff; --witch-mauve: #d4a5c7;
--surface-color: #f9fafb; --witch-lavender: #e8d5e8;
--text-color: #111827; --witch-black: #0a0009;
--text-secondary: #6b7280; --witch-silver: #c0c0c0;
--border-color: #e5e7eb; --witch-moon: #f5f5f5;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); --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); color: var(--text-color);
background-color: var(--bg-color); background-color: var(--bg-color);
@@ -61,14 +73,38 @@ body {
font-size: 1.125rem; 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 */
.warning-banner { .warning-banner {
background-color: #fef3c7; background-color: var(--witch-plum);
color: #92400e; color: var(--witch-lavender);
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
text-align: center; text-align: center;
border: 1px solid var(--witch-rose);
} }
/* App Content */ /* App Content */
@@ -99,9 +135,9 @@ body {
font-size: 1.25rem; font-size: 1.25rem;
padding: 1rem 2rem; padding: 1rem 2rem;
border-radius: 0.75rem; border-radius: 0.75rem;
border: 2px solid transparent; border: 2px solid var(--witch-rose);
background-color: var(--primary-color); background-color: var(--primary-color);
color: white; color: var(--witch-moon);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@@ -109,11 +145,10 @@ body {
} }
.record-button:hover { .record-button:hover {
background-color: var(--primary-hover); background-color: var(--witch-mauve);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: box-shadow: 0 6px 12px -2px var(--witch-shadow);
0 4px 6px -1px rgba(0, 0, 0, 0.1), border-color: var(--witch-lavender);
0 2px 4px -1px rgba(0, 0, 0, 0.06);
} }
.record-button.recording { .record-button.recording {
@@ -121,7 +156,7 @@ body {
} }
.record-button.recording:hover { .record-button.recording:hover {
background-color: #dc2626; background-color: #b94a6d;
} }
.recording-indicator { .recording-indicator {
@@ -162,7 +197,7 @@ body {
gap: 1rem; gap: 1rem;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
background-color: var(--surface-color); background-color: var(--surface-color);
border: 1px solid var(--danger-color); border: 1px solid var(--witch-rose);
border-radius: 0.75rem; border-radius: 0.75rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
@@ -175,6 +210,12 @@ body {
animation: pulse 1.5s infinite; animation: pulse 1.5s infinite;
} }
.real-time-indicator {
font-size: 0.875rem;
color: var(--secondary-color);
font-style: italic;
}
.recording-duration { .recording-duration {
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -184,9 +225,9 @@ body {
.stop-button { .stop-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 2px solid var(--danger-color); border: 2px solid var(--witch-rose);
background-color: transparent; background-color: transparent;
color: var(--danger-color); color: var(--witch-rose);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -194,8 +235,9 @@ body {
} }
.stop-button:hover { .stop-button:hover {
background-color: var(--danger-color); background-color: var(--witch-rose);
color: white; color: var(--witch-moon);
box-shadow: 0 2px 4px var(--witch-shadow);
} }
/* Transcribing Indicator */ /* Transcribing Indicator */
@@ -242,13 +284,16 @@ body {
} }
.primary-button { .primary-button {
background-color: var(--secondary-color); background-color: var(--witch-mauve);
color: white; color: var(--witch-purple);
font-weight: 600;
} }
.primary-button:hover:not(:disabled) { .primary-button:hover:not(:disabled) {
background-color: #059669; background-color: var(--witch-rose);
color: var(--witch-moon);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 8px var(--witch-shadow);
} }
.primary-button:disabled { .primary-button:disabled {
@@ -257,13 +302,15 @@ body {
} }
.secondary-button { .secondary-button {
background-color: var(--surface-color); background-color: transparent;
color: var(--text-color); color: var(--witch-lavender);
border: 1px solid var(--border-color); border: 1px solid var(--witch-plum);
} }
.secondary-button:hover { .secondary-button:hover {
background-color: var(--border-color); background-color: var(--witch-plum);
color: var(--witch-moon);
border-color: var(--witch-rose);
} }
/* Content Grid */ /* Content Grid */
@@ -290,6 +337,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 600px; max-height: 600px;
box-shadow: var(--shadow);
} }
.transcript-display h2, .transcript-display h2,
@@ -331,6 +379,7 @@ body {
.speaker { .speaker {
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--witch-mauve);
} }
.timestamp { .timestamp {
@@ -353,15 +402,17 @@ body {
.download-button { .download-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid var(--border-color); border: 1px solid var(--witch-plum);
background-color: var(--bg-color); background-color: transparent;
color: var(--witch-silver);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.download-button:hover { .download-button:hover {
background-color: var(--surface-color); background-color: var(--witch-plum);
color: var(--witch-moon);
} }
.summary-text { .summary-text {
@@ -382,8 +433,9 @@ body {
.spinner { .spinner {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
border: 3px solid var(--border-color); border: 3px solid var(--witch-plum);
border-top-color: var(--primary-color); border-top-color: var(--witch-rose);
border-right-color: var(--witch-mauve);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@@ -426,7 +478,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: 100; z-index: 100;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 -2px 10px var(--witch-shadow);
} }
.logs-header { .logs-header {
@@ -462,10 +514,10 @@ body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 0.75rem; font-size: 0.75rem;
background-color: #1a1a2e; background-color: var(--witch-black);
color: #d4d4d4; color: var(--witch-silver);
} }
.logs-empty { .logs-empty {
@@ -546,7 +598,7 @@ body {
.error-message { .error-message {
color: var(--danger-color); color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.1); background-color: rgba(212, 101, 142, 0.1);
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
@@ -564,19 +616,251 @@ body {
margin: 1rem auto; margin: 1rem auto;
} }
/* Dark Mode */ /* Unified Witchy Purple Rose Theme - No Dark/Light Modes */
@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);
}
.warning-banner { /* New layout styles */
background-color: #451a03; .app-layout {
color: #fbbf24; display: flex;
flex: 1;
gap: 2rem;
min-height: 0;
}
.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;
}
+526 -84
View File
@@ -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 { 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 "./App.css";
import { TranscriptDisplay } from "./components/TranscriptDisplay"; import { TranscriptDisplay } from "./components/TranscriptDisplay";
import { SummaryDisplay } from "./components/SummaryDisplay"; import { SummaryDisplay } from "./components/SummaryDisplay";
import { BackendLogs } from "./components/BackendLogs"; import { BackendLogs } from "./components/BackendLogs";
import { ProgressBar } from "./components/ProgressBar";
interface TranscriptSegment { interface TranscriptSegment {
start: number; start: number;
@@ -12,37 +15,63 @@ interface TranscriptSegment {
speaker: string; 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() { function App() {
const [transcriptSegments, setTranscriptSegments] = useState<TranscriptSegment[]>([]); const [recordings, setRecordings] = useState<Recording[]>([]);
const [summary, setSummary] = useState<string | null>(null); const [activeRecording, setActiveRecording] = useState<Recording | null>(null);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false); const [selectedRecordingId, setSelectedRecordingId] = useState<string | null>(null);
const [appState, setAppState] = useState<AppState>("checking"); const [appState, setAppState] = useState<AppState>("checking");
const [statusMessage, setStatusMessage] = useState("Checking setup..."); const [statusMessage, setStatusMessage] = useState("Checking setup...");
const [showLogs, setShowLogs] = useState(false); const [showLogs, setShowLogs] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
const [appVersion, setAppVersion] = useState<string>("");
const initStarted = useRef(false); const initStarted = useRef(false);
const recordingTimer = useRef<number | null>(null); 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(() => { // Define initializeApp before using it
if (initStarted.current) return; const initializeApp = useCallback(async () => {
initStarted.current = true;
initializeApp();
}, []);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (recordingTimer.current) {
clearInterval(recordingTimer.current);
}
};
}, []);
const initializeApp = async () => {
try { try {
setAppState("checking"); setAppState("checking");
setStatusMessage("Checking if models are present..."); setStatusMessage("Checking if models are present...");
@@ -59,7 +88,9 @@ function App() {
// Check again after download // Check again after download
const modelsVerified = await invoke<boolean>("check_models"); const modelsVerified = await invoke<boolean>("check_models");
if (!modelsVerified) { 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"); await invoke("initialize_models");
// Check if models are ready
const ready = await invoke<boolean>("check_ready"); const ready = await invoke<boolean>("check_ready");
if (!ready) { if (!ready) {
throw new Error("Models failed to initialize. Check logs for details."); throw new Error("Models failed to initialize. Check logs for details.");
@@ -79,13 +109,103 @@ function App() {
setAppState("ready"); setAppState("ready");
setStatusMessage(""); setStatusMessage("");
setShowLogs(false); 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) { } catch (error) {
console.error("Initialization failed:", error); console.error("Initialization failed:", error);
setAppState("error"); setAppState("error");
setErrorMessage(String(error)); setErrorMessage(String(error));
setShowLogs(true); 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 = () => { const retrySetup = () => {
setErrorMessage(null); setErrorMessage(null);
@@ -93,19 +213,82 @@ function App() {
initializeApp(); 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 () => { const startRecording = async () => {
try { try {
setAppState("recording"); setAppState("recording");
setRecordingDuration(0); setRecordingDuration(0);
setTranscriptSegments([]); audioOffset.current = 0;
setSummary(null); 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"); await invoke("start_recording");
// Start timer to show recording duration // Start timer to show recording duration
recordingTimer.current = window.setInterval(() => { recordingTimer.current = window.setInterval(() => {
setRecordingDuration(d => d + 1); setRecordingDuration((d) => d + 1);
}, 1000); }, 1000);
// Start real-time transcription timer (every 5 seconds)
transcriptionTimer.current = window.setInterval(() => {
processAudioChunk();
}, 5000);
} catch (error) { } catch (error) {
console.error("Failed to start recording:", error); console.error("Failed to start recording:", error);
setAppState("ready"); setAppState("ready");
@@ -115,49 +298,204 @@ function App() {
const stopRecording = async () => { const stopRecording = async () => {
try { try {
// Stop the timer // Stop the timers first
if (recordingTimer.current) { if (recordingTimer.current) {
clearInterval(recordingTimer.current); clearInterval(recordingTimer.current);
recordingTimer.current = null; recordingTimer.current = null;
} }
if (transcriptionTimer.current) {
clearInterval(transcriptionTimer.current);
transcriptionTimer.current = null;
}
setAppState("transcribing"); setAppState("transcribing");
setStatusMessage("Transcribing audio..."); setStatusMessage("Processing final audio...");
setTranscriptionProgress(0);
const segments = await invoke<TranscriptSegment[]>("stop_recording"); // First, process any audio that hasn't been processed yet
setTranscriptSegments(segments); 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"); setAppState("ready");
setStatusMessage(""); setStatusMessage("");
setTranscriptionProgress(0);
} catch (error) { } catch (error) {
console.error("Failed to stop recording:", error); console.error("Failed to stop recording:", error);
setAppState("ready"); setAppState("ready");
setStatusMessage("");
setErrorMessage(String(error)); setErrorMessage(String(error));
} }
}; };
const generateSummary = async () => { const generateSummary = async (recordingId: string) => {
if (transcriptSegments.length === 0) return; 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}`) .map((seg) => `${seg.speaker}: ${seg.text}`)
.join("\n"); .join("\n");
try { try {
const summaryResult = await invoke<string>("summarize", { transcript: fullTranscript }); // Progress will be updated via events from the backend
setSummary(summaryResult); 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) { } catch (error) {
console.error("Failed to generate summary:", error); console.error("Failed to generate summary:", error);
setErrorMessage(String(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 copyToClipboard = async (text: string) => {
const content = transcriptSegments 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}`) .map((seg) => `[${formatTime(seg.start)}] ${seg.speaker}: ${seg.text}`)
.join("\n"); .join("\n");
@@ -165,19 +503,20 @@ function App() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; 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(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const downloadSummary = () => { const downloadSummary = (recordingId: string) => {
if (!summary) return; 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 url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; 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(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
@@ -194,6 +533,11 @@ function App() {
return `${mins}:${secs.toString().padStart(2, "0")}`; 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 = () => ( const renderSetupScreen = () => (
<div className="setup-screen"> <div className="setup-screen">
<div className="setup-content"> <div className="setup-content">
@@ -227,9 +571,7 @@ function App() {
<li>Whisper - Speech recognition</li> <li>Whisper - Speech recognition</li>
<li>VAD - Speaker detection</li> <li>VAD - Speaker detection</li>
</ul> </ul>
<p className="setup-note"> <p className="setup-note">This is a pure Rust backend - no Python required!</p>
This is a pure Rust backend - no Python required!
</p>
</div> </div>
)} )}
@@ -242,9 +584,9 @@ function App() {
</div> </div>
)} )}
{(appState === "checking" || appState === "initializing" || appState === "downloading_models") && ( {(appState === "checking" ||
<div className="loading-spinner" /> appState === "initializing" ||
)} appState === "downloading_models") && <div className="loading-spinner" />}
</div> </div>
<BackendLogs isVisible={showLogs} onToggle={() => setShowLogs(!showLogs)} /> <BackendLogs isVisible={showLogs} onToggle={() => setShowLogs(!showLogs)} />
@@ -255,7 +597,7 @@ function App() {
<section className="controls-section"> <section className="controls-section">
{appState === "ready" && ( {appState === "ready" && (
<button className="record-button" onClick={startRecording}> <button className="record-button" onClick={startRecording}>
šŸŽ™ļø Start Recording šŸŽ™ļø Start Transcribing
</button> </button>
)} )}
@@ -264,44 +606,87 @@ function App() {
<div className="recording-indicator"> <div className="recording-indicator">
<span className="recording-dot" /> <span className="recording-dot" />
Recording: {formatDuration(recordingDuration)} Recording: {formatDuration(recordingDuration)}
{activeRecording && activeRecording.transcriptSegments.length > 0 && (
<span className="real-time-indicator"> (Real-time transcription active)</span>
)}
</div> </div>
<button className="stop-button" onClick={stopRecording}> <button className="stop-button" onClick={stopRecording}>
ā¹ļø Stop Recording ā¹ļø Stop Transcribing
</button> </button>
</div> </div>
)} )}
{appState === "transcribing" && ( {appState === "transcribing" && (
<div className="transcribing-indicator"> <div className="transcribing-indicator">
<div className="loading-spinner small" /> <ProgressBar progress={transcriptionProgress} label="Processing final audio..." />
<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>
</div> </div>
)} )}
</section> </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 // 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 ( return (
<main className="container"> <main className="container">
<header className="app-header"> <header className="app-header">
<h1>šŸŽ™ļø Chronara</h1> <h1>šŸŽ™ļø Chronara</h1>
<p>Local Meeting Transcription & Summarization</p> <p>Local Meeting Transcription & Summarization</p>
{appVersion && <span className="app-version">v{appVersion}</span>}
</header> </header>
{renderSetupScreen()} {renderSetupScreen()}
</main> </main>
@@ -313,22 +698,79 @@ function App() {
<header className="app-header"> <header className="app-header">
<h1>šŸŽ™ļø Chronara</h1> <h1>šŸŽ™ļø Chronara</h1>
<p>Local Meeting Transcription & Summarization</p> <p>Local Meeting Transcription & Summarization</p>
{appVersion && <span className="app-version">v{appVersion}</span>}
</header> </header>
<div className="app-content"> <div className="app-layout">
{renderRecordingControls()} {/* Left sidebar with recordings list */}
{renderRecordingsList()}
<div className="content-grid"> {/* Main content area */}
<TranscriptDisplay segments={transcriptSegments} /> <div className="main-content">
<SummaryDisplay {renderRecordingControls()}
summary={summary}
isLoading={isGeneratingSummary} {/* Display selected recording or active recording */}
onDownload={downloadSummary} {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={displayedRecording.transcriptSegments} />
<SummaryDisplay
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> </div>
<BackendLogs isVisible={showLogs} onToggle={() => setShowLogs(!showLogs)} />
</main> </main>
); );
} }
+19
View File
@@ -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>
);
}
+12 -9
View File
@@ -1,25 +1,28 @@
interface SummaryDisplayProps { interface SummaryDisplayProps {
summary: string | null; summary: string | null;
isLoading: boolean; 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 ( return (
<div className="summary-display"> <div className="summary-display">
<div className="summary-header"> <div className="summary-header">
<h2>Meeting Summary</h2> <h2>Meeting Summary</h2>
{summary && (
<button className="download-button" onClick={onDownload}>
šŸ“„ Download
</button>
)}
</div> </div>
<div className="summary-content"> <div className="summary-content">
{isLoading ? ( {isLoading ? (
<div className="loading"> <div className="loading">
<div className="spinner"></div> {progress !== undefined ? (
<p>Generating summary...</p> <ProgressBar progress={progress} label="Generating summary..." />
) : (
<>
<div className="spinner"></div>
<p>Generating summary...</p>
</>
)}
</div> </div>
) : summary ? ( ) : summary ? (
<div className="summary-text">{summary}</div> <div className="summary-text">{summary}</div>