//! Tauri command handlers — the frontend's only entry into the core. //! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work //! is spawned and reported via events ("recording://*", "transcript://*", …). //! //! Phases 1-5 commands are real implementations; everything for a //! not-yet-reached phase returns `Err(not_implemented(...))` rather than //! panicking — `panic = "abort"` in the release profile means any panic //! reachable from a command handler kills the whole app, not just that call. use crate::audio::{AudioCapture, WasapiCapture}; use crate::calendar::{CalImport, CalendarSource, PstSource}; use crate::diarization::{Diarizer, SherpaDiarizer}; use crate::error::WaResult; use crate::hardware::{HardwareDetector, WinHardwareDetector}; use crate::models::*; use crate::notes::NotesRenderer; use crate::paths::{ diarization_embedding_model_file, diarization_segmentation_model_file, manual_notes_file, meeting_dir, settings_path, wa_root, whisper_model_file, }; use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile, SyncTargetRow}; use crate::transcription::{ models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber, }; use crate::{error::WaError, AppState, RecordingSession}; use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use tauri::{AppHandle, Emitter, Manager, State}; /// ~8s of 16kHz mono mic audio — enough for a stable speaker-embedding /// voiceprint (see `diarization::voiceprint`) without holding minutes of raw /// audio in memory for the whole meeting. const MIC_VOICEPRINT_SAMPLES: usize = 16_000 * 8; #[derive(Deserialize)] pub struct StartRecordingArgs { pub meeting_title: Option, pub calendar_event_id: Option, /// Retain audio as .wav? Defaults to false (ADR-0009). Controls retention, not capture. #[serde(default)] pub record: bool, /// Note-template id (Phase 8, T8.1, FR-NOTE-5) — see `notes::built_in_note_templates`. pub template_id: Option, /// Per-meeting transcription language override (T8.7, FR-TRX-4): `None` /// falls back to `Settings.whisper_language`; `None`/`"auto"` (either /// way) requests auto-detection. Only takes effect with a multilingual /// model — see `transcription::resolve_language`. #[serde(default)] pub language: Option, } // ---- File-backed settings (consent/default-retention/storage policy) ---- // `settings.json` per `docs/03-data-model.md`; meeting rows/files go through // `AppState.store` (Phase 2, `storage::SqliteStore`). fn default_settings() -> Settings { Settings { theme: "system".into(), storage_root: wa_root().display().to_string(), llm_provider: "ollama".into(), llm_endpoint: "http://localhost:11434".into(), llm_model: "llama3".into(), llm_advanced: serde_json::Value::Null, preferred_backend: "auto".into(), whisper_model: crate::paths::DEFAULT_WHISPER_MODEL.to_string(), whisper_language: None, // auto-detect by default (T8.7, FR-TRX-4) low_overhead: false, default_record: false, consent_acknowledged: false, hosted_ai_acknowledged: false, sync_enabled: false, retention_max_age_days: None, retention_max_size_gb: None, pst_last_path: None, pst_auto_sync: false, pst_import_range_days: None, auto_record_calendar: false, graph_calendar_enabled: false, graph_calendar_credential_ref: None, audio_output_device: None, microphone_enabled: true, audio_input_device: None, mcp_enabled: false, mcp_transport: "http".into(), mcp_port: 4849, mcp_expose: "none".into(), mcp_expose_recordings: false, } } pub(crate) fn load_settings() -> Settings { std::fs::read_to_string(settings_path()) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_else(default_settings) } fn save_settings(settings: &Settings) -> Result<(), WaError> { let path = settings_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| WaError::new("settings", e.to_string()))?; } let json = serde_json::to_string_pretty(settings) .map_err(|e| WaError::new("settings", e.to_string()))?; std::fs::write(path, json).map_err(|e| WaError::new("settings", e.to_string())) } /// Resolves which model file a recording should use: the "low overhead" preset /// (T3.9) always takes the smallest catalog model over whatever is configured, /// since it's optimizing for overhead, not accuracy. fn model_id_for(settings: &Settings) -> String { if settings.low_overhead { model_catalog::smallest_id().to_string() } else { settings.whisper_model.clone() } } /// Picks the backend to transcribe with: "low overhead" always forces CPU; /// otherwise resolve the user's preferred backend (or auto-detect) against /// what's actually available (T3.2, T3.5). fn backend_for(settings: &Settings) -> BackendId { if settings.low_overhead { return BackendId::Cpu; } let preferred = match settings.preferred_backend.as_str() { "npu" => Some(BackendId::Npu), "nvidia" => Some(BackendId::Nvidia), "amd" => Some(BackendId::Amd), "intel" => Some(BackendId::Intel), "cpu" => Some(BackendId::Cpu), _ => None, // "auto" }; WinHardwareDetector.best(preferred).id } /// Builds the transcriber for `backend`: the NPU backend goes to the /// ONNX/OpenVINO engine (T3.4), everything else to whisper.cpp. Falls through to /// whisper.cpp on CPU if the chosen engine can't load, and returns the backend /// *actually* used so callers can update their "active backend" state (T3.5, /// FR-HW-4). One place so streaming and batch dispatch identically. #[cfg(feature = "cpu-transcription")] fn load_transcriber( backend: BackendId, whisper_model: &Path, language: Option<&str>, ) -> Result<(Box, BackendId), crate::transcription::TrxError> { use crate::hardware::{resolve_accel, AccelPath}; // Resolve how this backend is actually served in *this* build (CUDA/Vulkan // baked in? NPU/DirectML runtime present?) rather than assuming a GPU // backend has a working accel path just because the hardware exists. let path = resolve_accel(backend); // ONNX engine — NPU (OpenVINO EP) or AMD/Intel GPU (DirectML EP). Same model // artifacts either way; the EP is chosen inside OnnxTranscriber::load from // `backend`, and the resolved `backend` is reported so the UI shows the real // engine. #[cfg(feature = "npu")] if matches!(path, AccelPath::OnnxOpenVino | AccelPath::OnnxDirectML) { use crate::transcription::{onnx_models, OnnxTranscriber}; if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) { let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL); match OnnxTranscriber::load(&dir, backend, language) { Ok(t) => return Ok((Box::new(t), backend)), Err(e) => tracing::warn!("ONNX engine load failed ({e}); falling back to CPU"), } } else { tracing::warn!( "ONNX backend selected but model not installed; falling back to whisper.cpp" ); } } // whisper.cpp path: only ask for GPU offload when the resolver picked a // whisper GPU backend that's compiled in — otherwise a bogus `use_gpu` for a // vendor with no accel path just no-ops. Anything else decodes on the CPU. let whisper_backend = match path { AccelPath::WhisperCuda | AccelPath::WhisperVulkan => backend, _ => BackendId::Cpu, }; match WhisperTranscriber::load(whisper_model, whisper_backend, language) { Ok(t) => Ok((Box::new(t), whisper_backend)), Err(e) if whisper_backend != BackendId::Cpu => { tracing::warn!("backend {whisper_backend:?} failed to load ({e}); falling back to CPU"); WhisperTranscriber::load(whisper_model, BackendId::Cpu, language) .map(|t| (Box::new(t) as Box, BackendId::Cpu)) } Err(e) => Err(e), } } /// Normalizes a requested language string the same way whisper.cpp itself /// treats it: an empty string or the literal `"auto"` both mean "no explicit /// language" — matching `FullParams::set_language`'s own `None`/`Some("auto")` /// equivalence (T8.7, FR-TRX-4) — so callers can pass either spelling through /// from settings/args without duplicating this check everywhere. fn normalize_language(language: Option<&str>) -> Option<&str> { match language { Some(l) if !l.is_empty() && !l.eq_ignore_ascii_case("auto") => Some(l), _ => None, } } fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// A command whose feature isn't built yet (roadmap phase not reached). This /// is deliberately a returned `Err`, never a `todo!()`/`unimplemented!()`: /// the release profile sets `panic = "abort"`, so a panic anywhere a command /// handler can reach would kill the whole app the instant any frontend code /// path calls it — not just fail that one call (found the hard way: an /// unconditional `listSyncTargets()` on startup was crashing every launch). fn not_implemented(feature: &str) -> WaError { WaError::new("not_implemented", format!("{feature} isn't built yet")) } /// Builds a `Diarizer` if both diarization models are installed — a fixed /// pair of well-known filenames, downloadable/removable via /// `diarization::models` and `list_diarization_models`/`download_model`/ /// `remove_model` (T4.7). Returns `None` rather than erring — diarization is /// a provisional/refinement layer that a recording never depends on, same /// treatment as a missing hardware backend. fn diarizer_from_installed_models() -> Option { let seg_model = diarization_segmentation_model_file(); let emb_model = diarization_embedding_model_file(); if !seg_model.exists() || !emb_model.exists() { return None; } match SherpaDiarizer::new(&seg_model, &emb_model) { Ok(d) => Some(d), Err(e) => { tracing::warn!("diarization models present but failed to load: {e}"); None } } } /// On-disk `mic_activity.json`: the mic's "You" ranges (Phase 3, FR-SPK), /// persisted next to a retained `audio.wav` so `reprocess_transcript` can /// re-attribute "You" without the live capture timeline. #[derive(serde::Serialize, serde::Deserialize)] struct MicTimelineFile { schema: u32, you_spans: Vec<(u64, u64)>, } /// Phase 3 (FR-SPK) attribution, shared by `stop_recording` and /// `reprocess_transcript`: label the mic's `you_spans` "You", diarize the /// recording with those ranges masked out (so clustering only ever sees the far /// side), assign the merged spans to `segments`, and return the You + Speaker /// name map. `None` when the far-side pass fails — the caller then falls back to /// the whole-signal pass + voiceprint match. async fn phase3_attribute( diarizer: Arc, wav_path: PathBuf, you_spans: Vec<(u64, u64)>, segments: &mut [TranscriptSegment], ) -> Option> { let diarizer_for_task = diarizer.clone(); let you_for_task = you_spans.clone(); let far = tauri::async_runtime::spawn_blocking(move || { let samples = crate::audio::read_wav_mono_16k(&wav_path).map_err(|e| e.to_string())?; diarizer_for_task .diarize_samples(mask_ranges(samples, &you_for_task)) .map_err(|e| e.to_string()) }) .await; let far_spans = match far { Ok(Ok(spans)) => spans, Ok(Err(e)) => { tracing::warn!("phase 3 far-side diarization failed: {e}"); return None; } Err(e) => { tracing::warn!("phase 3 diarization task failed: {e}"); return None; } }; let mut spans: Vec = you_spans .iter() .map(|&(start_ms, end_ms)| SpeakerSpan { start_ms, end_ms, speaker: "You".to_string(), }) .collect(); spans.extend(far_spans); spans.sort_by_key(|s| s.start_ms); diarizer.assign(segments, &spans); // Reuse the voiceprint path's naming so labels are uniform app-wide: // "You" -> "You", every far speaker in first-appearance order -> "Speaker 2"…. let labels = crate::diarization::voiceprint::first_appearance_order(&spans); Some(crate::diarization::voiceprint::build_name_map(&labels, "You")) } /// Zero out `ranges` (given as `(start_ms, end_ms)`) in 16kHz mono `samples` — /// used to remove the mic's "You" ranges from the recording before diarizing the /// far side, so clustering never sees the mic (Phase 3, FR-SPK). fn mask_ranges(mut samples: Vec, ranges: &[(u64, u64)]) -> Vec { const SAMPLES_PER_MS: usize = 16; // 16kHz mono for &(start_ms, end_ms) in ranges { let start = (start_ms as usize) .saturating_mul(SAMPLES_PER_MS) .min(samples.len()); let end = (end_ms as usize) .saturating_mul(SAMPLES_PER_MS) .min(samples.len()); if start < end { samples[start..end].fill(0.0); } } samples } /// The distinct speakers seen in `segments` so far, in first-appearance order, /// with any display names applied (T4.3/T4.4, FR-SPK-2/5). Falls back to the /// single pre-diarization "S1" placeholder if no segments exist yet. fn speaker_infos_from_segments( segments: &[TranscriptSegment], names: &HashMap, ) -> Vec { let mut seen = std::collections::HashSet::new(); let mut out: Vec = segments .iter() .filter(|s| seen.insert(s.speaker.clone())) .map(|s| SpeakerInfo { label: s.speaker.clone(), display_name: names.get(&s.speaker).cloned(), participant_id: None, }) .collect(); if out.is_empty() { out.push(SpeakerInfo { label: "S1".to_string(), display_name: names.get("S1").cloned(), participant_id: None, }); } out } // ---- Recording lifecycle (Phase 1) ---- #[tauri::command] pub async fn start_recording( app: AppHandle, state: State<'_, AppState>, args: StartRecordingArgs, ) -> WaResult { let mut guard = state.session.lock().await; if guard.is_some() { return Err(WaError::new( "recording", "a meeting is already in progress", )); } if args.record && !load_settings().consent_acknowledged { return Err(WaError::new( "consent", "recording consent has not been acknowledged yet", )); } let settings = load_settings(); let model_id = model_id_for(&settings); let backend = backend_for(&settings); let model_path = whisper_model_file(&model_id); if !model_path.exists() { return Err(WaError::new( "transcription", format!( "whisper model '{model_id}' not found at {}; download it from Settings first", model_path.display() ), )); } // T8.7/FR-TRX-4: a per-meeting override wins over the Settings default; // both spellings of "no explicit language" collapse to `None` here. let language: Option = normalize_language( args.language .as_deref() .or(settings.whisper_language.as_deref()), ) .map(|s| s.to_string()); let meeting_id = state .store .create_meeting(NewMeeting { title: args .meeting_title .unwrap_or_else(|| "Untitled meeting".to_string()), calendar_event_id: args.calendar_event_id, template_id: args.template_id, language: language.clone(), }) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let wav_path = meeting_dir(&meeting_id).join("audio.wav"); // Bounded so a slow/stalled transcription worker can never back up the capture thread. let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::>(8); // Level updates + device-change notices (FR-CAP-5/6); low-volume, forwarded to events below. let (event_tx, event_rx) = std::sync::mpsc::sync_channel::(8); // When the mic is enabled (FR-CAP-7), capture it alongside loopback and let // the mixer sum both into `frame_tx`; with it off, loopback feeds the // transcription worker directly, exactly as before (no mixer overhead). A // microphone that fails to open must not sink the meeting: we log and fall // back to loopback-only (the mixer forwards loopback alone once its sink // drops). let (capture, mic_capture, mic_voice_sample, mic_activity) = if settings.microphone_enabled { // The mixer sums both streams for the live transcript; the bridge carries // the mic into the loopback thread so the recorded WAV holds both sides // at native quality (FR-CAP-7). let bridge = crate::audio::MicBridge::shared(); // A few seconds of raw mic audio for the post-stop voiceprint match // (bug: mic speaker mislabeled "S1"/"S2" instead of "You"). let voice_sample = crate::audio::VoiceSample::new(MIC_VOICEPRINT_SAMPLES); // Phase 3 (FR-SPK): timeline of when the mic ("You") is speaking, filled // by the loopback writer where the mic is folded into audio.wav. let mic_activity = crate::audio::MicActivity::shared(); let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx); let capture = WasapiCapture .start_loopback_recording( &wav_path, settings.audio_output_device.as_deref(), loop_sink, event_tx.clone(), bridge.clone(), Some(mic_activity.clone()), ) .map_err(|e| WaError::new("audio", e.to_string()))?; let mic = WasapiCapture .start_microphone_recording( settings.audio_input_device.as_deref(), mic_sink, event_tx, bridge, Some(voice_sample.clone()), ) .map_err(|e| tracing::warn!("microphone capture unavailable: {e}")) .ok(); let voice_sample = mic.is_some().then_some(voice_sample); let mic_activity = mic.is_some().then_some(mic_activity); (capture, mic, voice_sample, mic_activity) } else { let capture = WasapiCapture .start( &wav_path, settings.audio_output_device.as_deref(), frame_tx, event_tx, ) .map_err(|e| WaError::new("audio", e.to_string()))?; (capture, None, None, None) }; // Fire-and-forget: exits on its own once `event_tx` drops at capture stop; // nothing downstream needs to join it. let app_for_capture_events = app.clone(); let meeting_id_for_capture_events = meeting_id.clone(); std::thread::Builder::new() .name("wa-capture-events".into()) .spawn(move || { for event in event_rx { match event { crate::audio::CaptureEvent::Level(level) => { let _ = app_for_capture_events.emit( "recording://level", serde_json::json!({ "meetingId": meeting_id_for_capture_events, "rms": level.rms, "peak": level.peak, "mic": level.mic, }), ); } crate::audio::CaptureEvent::DeviceChanged { recovered, message } => { let _ = app_for_capture_events.emit( "recording://device", serde_json::json!({ "meetingId": meeting_id_for_capture_events, "recovered": recovered, "message": message, }), ); } } } }) .map_err(|e| WaError::new("audio", e.to_string()))?; let segments: Arc>> = Arc::new(StdMutex::new(Vec::new())); let segments_for_worker = segments.clone(); let active_backend: Arc> = Arc::new(StdMutex::new(backend)); let active_backend_for_worker = active_backend.clone(); // T8.7/FR-TRX-4: starts at the requested value, then the worker refines // it — first to what the loaded engine actually resolved (e.g. forced // "en" for an English-only model), then to whatever was last // used/detected once streaming stops. `stop_recording` reads the final // value after joining this thread. let language_state: Arc>> = Arc::new(StdMutex::new(language.clone())); let language_state_for_worker = language_state.clone(); let language_for_worker = language.clone(); let app_for_worker = app.clone(); let meeting_id_for_worker = meeting_id.clone(); // Live-transcript cadence; the low-overhead preset trades a touch of // immediacy for half the decode load (see StreamTuning). let stream_tuning = crate::transcription::StreamTuning::new(settings.low_overhead); let transcription_worker = std::thread::Builder::new() .name("wa-transcription".into()) .spawn(move || { // Dispatch to the NPU engine or whisper.cpp, with graceful CPU // fall-through (T3.4/T3.5, FR-HW-4): a GPU/NPU load failure (driver // issue, OOM, missing model) drops to CPU rather than losing the // meeting's transcript entirely. let (transcriber, used) = match load_transcriber(backend, &model_path, language_for_worker.as_deref()) { Ok(pair) => pair, Err(e) => { tracing::error!("failed to load any transcriber: {e}"); return; } }; if used != backend { if let Ok(mut b) = active_backend_for_worker.lock() { *b = used; } let _ = app_for_worker.emit( "hardware://changed", serde_json::json!({ "active": used.as_str(), "reason": format!("{backend:?} unavailable; using {used:?}"), }), ); } if let Ok(mut lang) = language_state_for_worker.lock() { *lang = transcriber.effective_language(); } run_streaming_worker(transcriber.as_ref(), frame_rx, stream_tuning, |segment| { // Only committed lines are the real transcript; interim updates // are live-UI only (emitted below). Storing interims would push // a duplicate row per refresh into the buffer stop_recording and // the live-diarization preview both read. A committed line's id // is stable, so replace-in-place guards against any re-commit. if !segment.interim { if let Ok(mut buf) = segments_for_worker.lock() { match buf.iter_mut().find(|s| s.id == segment.id) { Some(existing) => *existing = segment.clone(), None => buf.push(segment.clone()), } } } let _ = app_for_worker.emit( "transcript://segment", serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }), ); }); // Prefer whatever the engine actually used/detected on its last // decode over the load-time resolution above — meaningful for // "auto" mode, where the real answer only exists after decoding. if let Some(detected) = transcriber.detected_language() { if let Ok(mut lang) = language_state_for_worker.lock() { *lang = Some(detected); } } }) .map_err(|e| WaError::new("transcription", e.to_string()))?; // T4.3: cheap/provisional live diarization, skipped entirely (None) when // the diarization models aren't installed yet (T4.7) — never blocks // recording, same graceful-degradation treatment as a missing backend. // Loading the ONNX models is blocking I/O, so it runs off this async task. let diarizer: Option> = tauri::async_runtime::spawn_blocking(diarizer_from_installed_models) .await .ok() .flatten() .map(|d| Arc::new(d) as Arc); let speaker_names: Arc>> = Arc::new(StdMutex::new(HashMap::new())); if let Some(diarizer) = diarizer.clone() { let app_for_diar = app.clone(); let meeting_id_for_diar = meeting_id.clone(); let wav_path_for_diar = wav_path.clone(); let segments_for_diar = segments.clone(); let names_for_diar = speaker_names.clone(); let voice_sample_for_diar = mic_voice_sample.clone(); tauri::async_runtime::spawn(async move { // ponytail: reprocesses the whole recording-so-far each tick // rather than incremental/windowed segmentation — sherpa-onnx's // offline Diarizer has no streaming primitive to build on, and // this is provisional preview only (the accurate pass runs once // at stop). Fine at meeting length and a 15s cadence; revisit // with real streaming segmentation if long meetings make it heavy. let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15)); ticker.tick().await; // interval's first tick fires immediately; skip it loop { ticker.tick().await; let still_active = app_for_diar .state::() .session .lock() .await .as_ref() .is_some_and(|s| s.meeting_id == meeting_id_for_diar); if !still_active { break; // recording stopped (or a new one started) — nothing left to do } let diarizer_for_pass = diarizer.clone(); let wav_path = wav_path_for_diar.clone(); let spans = tauri::async_runtime::spawn_blocking(move || { diarizer_for_pass.diarize(&wav_path) }) .await; let spans = match spans { Ok(Ok(spans)) => spans, Ok(Err(e)) => { tracing::warn!("live diarization pass failed: {e}"); continue; } Err(e) => { tracing::warn!("live diarization task failed: {e}"); continue; } }; let (speakers, changed) = { let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner()); // Snapshot prior labels so only segments whose speaker // actually changed this pass get re-emitted (ids are stable; // the frontend replaces by id). let before: HashMap = segs.iter().map(|s| (s.id, s.speaker.clone())).collect(); diarizer.assign(&mut segs, &spans); // Live "You": match the mic voiceprint against this pass's // clusters. Clusters re-shuffle every tick so match every // tick; never overwrite a name already set (user rename or a // prior pass) — same guard as the post-stop pass. if let Some(voice_sample) = &voice_sample_for_diar { let mic_samples = voice_sample.samples(); match crate::diarization::voiceprint::match_mic_speaker( &diarization_embedding_model_file(), &mic_samples, &wav_path_for_diar, &spans, ) { Ok(auto_names) => { let mut names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner()); for (label, name) in auto_names { names.entry(label).or_insert(name); } } Err(e) => tracing::warn!("live voiceprint match failed: {e}"), } } let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner()); let changed: Vec = segs .iter() .filter(|s| before.get(&s.id) != Some(&s.speaker)) .cloned() .collect(); (speaker_infos_from_segments(&segs, &names), changed) }; let _ = app_for_diar.emit( "diarization://updated", serde_json::json!({ "meetingId": meeting_id_for_diar, "speakers": speakers }), ); // Re-emit relabeled committed segments so the live transcript // reflects the refined speakers without a new event type // (docs/04-api-contracts.md: transcript://segment is replace-by-id). for segment in &changed { let _ = app_for_diar.emit( "transcript://segment", serde_json::json!({ "meetingId": meeting_id_for_diar, "segment": segment }), ); } } }); } *guard = Some(RecordingSession { meeting_id: meeting_id.clone(), capture, mic_capture, retention: args.record, wav_path, started_at: std::time::Instant::now(), transcription_worker, segments, active_backend, model_id, language: language_state, diarizer, speaker_names, mic_voice_sample, mic_activity, manual_notes: Arc::new(StdMutex::new(ManualNotes::default())), }); drop(guard); crate::update_tray_tooltip(&app, "WhispAssist — recording"); let _ = app.emit( "recording://state", serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }), ); Ok(meeting_id) } #[tauri::command] pub async fn stop_recording( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, ) -> WaResult<()> { let mut guard = state.session.lock().await; let session = match guard.take() { Some(s) if s.meeting_id == meeting_id => s, Some(s) => { *guard = Some(s); return Err(WaError::new( "recording", "meeting_id does not match the active recording", )); } None => { return Err(WaError::new( "recording", "no meeting is currently recording", )) } }; drop(guard); let summary = WasapiCapture .stop(session.capture) .map_err(|e| WaError::new("audio", e.to_string()))?; // Stop the mic too (FR-CAP-7) so the mixer sees both sinks drop and closes // `frame_tx`; ignore its summary/errors — the loopback WAV is the recording. if let Some(mic) = session.mic_capture { let _ = WasapiCapture.stop(mic); } // Both capture threads have now dropped their `FrameSink`s; the transcription // worker's `recv()` returns `Err` and the worker exits on its own — join to // guarantee it has fully drained the audio before we act on retention. let _ = session.transcription_worker.join(); let mut segments = session .segments .lock() .map(|g| g.clone()) .unwrap_or_default(); let segment_count = segments.len(); // Speaker attribution over the now-complete recording. // Phase 3 (FR-SPK): with the mic on, the mic *is* "You" — take "You" spans // straight from the activity timeline (no clustering) and diarize the far // side with those ranges masked out, so the mic can never merge into a // speaker cluster. Falls back to the whole-signal pass (ADR-0005) + // voiceprint "You" when there's no mic timeline (mic off) or the pass fails. let mut speaker_names: HashMap = HashMap::new(); let mut attributed = false; if let (Some(diarizer), Some(mic_activity)) = (session.diarizer.clone(), session.mic_activity.clone()) { let you = mic_activity.you_spans(); if let Some(names) = phase3_attribute(diarizer, session.wav_path.clone(), you, &mut segments).await { speaker_names = names; attributed = true; } } if !attributed { // Fallback: one whole-signal pass, then voiceprint-match the mic against // the clusters to auto-label "You" (never overriding a user-set name). let mut final_spans: Option> = None; if let Some(diarizer) = session.diarizer.clone() { let diarizer_for_task = diarizer.clone(); let wav_path = session.wav_path.clone(); match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wav_path)) .await { Ok(Ok(spans)) => { diarizer.assign(&mut segments, &spans); final_spans = Some(spans); } Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"), Err(e) => tracing::warn!("final diarization task failed: {e}"), } } if let (Some(voice_sample), Some(spans)) = (&session.mic_voice_sample, &final_spans) { let mic_samples = voice_sample.samples(); match crate::diarization::voiceprint::match_mic_speaker( &diarization_embedding_model_file(), &mic_samples, &session.wav_path, spans, ) { Ok(auto_names) => { for (label, name) in auto_names { let already_named = session .speaker_names .lock() .map(|g| g.contains_key(&label)) .unwrap_or(true); // poisoned lock: don't guess, skip if already_named { continue; } if let Err(e) = state.store.rename_speaker(&meeting_id, &label, &name).await { tracing::warn!("failed to persist auto speaker name: {e}"); continue; } if let Ok(mut names) = session.speaker_names.lock() { names.insert(label, name); } } } Err(e) => tracing::warn!("mic voiceprint match failed: {e}"), } } speaker_names = session .speaker_names .lock() .map(|g| g.clone()) .unwrap_or_default(); } let speakers = speaker_infos_from_segments(&segments, &speaker_names); let backend_used = session .active_backend .lock() .map(|b| b.as_str().to_string()) .unwrap_or_else(|_| BackendId::Cpu.as_str().to_string()); // T8.7/FR-TRX-4: whatever the transcription worker last resolved — // explicit request, English-only forcing, or auto-detected result. let language = session.language.lock().ok().and_then(|g| g.clone()); // T2.10: persist transcript.json (via finalize_meeting) and notes.md // *before* touching the working WAV, so a crash here still leaves a // recoverable, regenerable meeting. let template_id = state .store .finalize_meeting( &meeting_id, FinalizeMeeting { segments: segments.clone(), speakers: speakers.clone(), duration_secs: (summary.duration_ms / 1000) as i64, recorded: session.retention, language, backend_used: Some(backend_used), model_used: Some(session.model_id.clone()), }, ) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let template = template_id .as_deref() .and_then(crate::notes::note_template_by_id); // Granola-style redesign: fold in whatever was captured live (freeform // notes typed during the meeting + per-moment annotations) instead of // generating notes.md from the transcript alone. let manual_notes = session .manual_notes .lock() .map(|g| g.clone()) .unwrap_or_default(); let notes_md = crate::notes::MarkdownNotes.merge( &segments, &speakers, &manual_notes, None, template.as_ref(), ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( "transcript://finalized", serde_json::json!({ "meetingId": meeting_id, "segmentCount": segment_count }), ); // ADR-0009: delete the working WAV only after the transcript is finalized // above, and only when retention is off. let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav"); let mic_activity_path = meeting_dir(&meeting_id).join("mic_activity.json"); if !session.retention { let _ = std::fs::remove_file(&session.wav_path); // voiceprint.wav + mic_activity.json live and die with audio.wav // (ADR-0009); never written when retention is off, but remove // defensively regardless. let _ = std::fs::remove_file(&voiceprint_path); let _ = std::fs::remove_file(&mic_activity_path); } else if crate::vault::is_unlocked() { // Seal the retained recording at rest when the vault is unlocked (T8.8). // Runs before the sync enqueue below, so any uploaded copy is ciphertext. if let Ok(raw) = std::fs::read(&session.wav_path) { if let Ok(sealed) = crate::vault::seal(&raw) { let _ = std::fs::write(&session.wav_path, sealed); } } } // FR-SPK: persist the mic voiceprint alongside a retained recording so // reprocess_transcript can re-identify "You" after it re-clusters. Retained // audio of the user's own voice, so it's gated on the same ADR-0009 // consent/retention as audio.wav and sealed at rest the same way. if session.retention { if let Some(voice_sample) = &session.mic_voice_sample { let samples = voice_sample.samples(); if let Err(e) = crate::audio::write_wav_mono_16k(&voiceprint_path, &samples) { tracing::warn!("failed to persist voiceprint.wav: {e}"); } else if crate::vault::is_unlocked() { if let Ok(raw) = std::fs::read(&voiceprint_path) { if let Ok(sealed) = crate::vault::seal(&raw) { let _ = std::fs::write(&voiceprint_path, sealed); } } } } // Phase 3 (FR-SPK): persist the mic "You" timeline so reprocess can // re-attribute without the live capture. Plaintext timing metadata (no // audio), local-only (not a sync artifact), lives/dies with audio.wav. if let Some(mic_activity) = &session.mic_activity { let file = MicTimelineFile { schema: 1, you_spans: mic_activity.you_spans(), }; match serde_json::to_string(&file) { Ok(json) => { if let Err(e) = std::fs::write(&mic_activity_path, json) { tracing::warn!("failed to persist mic_activity.json: {e}"); } } Err(e) => tracing::warn!("failed to serialize mic_activity.json: {e}"), } } } // Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for // finalize-trigger targets and pump in the background so stop returns // promptly. Runs after the WAV-retention decision above, so a deleted // working recording is simply skipped (its file is gone). if load_settings().sync_enabled { let store = state.store.clone(); let app_sync = app.clone(); let mid = meeting_id.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = enqueue_meeting_sync(store.as_ref(), &mid, None, true).await { tracing::warn!("sync enqueue on finalize failed: {e:?}"); } pump_sync(&app_sync, store.as_ref()).await; }); } crate::update_tray_tooltip(&app, "WhispAssist — idle"); let _ = app.emit( "recording://state", serde_json::json!({ "meetingId": meeting_id, "state": "stopped", "elapsedMs": summary.duration_ms }), ); Ok(()) } /// Abandon the in-progress recording: stop capture, drop the transcript, and /// delete the meeting row + its working files entirely — for a recording that /// was started by mistake. Unlike `stop_recording`, nothing is finalized, /// transcribed further, diarized, retained, or synced. #[tauri::command] pub async fn cancel_recording( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, ) -> WaResult<()> { let mut guard = state.session.lock().await; let session = match guard.take() { Some(s) if s.meeting_id == meeting_id => s, Some(s) => { *guard = Some(s); return Err(WaError::new( "recording", "meeting_id does not match the active recording", )); } None => { return Err(WaError::new( "recording", "no meeting is currently recording", )) } }; drop(guard); // Stop both captures so their frame sinks drop and the transcription worker // exits; join it so nothing is still touching the files we're about to delete. let _ = WasapiCapture.stop(session.capture); if let Some(mic) = session.mic_capture { let _ = WasapiCapture.stop(mic); } let _ = session.transcription_worker.join(); // Remove the DB row + the whole meeting folder (working audio.wav included). state .store .delete_meeting(&meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; crate::update_tray_tooltip(&app, "WhispAssist — idle"); let _ = app.emit( "recording://state", serde_json::json!({ "meetingId": meeting_id, "state": "cancelled", "elapsedMs": 0 }), ); Ok(()) } #[tauri::command] pub async fn pause_recording( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, ) -> WaResult<()> { let guard = state.session.lock().await; let session = guard .as_ref() .filter(|s| s.meeting_id == meeting_id) .ok_or_else(|| WaError::new("recording", "no matching active recording"))?; WasapiCapture .pause(&session.capture) .map_err(|e| WaError::new("audio", e.to_string()))?; if let Some(mic) = session.mic_capture.as_ref() { WasapiCapture .pause(mic) .map_err(|e| WaError::new("audio", e.to_string()))?; } drop(guard); let _ = app.emit( "recording://state", serde_json::json!({ "meetingId": meeting_id, "state": "paused", "elapsedMs": 0 }), ); Ok(()) } #[tauri::command] pub async fn resume_recording( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, ) -> WaResult<()> { let guard = state.session.lock().await; let session = guard .as_ref() .filter(|s| s.meeting_id == meeting_id) .ok_or_else(|| WaError::new("recording", "no matching active recording"))?; WasapiCapture .resume(&session.capture) .map_err(|e| WaError::new("audio", e.to_string()))?; if let Some(mic) = session.mic_capture.as_ref() { WasapiCapture .resume(mic) .map_err(|e| WaError::new("audio", e.to_string()))?; } drop(guard); let _ = app.emit( "recording://state", serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }), ); Ok(()) } /// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1). #[tauri::command] pub async fn set_recording_retention( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, record: bool, ) -> WaResult<()> { if record && !load_settings().consent_acknowledged { return Err(WaError::new( "consent", "recording consent has not been acknowledged yet", )); } let mut guard = state.session.lock().await; let session = guard .as_mut() .filter(|s| s.meeting_id == meeting_id) .ok_or_else(|| WaError::new("recording", "no matching active recording"))?; session.retention = record; drop(guard); let _ = app.emit( "recording://retention", serde_json::json!({ "meetingId": meeting_id, "record": record }), ); Ok(()) } /// Record the one-time recording-consent acknowledgment (FR-REC-2). #[tauri::command] pub async fn acknowledge_recording_consent() -> WaResult<()> { let mut settings = load_settings(); settings.consent_acknowledged = true; save_settings(&settings) } // ---- Live notes: Granola-style redesign (freeform + per-moment, during recording) ---- /// Best-effort write-through of `manual_notes.json` — crash safety for what /// the user typed live, same spirit as T2.8's recover-scan. Never fails the /// calling command on a disk error; the in-memory copy (what `stop_recording` /// reads) is already updated by the time this runs. fn persist_manual_notes(meeting_id: &MeetingId, manual: &ManualNotes) { match serde_json::to_vec_pretty(manual) { Ok(bytes) => { if let Err(e) = std::fs::write(manual_notes_file(meeting_id), bytes) { tracing::warn!("failed to persist manual notes for {meeting_id}: {e}"); } } Err(e) => tracing::warn!("failed to serialize manual notes for {meeting_id}: {e}"), } } /// Reads `manual_notes.json` for a finalize path with no live /// `RecordingSession` to read it from in-memory — crash recovery /// (`resume_transcription`, T2.8) and post-finalize batch re-transcription /// (`reprocess_transcript`, T3.8) both re-render `notes.md` from scratch, and /// must not silently drop whatever manual notes were captured during the /// original recording. Defaults to empty if the file is missing (a meeting /// with the mic-notes feature never used, or nothing typed) or unreadable. fn load_manual_notes(meeting_id: &MeetingId) -> ManualNotes { std::fs::read(manual_notes_file(meeting_id)) .ok() .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default() } /// Update the freeform notes typed live during an in-progress recording — /// the "Notes pane, open and typable while recording" feature. Live-session /// only: once a meeting is finalized, `notes.md` is the single editable /// document and `update_notes` is the command for it. #[tauri::command] pub async fn update_live_notes( state: State<'_, AppState>, meeting_id: MeetingId, markdown: String, ) -> WaResult<()> { let guard = state.session.lock().await; let session = guard .as_ref() .filter(|s| s.meeting_id == meeting_id) .ok_or_else(|| WaError::new("recording", "no matching active recording"))?; let manual = { let mut manual = session .manual_notes .lock() .unwrap_or_else(|e| e.into_inner()); manual.freeform_md = markdown; manual.clone() }; drop(guard); persist_manual_notes(&meeting_id, &manual); Ok(()) } /// Attach (or clear, with `text: ""`) a note to a specific moment in an /// in-progress recording — the "click a transcript line, add a note to it" /// feature. Anchored by timestamp rather than segment id: a later batch /// re-transcription (T3.8) can renumber segments, but never moves the moment /// in time the note pointed at. Live-session only, same reasoning as /// `update_live_notes`. #[tauri::command] pub async fn set_segment_note( state: State<'_, AppState>, meeting_id: MeetingId, anchor_ms: u64, text: String, ) -> WaResult<()> { let guard = state.session.lock().await; let session = guard .as_ref() .filter(|s| s.meeting_id == meeting_id) .ok_or_else(|| WaError::new("recording", "no matching active recording"))?; let manual = { let mut manual = session .manual_notes .lock() .unwrap_or_else(|e| e.into_inner()); upsert_segment_note(&mut manual.segment_notes, anchor_ms, text, now_unix()); manual.clone() }; drop(guard); persist_manual_notes(&meeting_id, &manual); Ok(()) } /// Update the note at `anchor_ms` in place if one already exists (matches /// a re-click on an already-annotated segment), else append a new one. fn upsert_segment_note(notes: &mut Vec, anchor_ms: u64, text: String, now: i64) { match notes.iter_mut().find(|n| n.anchor_ms == anchor_ms) { Some(existing) => { existing.text = text; existing.updated_at = now; } None => notes.push(SegmentNote { anchor_ms, text, created_at: now, updated_at: now, }), } } // ---- Speakers (Phase 4) ---- /// Tells the frontend a finalized meeting's speaker names/mapping changed /// (T4.5/4.6, FR-SPK-3/5), e.g. after a rename or merge. /// /// Bug fix: this used to also re-render and overwrite `notes.md` from the /// current segments+speakers on every call — which, once the notes redesign /// made `notes.md` the user's actual freely-edited document (manual notes + /// transcript merged at finalize, see `MarkdownNotes::merge`), would have /// silently destroyed whatever the user had written. `notes.md` is only ever /// generated once, at finalize; a later rename updates the `speakers` table /// and live UI display, and simply doesn't retroactively rewrite text /// already baked into notes.md — same as any other manual edit isn't /// retroactively touched either. async fn refresh_notes_and_notify( app: &AppHandle, state: &State<'_, AppState>, meeting_id: &MeetingId, ) -> WaResult> { let meeting = state .store .get_meeting(meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let _ = app.emit( "diarization://updated", serde_json::json!({ "meetingId": meeting_id, "speakers": meeting.speakers }), ); Ok(meeting.speakers) } /// Name a speaker; applies to that speaker's past & future segments (T4.4, /// FR-SPK-2). Segments only ever carry the internal label ("S1"…) — never /// rewritten — so persisting the label→name mapping here is enough to cover /// both past and future segments once names are resolved at render time /// (FR-SPK-5). Works whether the meeting is still recording or already /// finalized. #[tauri::command] pub async fn rename_speaker( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, label: String, name: String, ) -> WaResult<()> { state .store .rename_speaker(&meeting_id, &label, &name) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let guard = state.session.lock().await; match guard.as_ref().filter(|s| s.meeting_id == meeting_id) { Some(session) => { // Still recording: transcript.json/notes.md don't exist on disk // yet, so there's nothing to re-render — just refresh the live // in-memory view (finalize builds notes.md from this same map // at stop, T4.3/4.4). let names = { let mut names = session .speaker_names .lock() .unwrap_or_else(|e| e.into_inner()); names.insert(label, name); names.clone() }; let segments = session .segments .lock() .map(|g| g.clone()) .unwrap_or_default(); drop(guard); let speakers = speaker_infos_from_segments(&segments, &names); let _ = app.emit( "diarization://updated", serde_json::json!({ "meetingId": meeting_id, "speakers": speakers }), ); } None => { drop(guard); refresh_notes_and_notify(&app, &state, &meeting_id).await?; } } Ok(()) } /// Fold over-split speakers into one canonical label (T4.5, FR-SPK-3) — e.g. /// diarization split one person into "S2" and "S3"; merging them shows one /// name and one grouped paragraph in notes/export. Post-meeting only: while /// still recording, the live provisional pass (T4.3) re-clusters from scratch /// every tick, so a label merged now could mean something else by the next /// tick. #[tauri::command] pub async fn merge_speakers( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, from: Vec, into: String, ) -> WaResult<()> { let guard = state.session.lock().await; if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) { return Err(WaError::new( "recording", "cannot merge speakers while this meeting is still recording — wait until it's stopped", )); } drop(guard); state .store .merge_speakers(&meeting_id, &from, &into) .await .map_err(|e| WaError::new("storage", e.to_string()))?; refresh_notes_and_notify(&app, &state, &meeting_id).await?; Ok(()) } /// Name a speaker AND link them to a known `Participant` (T6.5/T6.6, /// FR-SPK-4) — e.g. picked from the linked calendar event's attendee /// dropdown rather than typed free-text. The shared `participant_id` is what /// gives naming continuity across meetings the same person attends. Post- /// meeting only, same reasoning as `merge_speakers`: the live provisional /// diarization pass re-clusters from scratch every tick, so a label mapped /// now could mean someone else by the next tick. #[tauri::command] pub async fn map_speaker_to_participant( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, label: String, participant_id: String, ) -> WaResult<()> { let guard = state.session.lock().await; if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) { return Err(WaError::new( "recording", "cannot map a speaker to a participant while this meeting is still recording — wait until it's stopped", )); } drop(guard); state .store .map_speaker_to_participant(&meeting_id, &label, &participant_id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; refresh_notes_and_notify(&app, &state, &meeting_id).await?; Ok(()) } // ---- App metadata (About page) ---- /// Version + build commit for the About page. Version comes from Cargo; the /// short commit hash is baked in at build time by `build.rs` (`WA_GIT_HASH`). #[tauri::command] pub async fn app_info() -> WaResult { Ok(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "commit": env!("WA_GIT_HASH"), })) } /// Opens an http(s) URL in the user's default browser (About page source link). /// Windows-only (WA is Windows-native, ADR-0001). The scheme is validated so /// this can't be coerced into launching a local path or program, and `explorer` /// receives the URL as a single argv (no shell), so there's no injection surface. #[tauri::command] pub async fn open_url(url: String) -> WaResult<()> { if !(url.starts_with("https://") || url.starts_with("http://")) { return Err(WaError::new("app", "only http(s) URLs may be opened")); } #[cfg(windows)] { std::process::Command::new("explorer") .arg(&url) .spawn() .map_err(|e| WaError::new("app", e.to_string()))?; Ok(()) } #[cfg(not(windows))] { let _ = url; Err(WaError::new("app", "unsupported platform")) } } // ---- Hardware + models (Phase 3) ---- #[tauri::command] pub async fn hardware_status() -> WaResult { let settings = load_settings(); let backends = WinHardwareDetector.detect(); let active = backend_for(&settings); let model_id = model_id_for(&settings); Ok(serde_json::json!({ "backends": backends, "active": active, "modelSize": model_id, // ponytail: no real-time factor measurement harness yet (needs a // timed sample transcription); 1.0 stands in until T3.6 wires one up. "estRtf": 1.0, // NPU package state (T3.4 step 2) — drives the Settings ▸ Hardware // download indicator: chip detected, its runtime staged, model fetched. "npu": { "present": crate::hardware::npu_hardware_present(), "runtimeReady": crate::paths::npu_runtime_ready(), "modelInstalled": npu_model_installed(), }, // DirectML GPU package (P2): shares the ONNX model with the NPU path; // only the runtime differs. `applicable` gates the Settings ▸ Hardware // card so it only shows when DirectML would actually help this build. "directml": { "applicable": crate::hardware::directml_would_help(), "runtimeReady": crate::paths::directml_runtime_ready(), "modelInstalled": npu_model_installed(), }, })) } /// Lists active render (playback) devices for the "Audio Devices" picker /// (Settings ▸ Hardware) — loopback-only, matching the app's only capture /// path (FR-CAP-1). Runs off the async runtime thread since device /// enumeration is a blocking COM call. #[tauri::command] pub async fn list_audio_devices() -> WaResult> { tauri::async_runtime::spawn_blocking(crate::audio::list_render_devices) .await .map_err(|e| WaError::new("audio", e.to_string()))? .map_err(|e| WaError::new("audio", e.to_string())) } /// Enumerate capture (microphone) devices for the Settings "Microphone" picker /// (FR-CAP-7). Blocking COM enumeration, so it runs off the async runtime thread. #[tauri::command] pub async fn list_input_devices() -> WaResult> { tauri::async_runtime::spawn_blocking(crate::audio::list_capture_devices) .await .map_err(|e| WaError::new("audio", e.to_string()))? .map_err(|e| WaError::new("audio", e.to_string())) } #[derive(Deserialize)] pub struct SetPreferredBackendArgs { pub backend: String, // "auto"|"npu"|"nvidia"|"amd"|"intel"|"cpu" } #[tauri::command] pub async fn set_preferred_backend(args: SetPreferredBackendArgs) -> WaResult<()> { let mut settings = load_settings(); settings.preferred_backend = args.backend; save_settings(&settings) } /// True when the NPU ONNX Whisper model is downloaded (false on non-NPU builds). fn npu_model_installed() -> bool { #[cfg(feature = "npu")] { use crate::transcription::onnx_models; onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) } #[cfg(not(feature = "npu"))] { false } } /// Downloads the Whisper ONNX model for the NPU engine, emitting `npu://download` /// progress. Idempotent — skips artifacts already on disk. Also used by the /// startup background fetch (see `lib.rs`). #[cfg(feature = "npu")] pub async fn download_npu_model(app: AppHandle) -> WaResult<()> { use crate::transcription::onnx_models; let app2 = app.clone(); onnx_models::download( onnx_models::DEFAULT_ONNX_MODEL, move |idx, received, total| { let _ = app2.emit( "npu://download", serde_json::json!({ "stage": "model", "file": idx, "received": received, "total": total }), ); }, ) .await .map_err(|e| WaError::new("npu", e.to_string())) } /// Hosted OpenVINO runtime bundle (ORT 1.24.1 + OpenVINO 2025.4.1 DLLs, 7z). /// Served from the repo's `runtime/` dir via gitea's `media` path (resolves the /// Git LFS object, unlike `raw` which returns the pointer); overridable at /// runtime via `WA_NPU_RUNTIME_URL`. Keep the SHA-256 in step with the file. #[cfg(feature = "npu")] const NPU_RUNTIME_URL: &str = "https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/openvino.7z"; /// SHA-256 of the runtime bundle; empty string disables the integrity check. #[cfg(feature = "npu")] const NPU_RUNTIME_SHA256: &str = "ca0be9fc52c78ee623b152f790450b3d4020c5a7ebe99d27736455b308782191"; /// Hosted DirectML runtime bundle (ORT 1.24.1 DirectML-EP DLLs, 7z), same repo /// `media` (LFS-resolving) path as the OpenVINO one; overridable via /// `WA_DIRECTML_RUNTIME_URL`. #[cfg(feature = "npu")] const DIRECTML_RUNTIME_URL: &str = "https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/directml.7z"; /// SHA-256 of the DirectML runtime bundle; empty string disables the check. #[cfg(feature = "npu")] const DIRECTML_RUNTIME_SHA256: &str = "34369222fcc1be2e72a957b868b1976a90150ba704a06c9e8992c34ee368926b"; /// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir by /// downloading the hosted bundle and unzipping it (T3.4). `WA_NPU_RUNTIME_SRC` /// (';'-separated dirs) is honored as a dev/offline override that copies local /// DLLs instead of downloading. #[cfg(feature = "npu")] async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> { if crate::paths::npu_runtime_ready() { return Ok(()); } let dir = crate::paths::npu_runtime_dir(); std::fs::create_dir_all(&dir).map_err(|e| WaError::new("npu", e.to_string()))?; // Dev/offline override: copy DLLs from local dirs instead of downloading. if let Some(src) = std::env::var_os("WA_NPU_RUNTIME_SRC") { return stage_npu_runtime_from_local(app, &dir, &src); } let url = std::env::var("WA_NPU_RUNTIME_URL").unwrap_or_else(|_| NPU_RUNTIME_URL.to_string()); download_and_extract_runtime( app, &dir, &url, NPU_RUNTIME_SHA256, crate::paths::npu_runtime_ready, ) .await } /// Stages the DirectML ONNX Runtime into the app's DirectML runtime dir (P2) by /// downloading the hosted 7z bundle and unpacking it. Overridable via /// `WA_DIRECTML_RUNTIME_URL`; dev testing can also just set `ORT_DYLIB_PATH`. #[cfg(feature = "npu")] async fn stage_directml_runtime(app: &AppHandle) -> WaResult<()> { if crate::paths::directml_runtime_ready() { return Ok(()); } let dir = crate::paths::directml_runtime_dir(); std::fs::create_dir_all(&dir).map_err(|e| WaError::new("directml", e.to_string()))?; let url = std::env::var("WA_DIRECTML_RUNTIME_URL") .unwrap_or_else(|_| DIRECTML_RUNTIME_URL.to_string()); download_and_extract_runtime( app, &dir, &url, DIRECTML_RUNTIME_SHA256, crate::paths::directml_runtime_ready, ) .await } #[cfg(feature = "npu")] fn stage_npu_runtime_from_local( app: &AppHandle, dir: &Path, src: &std::ffi::OsStr, ) -> WaResult<()> { let mut copied = 0u32; for d in std::env::split_paths(src) { let Ok(entries) = std::fs::read_dir(&d) else { continue; }; for entry in entries.flatten() { let p = entry.path(); if p.extension().and_then(|s| s.to_str()) == Some("dll") { if let Some(name) = p.file_name() { if std::fs::copy(&p, dir.join(name)).is_ok() { copied += 1; } } } } let _ = app.emit( "npu://download", serde_json::json!({ "stage": "runtime", "copied": copied }), ); } if !crate::paths::npu_runtime_ready() { return Err(WaError::new( "npu", format!("staged {copied} dll(s) but onnxruntime.dll not among them"), )); } Ok(()) } /// Downloads a runtime bundle (streaming progress + SHA-256 check) and unzips /// its DLLs flat into `dir`. `sha256` empty disables the integrity check; /// `ready` is the on-disk readiness predicate for the target runtime (NPU or /// DirectML), so this one function serves both. // ponytail: progress event + error domain stay "npu"/"npu://download" even on // the DirectML path — cosmetic only, no frontend consumer for a directml // channel yet. Split them out when the Settings UI grows a DirectML indicator. #[cfg(feature = "npu")] async fn download_and_extract_runtime( app: &AppHandle, dir: &Path, url: &str, sha256: &str, ready: fn() -> bool, ) -> WaResult<()> { use futures_util::StreamExt; use sha2::{Digest, Sha256}; let resp = reqwest::get(url) .await .map_err(|e| WaError::new("npu", e.to_string()))?; if !resp.status().is_success() { return Err(WaError::new( "npu", format!("runtime download failed: HTTP {}", resp.status()), )); } let total = resp.content_length(); let tmp = dir.join("runtime.7z.part"); let mut file = std::fs::File::create(&tmp).map_err(|e| WaError::new("npu", e.to_string()))?; let mut hasher = Sha256::new(); let mut received = 0u64; let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| WaError::new("npu", e.to_string()))?; std::io::Write::write_all(&mut file, &chunk) .map_err(|e| WaError::new("npu", e.to_string()))?; hasher.update(&chunk); received += chunk.len() as u64; let _ = app.emit( "npu://download", serde_json::json!({ "stage": "runtime", "received": received, "total": total }), ); } drop(file); let digest = format!("{:x}", hasher.finalize()); if !sha256.is_empty() && digest != sha256 { let _ = std::fs::remove_file(&tmp); return Err(WaError::new("npu", "runtime bundle checksum mismatch")); } // Un-7z off the async runtime (CPU/IO-bound). let tmp_for_unzip = tmp.clone(); let dir_for_unzip = dir.to_path_buf(); tokio::task::spawn_blocking(move || extract_7z_flat(&tmp_for_unzip, &dir_for_unzip)) .await .map_err(|e| WaError::new("npu", e.to_string()))? .map_err(|e| WaError::new("npu", e))?; let _ = std::fs::remove_file(&tmp); if !ready() { return Err(WaError::new( "npu", "runtime bundle extracted but onnxruntime.dll is missing", )); } Ok(()) } /// Extract every file entry of a zip into `dir`, flattening paths to just the /// file name (which also strips the archive's top folder — the bundles nest DLLs /// under `directml/` or `openvino/` — and prevents path traversal). #[cfg(feature = "npu")] fn extract_7z_flat(archive_path: &Path, dir: &Path) -> Result<(), String> { sevenz_rust2::decompress_file_with_extract_fn(archive_path, dir, |entry, reader, _dest| { if entry.is_directory() { return Ok(true); } // Flatten to just the file name, dropping any folder prefix. let Some(name) = Path::new(entry.name()).file_name().and_then(|n| n.to_str()) else { return Ok(true); }; let mut out = std::fs::File::create(dir.join(name)).map_err(sevenz_rust2::Error::io)?; std::io::copy(reader, &mut out).map_err(sevenz_rust2::Error::io)?; Ok(true) }) .map_err(|e| e.to_string()) } /// Downloads everything the NPU engine needs (ONNX model + OpenVINO runtime) for /// the Settings ▸ Hardware "download NPU package" action (T3.4 step 2). #[tauri::command] pub async fn download_npu_package(app: AppHandle) -> WaResult<()> { #[cfg(feature = "npu")] { download_npu_model(app.clone()).await?; stage_npu_runtime(&app).await?; let _ = app.emit( "npu://download", serde_json::json!({ "stage": "done", "ready": crate::paths::npu_runtime_ready() }), ); Ok(()) } #[cfg(not(feature = "npu"))] { let _ = app; Err(WaError::new("npu", "this build has no NPU support")) } } /// Downloads everything the DirectML GPU engine needs — the same Whisper ONNX /// model as the NPU path, plus the DirectML runtime (P2). The non-Vulkan GPU /// path for AMD/Intel. #[tauri::command] pub async fn download_directml_package(app: AppHandle) -> WaResult<()> { #[cfg(feature = "npu")] { download_npu_model(app.clone()).await?; // identical ONNX artifacts stage_directml_runtime(&app).await?; let _ = app.emit( "directml://download", serde_json::json!({ "stage": "done", "ready": crate::paths::directml_runtime_ready() }), ); Ok(()) } #[cfg(not(feature = "npu"))] { let _ = app; Err(WaError::new( "directml", "this build has no ONNX/DirectML support", )) } } #[tauri::command] pub async fn list_models() -> WaResult> { let settings = load_settings(); Ok(model_catalog::list(&model_id_for(&settings))) } /// The Settings language dropdown's contents (T8.7, FR-TRX-4) — every /// whisper.cpp-recognized ISO-639-1 code. "Auto-detect" isn't in this list; /// the frontend prepends it (maps to omitting `language` at recording start). #[tauri::command] pub async fn list_whisper_languages() -> WaResult> { Ok(crate::transcription::languages::list()) } /// The fixed segmentation+embedding pair (T4.7, FR-MODEL-1) — a separate /// command rather than folding into `list_models` because they're a fixed /// installable pair, not an interchangeable-size catalog like whisper's. #[tauri::command] pub async fn list_diarization_models() -> WaResult> { Ok(crate::diarization::models::list()) } #[derive(Deserialize)] pub struct DownloadModelArgs { pub kind: String, // "whisper" | "diar-seg" | "diar-emb" pub id: String, } #[tauri::command] pub async fn download_model(app: AppHandle, args: DownloadModelArgs) -> WaResult<()> { let id = args.id; let id_for_progress = id.clone(); let on_progress = move |received: u64, total: Option| { let _ = app.emit( "model://progress", serde_json::json!({ "id": id_for_progress, "receivedBytes": received, "totalBytes": total }), ); }; match args.kind.as_str() { "whisper" => model_catalog::download(&id, on_progress) .await .map_err(|e| WaError::new("model", e.to_string())), "diar-seg" | "diar-emb" => crate::diarization::models::download(&id, on_progress) .await .map_err(|e| WaError::new("model", e.to_string())), other => Err(WaError::new( "model", format!("unknown model kind '{other}'"), )), } } #[tauri::command] pub async fn remove_model(id: String) -> WaResult<()> { // No `kind` in this command's contract — disambiguate by catalog // membership instead; whisper/diarization ids never collide. if crate::diarization::models::list() .iter() .any(|m| m.id == id) { return crate::diarization::models::remove(&id) .map_err(|e| WaError::new("model", e.to_string())); } let settings = load_settings(); model_catalog::remove(&id, &model_id_for(&settings)) .map_err(|e| WaError::new("model", e.to_string())) } /// Batch re-transcribe a finished meeting with a different (typically larger) /// model (T3.8, FR-TRX-3). Only works if the meeting's audio was retained. /// /// Re-diarizes from scratch: the fresh transcription is re-clustered and, if a /// `voiceprint.wav` was retained, the mic cluster is re-labeled "You" (FR-SPK). /// The meeting's stored speaker names are **discarded** — they key to the /// original run's labels, which no longer exist after re-clustering, so any /// names the user typed on the first pass are intentionally lost here. #[tauri::command] pub async fn reprocess_transcript( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, model: String, // T8.7/FR-TRX-4: `None` reuses whatever language the meeting was // already recorded/reprocessed with, so re-transcribing with a bigger // model doesn't silently drop a prior language selection. language: Option, ) -> WaResult<()> { let model_path = whisper_model_file(&model); if !model_path.exists() { return Err(WaError::new( "model", format!("model '{model}' is not installed"), )); } let wav_path = meeting_dir(&meeting_id).join("audio.wav"); if !wav_path.exists() { return Err(WaError::new( "transcription", "no retained audio.wav to reprocess — enable recording retention for this meeting", )); } let meeting = state .store .get_meeting(&meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let settings = load_settings(); let backend = backend_for(&settings); let requested_language = normalize_language(language.as_deref().or(meeting.language.as_deref())) .map(|s| s.to_string()); let (mut segments, resolved_language) = tauri::async_runtime::spawn_blocking({ let wav_path = wav_path.clone(); move || { let (transcriber, _used) = load_transcriber(backend, &model_path, requested_language.as_deref())?; let segments = transcriber.transcribe_file(&wav_path)?; let resolved = transcriber .detected_language() .or_else(|| transcriber.effective_language()); Ok::<_, crate::transcription::TrxError>((segments, resolved)) } }) .await .map_err(|e| WaError::new("transcription", e.to_string()))? .map_err(|e| WaError::new("transcription", e.to_string()))?; // FR-SPK: re-diarize the fresh transcript. The meeting's stored labels key // to the original clustering and are meaningless now — rebuild from the // audio. Missing models degrade to the single "S1" placeholder, same as // live/import. let diarizer: Option> = tauri::async_runtime::spawn_blocking(diarizer_from_installed_models) .await .ok() .flatten() .map(|d| Arc::new(d) as Arc); // Rebuild the name map fresh (stale labels are discarded either way). // Prefer the Phase 3 mic timeline when it was persisted (mic = "You", // diarize the far side masked); otherwise fall back to the whole-signal pass // + voiceprint match (imports, pre-Phase-3 recordings). No models → raw "S1". let mut speaker_names = HashMap::new(); let you_spans: Option> = std::fs::read_to_string(meeting_dir(&meeting_id).join("mic_activity.json")) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .map(|f| f.you_spans); match (diarizer, you_spans) { (Some(diarizer), Some(you)) => { if let Some(names) = phase3_attribute(diarizer, wav_path.clone(), you, &mut segments).await { speaker_names = names; } } (Some(diarizer), None) => { let mut spans: Option> = None; let diarizer_for_task = diarizer.clone(); let wp = wav_path.clone(); match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await { Ok(Ok(s)) => { diarizer.assign(&mut segments, &s); spans = Some(s); } Ok(Err(e)) => tracing::warn!("reprocess diarization pass failed: {e}"), Err(e) => tracing::warn!("reprocess diarization task failed: {e}"), } let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav"); if let (Some(spans), true) = (&spans, voiceprint_path.exists()) { match crate::audio::read_wav_mono_16k(&voiceprint_path) { Ok(mic_samples) => match crate::diarization::voiceprint::match_mic_speaker( &diarization_embedding_model_file(), &mic_samples, &wav_path, spans, ) { Ok(names) => speaker_names = names, Err(e) => tracing::warn!("reprocess voiceprint match failed: {e}"), }, Err(e) => tracing::warn!("failed to read voiceprint.wav: {e}"), } } } (None, _) => {} // no diarization models: raw "S1" labels } let speakers = speaker_infos_from_segments(&segments, &speaker_names); // Drop the previous run's speaker rows before re-inserting the fresh set: // the old labels key to the discarded clustering, so leaving them stranded // in the DB makes the Participants pane show ghosts (e.g. 83 old labels // when the new transcript has 5). finalize_meeting below re-upserts // `speakers`. (FR-SPK) state .store .clear_speakers(&meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let duration_secs = segments .last() .map(|s| (s.end_ms / 1000) as i64) .unwrap_or(meeting.duration_secs.unwrap_or(0)); let template = meeting .template_id .as_deref() .and_then(crate::notes::note_template_by_id); state .store .finalize_meeting( &meeting_id, FinalizeMeeting { segments: segments.clone(), speakers: speakers.clone(), duration_secs, recorded: meeting.recorded, language: resolved_language, backend_used: Some(backend.as_str().to_string()), model_used: Some(model), }, ) .await .map_err(|e| WaError::new("storage", e.to_string()))?; // Bug fix: re-transcribing must not silently drop manual notes the user // typed live during the original recording (see `load_manual_notes`). let manual_notes = load_manual_notes(&meeting_id); let notes_md = crate::notes::MarkdownNotes.merge( &segments, &speakers, &manual_notes, None, template.as_ref(), ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; spawn_auto_sync(&app, state.store.clone(), meeting_id.clone()); let _ = app.emit( "transcript://finalized", serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }), ); Ok(()) } /// Filename stem of a local path, for a sensible default meeting title; `None` /// for a URL (yt-dlp's real title isn't fetched — the user can rename). fn default_title_from_source(source: &str) -> Option { if crate::media::is_url(source) { return None; } std::path::Path::new(source) .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_string()) .filter(|s| !s.trim().is_empty()) } /// Manually add a meeting from an existing recording: a local audio/video file /// or a URL (YouTube/streaming page, or a direct media URL). Shells out to /// `ffmpeg` (transcode) and — for URLs — `yt-dlp` (both external, not bundled; /// a missing tool is a clear error). The produced 16kHz-mono WAV becomes the /// meeting's retained `audio.wav`, then goes through the same /// transcription + diarization + finalize path as a live recording. #[tauri::command] pub async fn import_media( app: AppHandle, state: State<'_, AppState>, source: String, title: Option, ) -> WaResult { let settings = load_settings(); let model_id = model_id_for(&settings); let backend = backend_for(&settings); let model_path = whisper_model_file(&model_id); if !model_path.exists() { return Err(WaError::new( "transcription", format!( "whisper model '{model_id}' not found at {}; download it from Settings first", model_path.display() ), )); } let language: Option = normalize_language(settings.whisper_language.as_deref()).map(|s| s.to_string()); let title = title .filter(|t| !t.trim().is_empty()) .or_else(|| default_title_from_source(&source)) .unwrap_or_else(|| "Imported meeting".to_string()); let meeting_id = state .store .create_meeting(NewMeeting { title, calendar_event_id: None, template_id: None, language: language.clone(), }) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let dir = meeting_dir(&meeting_id); let wav_path = dir.join("audio.wav"); // Transcode into the meeting's audio.wav off the async runtime (shells out // to ffmpeg/yt-dlp). On any failure, drop the empty meeting so a bad import // doesn't leave a husk row behind. let transcode = tauri::async_runtime::spawn_blocking({ let source = source.clone(); let wav_path = wav_path.clone(); let dir = dir.clone(); move || { let work = dir.join("import-tmp"); std::fs::create_dir_all(&work)?; let r = crate::media::import_to_wav(&source, &wav_path, &work); let _ = std::fs::remove_dir_all(&work); r } }) .await .map_err(|e| WaError::new("import", e.to_string()))?; if let Err(e) = transcode { let _ = state.store.delete_meeting(&meeting_id).await; return Err(WaError::new("import", e.to_string())); } // Transcribe the produced WAV (same batch path as reprocess_transcript). let (mut segments, resolved_language) = tauri::async_runtime::spawn_blocking({ let wav_path = wav_path.clone(); let model_path = model_path.clone(); let requested_language = language.clone(); move || { let (transcriber, _used) = load_transcriber(backend, &model_path, requested_language.as_deref())?; let segments = transcriber.transcribe_file(&wav_path)?; let resolved = transcriber .detected_language() .or_else(|| transcriber.effective_language()); Ok::<_, crate::transcription::TrxError>((segments, resolved)) } }) .await .map_err(|e| WaError::new("transcription", e.to_string()))? .map_err(|e| WaError::new("transcription", e.to_string()))?; // One diarization pass if the models are installed, exactly like // stop_recording — otherwise every line stays the single "S1" placeholder. let diarizer: Option> = tauri::async_runtime::spawn_blocking(diarizer_from_installed_models) .await .ok() .flatten() .map(|d| Arc::new(d) as Arc); if let Some(diarizer) = diarizer { let diarizer_for_task = diarizer.clone(); let wp = wav_path.clone(); match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await { Ok(Ok(spans)) => diarizer.assign(&mut segments, &spans), Ok(Err(e)) => tracing::warn!("import diarization pass failed: {e}"), Err(e) => tracing::warn!("import diarization task failed: {e}"), } } let speakers = speaker_infos_from_segments(&segments, &HashMap::new()); let duration_secs = segments .last() .map(|s| (s.end_ms / 1000) as i64) .unwrap_or(0); state .store .finalize_meeting( &meeting_id, FinalizeMeeting { segments: segments.clone(), speakers: speakers.clone(), duration_secs, recorded: true, // the imported WAV is the recording — keep it language: resolved_language, backend_used: Some(backend.as_str().to_string()), model_used: Some(model_id.clone()), }, ) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let notes_md = crate::notes::MarkdownNotes.merge( &segments, &speakers, &crate::models::ManualNotes::default(), None, None, ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; // Seal the retained recording at rest when the vault is unlocked (T8.8), // matching stop_recording so imports aren't left as plaintext outliers. if crate::vault::is_unlocked() { if let Ok(raw) = std::fs::read(&wav_path) { if let Ok(sealed) = crate::vault::seal(&raw) { let _ = std::fs::write(&wav_path, sealed); } } } let _ = app.emit( "transcript://finalized", serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }), ); Ok(meeting_id) } /// Re-run transcription from a `recovering` meeting's working `audio.wav` /// (T2.8, FR-REL-1). CPU-bound, so it runs on a blocking task rather than /// tying up an async worker. #[tauri::command] pub async fn resume_transcription( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, ) -> WaResult<()> { let settings = load_settings(); let model_id = model_id_for(&settings); let model_path = whisper_model_file(&model_id); if !model_path.exists() { return Err(WaError::new( "transcription", format!( "whisper model '{model_id}' not found at {}; download it from Settings first", model_path.display() ), )); } let wav_path = meeting_dir(&meeting_id).join("audio.wav"); if !wav_path.exists() { return Err(WaError::new( "recovery", "no working audio.wav to recover from", )); } // T8.7/FR-TRX-4: `create_meeting` persisted the originally requested // language immediately (not just at finalize), so a crash-recovered // meeting can still honor it here rather than silently reverting to // auto-detect. let requested_language = state .store .get_meeting(&meeting_id) .await .ok() .and_then(|m| m.language); // Note: deliberately doesn't emit `recording://state` — that event drives // the main header's single live-session Record/Stop toggle, and reusing // it here would make the header show a phantom "Stop" button for a // recovery run that isn't a `RecordingSession` at all. The "recovering" // badge in the meetings list is this operation's own progress signal. let (segments, resolved_language) = tauri::async_runtime::spawn_blocking(move || { let transcriber = WhisperTranscriber::load(&model_path, BackendId::Cpu, requested_language.as_deref())?; let segments = transcriber.transcribe_file(&wav_path)?; let resolved = transcriber .detected_language() .or_else(|| transcriber.effective_language()); Ok::<_, crate::transcription::TrxError>((segments, resolved)) }) .await .map_err(|e| WaError::new("transcription", e.to_string()))? .map_err(|e| WaError::new("transcription", e.to_string()))?; let speakers = vec![SpeakerInfo { label: "S1".to_string(), display_name: None, participant_id: None, }]; let duration_secs = segments .last() .map(|s| (s.end_ms / 1000) as i64) .unwrap_or(0); let template_id = state .store .finalize_meeting( &meeting_id, FinalizeMeeting { segments: segments.clone(), speakers: speakers.clone(), duration_secs, // The working WAV surviving a crash is the only signal we have // left about intent; keep it rather than silently discard it. recorded: true, language: resolved_language, backend_used: Some(BackendId::Cpu.as_str().to_string()), model_used: Some(model_id), }, ) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let template = template_id .as_deref() .and_then(crate::notes::note_template_by_id); // Crash recovery: no RecordingSession survives a crash, so read whatever // manual notes were write-through persisted to disk before it happened. let manual_notes = load_manual_notes(&meeting_id); let notes_md = crate::notes::MarkdownNotes.merge( &segments, &speakers, &manual_notes, None, template.as_ref(), ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( "transcript://finalized", serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }), ); Ok(()) } // ---- Meetings / storage (Phase 2) ---- #[tauri::command] pub async fn list_meetings( state: State<'_, AppState>, query: Option, tag: Option, participant_id: Option, from: Option, to: Option, ) -> WaResult> { state .store .list_meetings(crate::storage::MeetingFilter { query, tag, participant_id, from, to, }) .await .map_err(|e| WaError::new("storage", e.to_string())) } /// Replaces a meeting's complete tag set (Phase 8, FR-SEARCH-2). #[tauri::command] pub async fn set_tags( app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, tags: Vec, ) -> WaResult<()> { state .store .set_tags(&meeting_id, &tags) .await .map_err(|e| WaError::new("storage", e.to_string()))?; spawn_auto_sync(&app, state.store.clone(), meeting_id); Ok(()) } /// All known tag names, sorted, for filter/autocomplete UI (Phase 8, FR-SEARCH-2). #[tauri::command] pub async fn list_tags(state: State<'_, AppState>) -> WaResult> { state .store .list_tags() .await .map_err(|e| WaError::new("storage", e.to_string())) } /// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1) — /// distinct from `list_meetings`' `query`, which only substring-matches the /// title. #[tauri::command] pub async fn search(state: State<'_, AppState>, query: String) -> WaResult> { state .store .search(&query) .await .map_err(|e| WaError::new("storage", e.to_string())) } /// Built-in note templates for a picker at recording-start time (Phase 8, /// T8.1, FR-NOTE-5). #[tauri::command] pub async fn list_note_templates() -> WaResult> { Ok(crate::notes::built_in_note_templates()) } #[tauri::command] pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult { state .store .get_meeting(&meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string())) } #[tauri::command] pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<()> { let guard = state.session.lock().await; if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) { return Err(WaError::new( "recording", "cannot delete a meeting that is currently recording", )); } drop(guard); state .store .delete_meeting(&meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string())) } /// Return the `waaudio://` URL the in-app player loads for a meeting's recording /// (FR-REC-5). The bytes are decrypted in memory on demand by `serve_recording` /// — nothing plaintext is ever written to disk. Prechecks that a recording /// exists and (if sealed) the vault is unlocked, so the UI can show a clear /// error before playback; also clears any stale plaintext `audio.play.wav` left /// by the previous file-based player. #[tauri::command] pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult { let id = meeting_id.clone(); tauri::async_runtime::spawn_blocking(move || { let dir = meeting_dir(&id); let wav = dir.join("audio.wav"); if !wav.exists() { return Err(WaError::new( "recording", "no saved recording for this meeting", )); } // Cheap sealed check — read only the magic prefix, not the whole file. let mut head = [0u8; 8]; let sealed = std::fs::File::open(&wav) .and_then(|mut f| { use std::io::Read; let n = f.read(&mut head)?; Ok(n) }) .map(|n| crate::vault::is_sealed(&head[..n])) .unwrap_or(false); if sealed && !crate::vault::is_unlocked() { return Err(WaError::new( "recording", "unlock the vault to play this recording", )); } // Drop any plaintext temp the old file-based player left behind. let _ = std::fs::remove_file(dir.join("audio.play.wav")); Ok(()) }) .await .map_err(|e| WaError::new("recording", e.to_string()))??; Ok(format!("http://waaudio.localhost/{meeting_id}")) } /// Custom-scheme handler backing the `waaudio://` URL: reads the meeting's /// `audio.wav`, decrypts it in memory if sealed (T8.8), and streams the PCM to /// the `