diff --git a/docs/01-requirements.md b/docs/01-requirements.md index f76689c..0965f71 100644 --- a/docs/01-requirements.md +++ b/docs/01-requirements.md @@ -16,6 +16,7 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr | FR-CAP-4 | M | 1 | Show an unambiguous "recording active" indicator (in-app banner + tray icon). | | FR-CAP-5 | S | 7 | Render a live input waveform / level meter while recording. | | FR-CAP-6 | S | 7 | Handle audio device changes mid-recording without losing the session. | +| FR-CAP-7 | S | 1 | Optionally capture the user's **microphone** alongside loopback and mix it into the live transcript (default ON, local-only/no egress, selectable device + "off"). | ### Recording retention & consent (REC) — see ADR-0009 diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 7f02839..10cf293 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -55,7 +55,7 @@ touches files, DB, or network directly — only Tauri commands/events (`04-api-c | Service | Responsibility | Primary crate(s) | |---|---|---| -| `audio` | WASAPI loopback capture; PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` | +| `audio` | WASAPI loopback capture (+ optional microphone, mixed into the transcript stream, FR-CAP-7); PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` | | `hardware` | Enumerate NPU/GPU/CPU; rank backends; report capabilities | `ort`, DXGI via `windows` | | `transcription` | Load model on a backend; stream segments (whisper.cpp) or NPU (ONNX) | `whisper-rs`, `ort` | | `diarization` | Post-process audio → speaker spans; align to segments; merge | `sherpa-onnx` (FFI) | diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index a3de873..0f1f25f 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -154,10 +154,15 @@ indicative (async where I/O-bound). pub trait AudioCapture: Send + Sync { /// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`. fn start(&self, wav_path: &Path, sink: FrameSink) -> Result; + /// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV. + fn start_microphone(&self, device_id: Option<&str>, sink: FrameSink) -> Result; fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>; fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>; fn stop(&self, h: CaptureHandle) -> Result; } +// When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono +// frames into the single transcription stream (`list_input_devices` enumerates +// mic devices, mirroring `list_audio_devices` for render devices). // hardware/mod.rs pub trait HardwareDetector: Send + Sync { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7857445..7820615 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5980,6 +5980,7 @@ dependencies = [ "argon2", "async-trait", "chacha20poly1305", + "chrono", "docx-rs", "futures-util", "getrandom 0.2.17", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8d6390..d4e9803 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4"] } +# Local<->UTC, DST-aware — needed for calendar recurrence (T6.2); already in +# the dependency tree transitively (sqlx), this just promotes it to direct. +chrono = { version = "0.4", default-features = false, features = ["clock"] } # storage sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] } diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index b9f6b7b..d3e29fa 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -81,9 +81,24 @@ pub struct CaptureSummary { } pub trait AudioCapture: Send + Sync { + /// `device_id` is a `Device::get_id()` string (Settings.audio_output_device); + /// `None` uses the system default render device, same as before this was + /// selectable at all. fn start( &self, wav_path: &Path, + device_id: Option<&str>, + frame_sink: FrameSink, + event_sink: EventSink, + ) -> Result; + /// Capture the user's **microphone** (an input device — normal capture, no + /// loopback) into `frame_sink` for live transcription (FR-CAP-7). No WAV is + /// written here: the byte-accurate recording stays the loopback WAV; the mic + /// is mixed into the transcript stream (see `spawn_mixer`). `device_id` is a + /// `Device::get_id()`; `None` uses the system default capture device. + fn start_microphone( + &self, + device_id: Option<&str>, frame_sink: FrameSink, event_sink: EventSink, ) -> Result; @@ -92,15 +107,67 @@ pub trait AudioCapture: Send + Sync { fn stop(&self, h: CaptureHandle) -> Result; } +/// One enumerated render (playback) device — the id is what gets persisted in +/// Settings and passed back into `AudioCapture::start`; the name is display-only. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AudioDeviceInfo { + pub id: String, + pub name: String, +} + +/// Enumerates active devices in one direction (Render = playback for loopback, +/// Capture = microphones) for the Settings device pickers. +#[cfg(feature = "audio")] +fn list_devices(direction: &Direction) -> Result, AudioError> { + wasapi::initialize_mta() + .ok() + .map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?; + let collection = wasapi::DeviceCollection::new(direction) + .map_err(|e| AudioError::Device(format!("enumerate devices failed: {e}")))?; + let mut devices = Vec::new(); + for device in &collection { + let device = + device.map_err(|e| AudioError::Device(format!("device enumeration error: {e}")))?; + let id = device + .get_id() + .map_err(|e| AudioError::Device(format!("device id: {e}")))?; + let name = device.get_friendlyname().unwrap_or_else(|_| id.clone()); + devices.push(AudioDeviceInfo { id, name }); + } + Ok(devices) +} + +/// Lists active render (playback) devices for the loopback "Audio Devices" +/// picker (Settings -> Hardware); WhispAssist records whatever the chosen output +/// plays (FR-CAP-1). +#[cfg(feature = "audio")] +pub fn list_render_devices() -> Result, AudioError> { + list_devices(&Direction::Render) +} + +/// Lists active capture (microphone) devices for the "Microphone" picker +/// (FR-CAP-7) — the user's own voice, mixed into the live transcript. +#[cfg(feature = "audio")] +pub fn list_capture_devices() -> Result, AudioError> { + list_devices(&Direction::Capture) +} + /// Default Windows WASAPI implementation (Phase 1, feature `audio`). #[cfg(feature = "audio")] pub struct WasapiCapture; #[cfg(feature = "audio")] -impl AudioCapture for WasapiCapture { - fn start( +impl WasapiCapture { + /// Shared spawn path for both loopback and microphone capture. `wav_path` is + /// `Some` only for the loopback stream (the byte-accurate recording); the mic + /// stream produces frames for transcription but no WAV. `direction` selects + /// loopback (Render) vs. plain microphone capture (Capture). + fn start_capture( &self, - wav_path: &Path, + thread_name: &str, + wav_path: Option<&Path>, + direction: Direction, + device_id: Option<&str>, frame_sink: FrameSink, event_sink: EventSink, ) -> Result { @@ -108,12 +175,21 @@ impl AudioCapture for WasapiCapture { let paused = Arc::new(AtomicBool::new(false)); let running_th = running.clone(); let paused_th = paused.clone(); - let wav_path = wav_path.to_path_buf(); + let wav_path = wav_path.map(Path::to_path_buf); + let device_id = device_id.map(str::to_string); let thread = thread::Builder::new() - .name("wa-audio-capture".into()) + .name(thread_name.into()) .spawn(move || { - capture_loop(&wav_path, &frame_sink, &event_sink, &running_th, &paused_th) + capture_loop( + wav_path.as_deref(), + direction, + device_id.as_deref(), + &frame_sink, + &event_sink, + &running_th, + &paused_th, + ) }) .map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?; @@ -123,6 +199,42 @@ impl AudioCapture for WasapiCapture { thread, }) } +} + +#[cfg(feature = "audio")] +impl AudioCapture for WasapiCapture { + fn start( + &self, + wav_path: &Path, + device_id: Option<&str>, + frame_sink: FrameSink, + event_sink: EventSink, + ) -> Result { + self.start_capture( + "wa-audio-capture", + Some(wav_path), + Direction::Render, + device_id, + frame_sink, + event_sink, + ) + } + + fn start_microphone( + &self, + device_id: Option<&str>, + frame_sink: FrameSink, + event_sink: EventSink, + ) -> Result { + self.start_capture( + "wa-mic-capture", + None, + Direction::Capture, + device_id, + frame_sink, + event_sink, + ) + } fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError> { h.paused.store(true, Ordering::SeqCst); @@ -153,14 +265,40 @@ struct CaptureSession { format: WaveFormat, } -/// Opens the current default render device for loopback capture. Called both -/// for the initial open and to reconnect after the device disappears -/// mid-recording (FR-CAP-6) — each call re-resolves "the default device", -/// so it naturally picks up whatever the OS switched to. +/// Resolves the configured device (in the given direction) by id, falling back +/// to the system default if it's no longer present (unplugged/renamed since it +/// was picked) — same "degrade gracefully rather than fail the recording" spirit +/// as the device-recovery reconnect below. #[cfg(feature = "audio")] -fn open_capture_session() -> Result { - let device = wasapi::get_default_device(&Direction::Render) - .map_err(|e| AudioError::Device(format!("no default render device: {e}")))?; +fn find_device(direction: &Direction, device_id: Option<&str>) -> Result { + let Some(id) = device_id else { + return wasapi::get_default_device(direction) + .map_err(|e| AudioError::Device(format!("no default audio device: {e}"))); + }; + let collection = wasapi::DeviceCollection::new(direction) + .map_err(|e| AudioError::Device(format!("enumerate devices failed: {e}")))?; + for device in &collection { + let device = + device.map_err(|e| AudioError::Device(format!("device enumeration error: {e}")))?; + if device.get_id().map(|d| d == id).unwrap_or(false) { + return Ok(device); + } + } + wasapi::get_default_device(direction) + .map_err(|e| AudioError::Device(format!("configured device not found and no default: {e}"))) +} + +/// Opens the configured render device (or the current default, if none is +/// configured) for loopback capture. Called both for the initial open and to +/// reconnect after the device disappears mid-recording (FR-CAP-6) — each call +/// re-resolves the same selection, so a transient loss reconnects to the same +/// device while a truly-gone device falls back to whatever is now default. +#[cfg(feature = "audio")] +fn open_capture_session( + direction: &Direction, + device_id: Option<&str>, +) -> Result { + let device = find_device(direction, device_id)?; let mut audio_client = device .get_iaudioclient() .map_err(|e| AudioError::Device(format!("activate IAudioClient failed: {e}")))?; @@ -239,7 +377,9 @@ const LEVEL_EMIT_INTERVAL: Duration = Duration::from_millis(50); /// is cleared. #[cfg(feature = "audio")] fn capture_loop( - wav_path: &Path, + wav_path: Option<&Path>, + direction: Direction, + device_id: Option<&str>, frame_sink: &FrameSink, event_sink: &EventSink, running: &AtomicBool, @@ -249,11 +389,17 @@ fn capture_loop( .ok() .map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?; - let mut session = open_capture_session()?; + // Only the loopback stream emits level updates (FR-CAP-5) and writes a WAV; + // the mic stream just contributes frames to the transcript mixer (FR-CAP-7). + let emit_level = wav_path.is_some(); + let mut session = open_capture_session(&direction, device_id)?; let spec = wav_spec_for(&session.format)?; - let mut writer = WavWriter::create(wav_path, spec).map_err(|e| { - AudioError::Capture(format!("could not create {}: {e}", wav_path.display())) - })?; + let mut writer = match wav_path { + Some(path) => Some(WavWriter::create(path, spec).map_err(|e| { + AudioError::Capture(format!("could not create {}: {e}", path.display())) + })?), + None => None, + }; session .audio_client @@ -279,7 +425,7 @@ fn capture_loop( message: e.to_string(), }); session.audio_client.stop_stream().ok(); - let new_session = open_capture_session().map_err(|re| { + let new_session = open_capture_session(&direction, device_id).map_err(|re| { AudioError::Capture(format!("device lost, reconnect failed: {re}")) })?; if !format_compatible(&session.format, &new_session.format) { @@ -310,10 +456,12 @@ fn capture_loop( continue; } - frames_written += write_wav_bytes(&mut writer, &bytes, &session.format)?; + if let Some(w) = writer.as_mut() { + frames_written += write_wav_bytes(w, &bytes, &session.format)?; + } let mono = decode_mono_f32(&bytes, &session.format)?; - if last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL { + if emit_level && last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL { let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono))); last_level_emit = Instant::now(); } @@ -325,9 +473,10 @@ fn capture_loop( } session.audio_client.stop_stream().ok(); - writer - .finalize() - .map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?; + if let Some(w) = writer { + w.finalize() + .map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?; + } let sample_rate = session.format.get_samplespersec(); Ok(CaptureSummary { @@ -546,10 +695,145 @@ pub fn read_wav_mono_16k(path: &Path) -> Result, AudioError> { Ok(resampler.process(&mono)) } +/// Ceiling on how far the still-flowing stream may run ahead of a stalled +/// partner before the mixer forwards it alone — so a muted/dead microphone (or a +/// silent system output) can't hold up the live transcript. 0.5s @ 16kHz. +const MIXER_MAX_LAG: usize = 8_000; + +/// Sums two already-16kHz-mono streams (loopback + microphone, FR-CAP-7) sample +/// for sample into the single transcript stream. Pure struct so the alignment +/// logic is unit-testable without threads or real devices. +struct Mixer { + loopback: std::collections::VecDeque, + mic: std::collections::VecDeque, +} + +impl Mixer { + fn new() -> Self { + Self { + loopback: std::collections::VecDeque::new(), + mic: std::collections::VecDeque::new(), + } + } + + /// Emit what's ready: the overlapping head of both buffers summed, plus — if + /// a source has ended (`*_open == false`) or stalled while the other backs up + /// past `MIXER_MAX_LAG` — the leftover of the still-flowing source on its own. + fn drain_ready(&mut self, loop_open: bool, mic_open: bool) -> Vec { + let n = self.loopback.len().min(self.mic.len()); + let mut out = Vec::with_capacity(n); + for _ in 0..n { + let s = self.loopback.pop_front().unwrap() + self.mic.pop_front().unwrap(); + // ponytail: hard-clip on sum; only distorts when both sources peak at + // once, and STT tolerates it far better than a wrapped sample. + out.push(s.clamp(-1.0, 1.0)); + } + // Microphone gone/stalled -> forward the loopback tail rather than wait. + if !mic_open || (self.mic.is_empty() && self.loopback.len() >= MIXER_MAX_LAG) { + out.extend(self.loopback.drain(..)); + } + // Symmetric: loopback gone/stalled -> forward the mic tail. + if !loop_open || (self.loopback.is_empty() && self.mic.len() >= MIXER_MAX_LAG) { + out.extend(self.mic.drain(..)); + } + out + } +} + +/// Spawns the mixer thread and returns the two frame sinks to hand to the +/// loopback and microphone captures respectively; their summed output flows into +/// `out` (the transcription worker). Only used when the mic is enabled — with it +/// off, loopback frames go straight to `out` as before (no mixer overhead). +pub fn spawn_mixer(out: FrameSink) -> (FrameSink, FrameSink) { + let (loop_tx, loop_rx) = std::sync::mpsc::sync_channel::>(8); + let (mic_tx, mic_rx) = std::sync::mpsc::sync_channel::>(8); + let _ = thread::Builder::new() + .name("wa-audio-mixer".into()) + .spawn(move || mixer_loop(loop_rx, mic_rx, &out)); + (loop_tx, mic_tx) +} + +fn mixer_loop( + loop_rx: std::sync::mpsc::Receiver>, + mic_rx: std::sync::mpsc::Receiver>, + out: &FrameSink, +) { + use std::sync::mpsc::{RecvTimeoutError, TryRecvError}; + let mut mixer = Mixer::new(); + let mut loop_open = true; + let mut mic_open = true; + + while loop_open || mic_open || !mixer.loopback.is_empty() || !mixer.mic.is_empty() { + // Loopback is the guaranteed primary source and drives the cadence: block + // on it briefly so we wake on its ~10ms chunks instead of busy-spinning. + if loop_open { + match loop_rx.recv_timeout(Duration::from_millis(20)) { + Ok(chunk) => mixer.loopback.extend(chunk), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => loop_open = false, + } + } else { + thread::sleep(Duration::from_millis(20)); + } + // Drain whatever the mic has queued without blocking. + loop { + match mic_rx.try_recv() { + Ok(chunk) => mixer.mic.extend(chunk), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + mic_open = false; + break; + } + } + } + let ready = mixer.drain_ready(loop_open, mic_open); + // Blocking send applies backpressure (which then makes the capture threads + // drop frames, as they already do); a send error means the transcription + // consumer is gone, so there's nothing left to mix. + if !ready.is_empty() && out.send(ready).is_err() { + break; + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn mixer_sums_aligned_streams_and_keeps_the_unmatched_tail() { + let mut m = Mixer::new(); + m.loopback.extend([0.1, 0.2, 0.3, 0.4]); + m.mic.extend([0.5, 0.5]); + // Both still open: only the 2-sample overlap is emitted; the loopback tail + // is held back to align with future mic samples (tail < MIXER_MAX_LAG). + let out = m.drain_ready(true, true); + assert_eq!(out, vec![0.6, 0.7]); + assert_eq!(m.loopback.len(), 2); + assert!(m.mic.is_empty()); + } + + #[test] + fn mixer_clamps_a_summed_overload() { + let mut m = Mixer::new(); + m.loopback.extend([0.8, -0.8]); + m.mic.extend([0.8, -0.8]); + let out = m.drain_ready(true, true); + assert_eq!(out, vec![1.0, -1.0]); // 1.6 / -1.6 clamped to the valid range + } + + #[test] + fn mixer_flushes_the_survivor_when_the_mic_ends() { + let mut m = Mixer::new(); + m.loopback.extend([0.1, 0.2, 0.3]); + m.mic.extend([0.5]); + // Mic closed: sum the 1-sample overlap, then forward the loopback tail + // alone rather than stall the transcript forever. + let out = m.drain_ready(true, false); + assert_eq!(out, vec![0.6, 0.2, 0.3]); + assert!(m.loopback.is_empty()); + } + #[test] fn wav_writer_roundtrip_produces_valid_header_and_duration() { let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4())); @@ -690,6 +974,85 @@ mod tests { assert_eq!(r.process(&input), input); } + #[test] + fn list_render_devices_succeeds_and_ids_are_unique() { + // Real COM/WASAPI enumeration against whatever hardware this machine + // actually has — device count/names aren't asserted (machine- + // dependent), just that enumeration works and ids are stable/unique + // (the property the Settings picker and find_render_device rely on). + let devices = list_render_devices().unwrap(); + let mut ids: Vec<&str> = devices.iter().map(|d| d.id.as_str()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), devices.len()); + } + + #[test] + #[ignore = "opens the real default microphone; run with --ignored on a machine that has one"] + fn microphone_capture_opens_and_stops_cleanly() { + // FR-CAP-7 against real hardware: the default mic opens for capture and + // stops without error. Frame *delivery* isn't asserted here — WASAPI + // doesn't pump default-device audio into a `cargo test` process on some + // machines (same quirk that makes get_default_device(Render) fail under + // test), so that end-to-end check belongs in the running app. The logged + // frame count is informational. + let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::>(64); + let (event_tx, _event_rx) = std::sync::mpsc::sync_channel::(8); + let handle = WasapiCapture + .start_microphone(None, frame_tx, event_tx) + .expect("open default microphone"); + std::thread::sleep(Duration::from_millis(600)); + let summary = WasapiCapture.stop(handle).expect("stop mic"); + let samples: usize = frame_rx.try_iter().map(|c| c.len()).sum(); + eprintln!( + "[mic] device opened at {}Hz; delivered {samples} frames @16kHz over ~0.6s", + summary.sample_rate + ); + assert!(summary.sample_rate > 0, "mic session did not open a device"); + } + + #[test] + fn list_capture_devices_succeeds_and_ids_are_unique() { + // Same contract as the render picker, for the microphone picker + // (FR-CAP-7): enumeration works and ids are stable/unique so + // find_device can resolve a persisted selection. + let devices = list_capture_devices().unwrap(); + let mut ids: Vec<&str> = devices.iter().map(|d| d.id.as_str()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), devices.len()); + } + + #[test] + fn find_render_device_resolves_a_real_enumerated_id() { + let devices = list_render_devices().unwrap(); + let Some(first) = devices.first() else { + return; // no render device on this machine/CI runner + }; + assert!(find_device(&Direction::Render, Some(&first.id)).is_ok()); + } + + #[test] + fn find_render_device_none_matches_get_default_device() { + // Documents the actual environment rather than assuming one: if this + // machine/CI runner has no default render device at all, + // wasapi::get_default_device itself errors — same as it always has, + // unrelated to device selection — and find_device(None) must + // fail identically rather than pretending to succeed. + let default_ok = wasapi::get_default_device(&Direction::Render).is_ok(); + assert_eq!(find_device(&Direction::Render, None).is_ok(), default_ok); + } + + #[test] + fn find_render_device_unknown_id_falls_back_exactly_like_none() { + // An id that can never be real should resolve identically to "no + // selection" — same success/failure outcome as whatever this + // environment's default-device lookup actually does. + let none_ok = find_device(&Direction::Render, None).is_ok(); + let unknown_ok = find_device(&Direction::Render, Some("not-a-real-device-id")).is_ok(); + assert_eq!(none_ok, unknown_ok); + } + #[test] fn audio_level_of_empty_chunk_is_silence() { let level = audio_level(&[]); diff --git a/src-tauri/src/calendar/mod.rs b/src-tauri/src/calendar/mod.rs index e99450f..29eda2b 100644 --- a/src-tauri/src/calendar/mod.rs +++ b/src-tauri/src/calendar/mod.rs @@ -9,6 +9,7 @@ //! writes to it. use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent}; +use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc}; use std::path::Path; use std::process::Command; @@ -79,9 +80,15 @@ fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> { _ => CalError::Open(e.to_string()), })?; if !output.status.success() { - return Err(CalError::Parse( - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )); + // readpst writes some errors to stdout rather than stderr; show + // whichever stream actually has text, stderr first. + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + stderr + }; + return Err(CalError::Parse(message)); } Ok(()) } @@ -202,6 +209,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec { let mut description = None; let mut starts_at = None; let mut ends_at = None; + let mut rrule: Option = None; let mut attendees: Vec = Vec::new(); for line in &lines { @@ -217,22 +225,49 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec { description = None; starts_at = None; ends_at = None; + rrule = None; attendees = Vec::new(); } "END" if value.eq_ignore_ascii_case("VEVENT") && in_event => { - events.push(ImportedEvent { - event: CalendarEvent { - id: uuid::Uuid::new_v4().to_string(), - source: source.to_string(), - subject: summary.take(), - organizer: organizer.take(), - starts_at, - ends_at, - description: description.take(), - raw_uid: uid.take(), - }, - attendees: std::mem::take(&mut attendees), - }); + let attendees = std::mem::take(&mut attendees); + match (rrule.take(), &uid, starts_at) { + // A recurring event needs a UID to key its occurrences + // for dedup (source, raw_uid) — without one, fall back + // to importing just the single stored occurrence below. + (Some(rule), Some(base_uid), Some(dtstart)) => { + let duration = ends_at.map(|e| e - dtstart).unwrap_or(0); + for occ_start in expand_rrule(&rule, dtstart) { + events.push(ImportedEvent { + event: CalendarEvent { + id: uuid::Uuid::new_v4().to_string(), + source: source.to_string(), + subject: summary.clone(), + organizer: organizer.clone(), + starts_at: Some(occ_start), + ends_at: Some(occ_start + duration), + description: description.clone(), + raw_uid: Some(format!("{base_uid}@{}", ymd_digits(occ_start))), + }, + attendees: attendees.clone(), + }); + } + } + _ => { + events.push(ImportedEvent { + event: CalendarEvent { + id: uuid::Uuid::new_v4().to_string(), + source: source.to_string(), + subject: summary.take(), + organizer: organizer.take(), + starts_at, + ends_at, + description: description.take(), + raw_uid: uid.take(), + }, + attendees, + }); + } + } in_event = false; } "UID" if in_event => uid = Some(value.to_string()), @@ -240,6 +275,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec { "DESCRIPTION" if in_event => description = Some(unescape_text(value)), "DTSTART" if in_event => starts_at = parse_ics_datetime(value), "DTEND" if in_event => ends_at = parse_ics_datetime(value), + "RRULE" if in_event => rrule = Some(value.to_string()), "ORGANIZER" if in_event => { let (name, email) = cal_address(params, value); organizer = name.or(email); @@ -261,6 +297,266 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec { events } +// ---- RRULE (RFC 5545 recurrence) expansion — pure, no I/O ---- + +// ponytail: caps for RRULEs with no COUNT/UNTIL (only YEARLY holidays do this +// in practice) and a hard ceiling regardless — extend both if a real series +// needs more instances than this. +const RECURRENCE_HORIZON_YEARS: i64 = 10; +const RECURRENCE_MAX_OCCURRENCES: usize = 500; + +enum Freq { + Daily, + Weekly, + Monthly, + Yearly, +} + +struct Rrule { + freq: Freq, + interval: i64, + count: Option, + until: Option, + byday: Vec, // weekday indices, 0=SU..6=SA + bymonthday: Vec, // 1..31 + bymonth: Vec, // 1..12 +} + +fn weekday_code_to_index(code: &str) -> Option { + // Strips a leading ordinal like "2MO" ("2nd Monday") — not seen in this + // codebase's real-world data (only plain weekday codes are), but the + // weekday is all this parser uses either way. + let letters: String = code.chars().filter(|c| c.is_ascii_alphabetic()).collect(); + match letters.to_ascii_uppercase().as_str() { + "SU" => Some(0), + "MO" => Some(1), + "TU" => Some(2), + "WE" => Some(3), + "TH" => Some(4), + "FR" => Some(5), + "SA" => Some(6), + _ => None, + } +} + +/// Parses `FREQ=WEEKLY;COUNT=26;BYDAY=MO`-style RRULE values. Supports +/// DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/ +/// BYMONTH — every combination confirmed present in a real 7.2GB mailbox +/// (ADR-0008). No BYSETPOS, no per-occurrence exceptions (RECURRENCE-ID). +fn parse_rrule(rule: &str) -> Option { + let mut freq = None; + let mut interval = 1i64; + let mut count = None; + let mut until = None; + let mut byday = Vec::new(); + let mut bymonthday = Vec::new(); + let mut bymonth = Vec::new(); + let mut current_list_key = String::new(); + for part in rule.split(';') { + // readpst joins a multi-value BYDAY with `;` instead of RFC 5545's + // `,` (e.g. `BYDAY=MO;TU;WE;TH;FR`), so a continuation token has no + // `=` at all — attribute it to whichever list key came before it. + let (key, v) = match part.split_once('=') { + Some((k, v)) => { + current_list_key = k.to_ascii_uppercase(); + (current_list_key.as_str(), v) + } + None => (current_list_key.as_str(), part), + }; + match key { + "FREQ" => { + freq = match v.to_ascii_uppercase().as_str() { + "DAILY" => Some(Freq::Daily), + "WEEKLY" => Some(Freq::Weekly), + "MONTHLY" => Some(Freq::Monthly), + "YEARLY" => Some(Freq::Yearly), + _ => None, + } + } + "INTERVAL" => interval = v.parse().unwrap_or(1).max(1), + "COUNT" => count = v.parse().ok(), + "UNTIL" => until = parse_ics_datetime(v), + "BYDAY" => byday.extend(v.split(',').filter_map(weekday_code_to_index)), + "BYMONTHDAY" => bymonthday.extend(v.split(',').filter_map(|s| s.parse::().ok())), + "BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::().ok())), + _ => {} + } + } + Some(Rrule { + freq: freq?, + interval, + count, + until, + byday, + bymonthday, + bymonth, + }) +} + +/// Resolves a local wall-clock datetime to a UTC unix timestamp, applying +/// whatever DST rule the OS has for that specific calendar date — this is +/// what keeps a recurring meeting at the same local time across a DST +/// transition instead of drifting by an hour. A skipped (spring-forward gap) +/// or ambiguous (fall-back overlap) local time resolves to the OS's earliest +/// matching instant rather than failing outright. +fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 { + match Local.from_local_datetime(&dt) { + chrono::LocalResult::Single(ldt) | chrono::LocalResult::Ambiguous(ldt, _) => { + ldt.with_timezone(&Utc).timestamp() + } + chrono::LocalResult::None => dt.and_utc().timestamp(), + } +} + +fn ymd_digits(unix_secs: i64) -> String { + match Utc.timestamp_opt(unix_secs, 0).single() { + Some(dt) => { + let d = dt.with_timezone(&Local).date_naive(); + format!("{:04}{:02}{:02}", d.year(), d.month(), d.day()) + } + None => String::new(), + } +} + +/// Expands an RRULE into occurrence start timestamps (unix seconds). Each +/// occurrence keeps `dtstart`'s *local* wall-clock time-of-day (assuming the +/// meeting's timezone matches this machine's — reasonable for a single-user +/// tool reading its own Outlook data), re-resolving the UTC offset per +/// occurrence so a series spanning a DST transition doesn't drift by an hour. +fn expand_rrule(rule: &str, dtstart: i64) -> Vec { + let Some(r) = parse_rrule(rule) else { + return vec![dtstart]; + }; + let Some(dtstart_utc) = Utc.timestamp_opt(dtstart, 0).single() else { + return vec![dtstart]; + }; + let local_start = dtstart_utc.with_timezone(&Local).naive_local(); + let (hour, min, sec) = ( + local_start.hour(), + local_start.minute(), + local_start.second(), + ); + let start_date = local_start.date(); + + let indefinite = r.count.is_none() && r.until.is_none(); + let effective_until = if indefinite { + dtstart + RECURRENCE_HORIZON_YEARS * 365 * 86_400 + } else { + r.until.unwrap_or(i64::MAX) + }; + let count_cap = r + .count + .unwrap_or(RECURRENCE_MAX_OCCURRENCES) + .min(RECURRENCE_MAX_OCCURRENCES); + + let at = |date: NaiveDate| date.and_hms_opt(hour, min, sec).map(local_to_utc_secs); + + let mut occurrences = Vec::new(); + match r.freq { + // Outlook emits "every weekday" as either FREQ, always with BYDAY — + // both iterate calendar weeks and keep the requested weekdays. + Freq::Weekly | Freq::Daily if !r.byday.is_empty() => { + let step_weeks = if matches!(r.freq, Freq::Weekly) { + r.interval + } else { + 1 + }; + let mut week_start = + start_date - Duration::days(start_date.weekday().num_days_from_sunday() as i64); + 'weeks: loop { + for &wd in &r.byday { + let date = week_start + Duration::days(wd as i64); + if date < start_date { + continue; + } + let Some(ts) = at(date) else { continue }; + if ts > effective_until || occurrences.len() >= count_cap { + break 'weeks; + } + occurrences.push(ts); + } + week_start += Duration::weeks(step_weeks); + } + } + Freq::Daily => { + let mut date = start_date; + while let Some(ts) = at(date) { + if ts > effective_until || occurrences.len() >= count_cap { + break; + } + occurrences.push(ts); + date += Duration::days(r.interval); + } + } + Freq::Weekly => { + let mut date = start_date; + while let Some(ts) = at(date) { + if ts > effective_until || occurrences.len() >= count_cap { + break; + } + occurrences.push(ts); + date += Duration::weeks(r.interval); + } + } + Freq::Monthly => { + let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day()); + let mut idx: i64 = 0; + loop { + let total = start_date.month0() as i64 + idx * r.interval; + let year = start_date.year() + total.div_euclid(12) as i32; + let month = (total.rem_euclid(12) + 1) as u32; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) { + if date >= start_date { + if let Some(ts) = at(date) { + if ts > effective_until || occurrences.len() >= count_cap { + break; + } + occurrences.push(ts); + } + } + } + idx += 1; + if idx as usize > RECURRENCE_MAX_OCCURRENCES * 2 { + break; // safety valve against a pathological rule + } + } + } + Freq::Yearly => { + let months = if r.bymonth.is_empty() { + vec![start_date.month()] + } else { + r.bymonth.clone() + }; + let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day()); + let mut year = start_date.year(); + loop { + for &month in &months { + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) { + if date >= start_date { + if let Some(ts) = at(date) { + if ts <= effective_until && occurrences.len() < count_cap { + occurrences.push(ts); + } + } + } + } + } + year += r.interval as i32; + if occurrences.len() >= count_cap + || year > start_date.year() + (RECURRENCE_HORIZON_YEARS * 2) as i32 + { + break; + } + } + } + } + if occurrences.is_empty() { + vec![dtstart] + } else { + occurrences + } +} + /// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or /// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating /// (no `Z`, no `TZID`) values are treated as UTC — full IANA timezone @@ -402,4 +698,115 @@ END:VCALENDAR\r\n"; }); assert!(matches!(result, Err(CalError::Open(_)))); } + + // These assert on *local* wall-clock time rather than raw UTC offsets — + // that's the entire point of the DST fix (a fixed UTC time-of-day is + // exactly the bug: a recurring meeting drifts an hour across a DST + // transition). Local-time assertions depend on this machine's configured + // timezone, same as the production code they're testing. + fn local_hms(ts: i64) -> (u32, u32, u32) { + let dt = Utc.timestamp_opt(ts, 0).unwrap().with_timezone(&Local); + (dt.hour(), dt.minute(), dt.second()) + } + fn local_date(ts: i64) -> NaiveDate { + Utc.timestamp_opt(ts, 0) + .unwrap() + .with_timezone(&Local) + .date_naive() + } + + #[test] + fn expand_rrule_weekly_single_byday_matches_the_real_1on1_pattern() { + // The exact rule readpst produced for a real "Weekly 1:1" on Mondays. + let dtstart = ymd_hms_to_unix(2026, 1, 12, 16, 30, 0); // a Monday + let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=26;BYDAY=MO", dtstart); + assert_eq!(occurrences.len(), 26); + assert_eq!(occurrences[0], dtstart); + let expected_hms = local_hms(dtstart); + for pair in occurrences.windows(2) { + assert_eq!((local_date(pair[1]) - local_date(pair[0])).num_days(), 7); + } + for occ in &occurrences { + assert_eq!( + local_hms(*occ), + expected_hms, + "local wall-clock time must not drift across a DST transition" + ); + assert_eq!(local_date(*occ).weekday(), chrono::Weekday::Mon); + } + } + + #[test] + fn expand_rrule_weekly_multi_byday_covers_every_weekday_in_order() { + let dtstart = ymd_hms_to_unix(2026, 1, 12, 9, 0, 0); // Monday + let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR", dtstart); + assert_eq!(occurrences.len(), 10); + // Mon..Fri week 1, then Mon..Fri week 2 — a flat +1 day step except + // the weekend gap between index 4 (Fri) and 5 (next Mon). + for i in 0..4 { + assert_eq!( + (local_date(occurrences[i + 1]) - local_date(occurrences[i])).num_days(), + 1 + ); + } + assert_eq!( + (local_date(occurrences[5]) - local_date(occurrences[4])).num_days(), + 3 + ); + let expected_hms = local_hms(dtstart); + for occ in &occurrences { + assert_eq!(local_hms(*occ), expected_hms); + } + } + + #[test] + fn expand_rrule_monthly_bymonthday_steps_calendar_months() { + let dtstart = ymd_hms_to_unix(2026, 1, 1, 9, 0, 0); + let occurrences = expand_rrule("FREQ=MONTHLY;COUNT=7;BYMONTHDAY=1", dtstart); + assert_eq!(occurrences.len(), 7); + let last = local_date(occurrences[6]); + assert_eq!((last.year(), last.month(), last.day()), (2026, 7, 1)); + let expected_hms = local_hms(dtstart); + for occ in &occurrences { + assert_eq!(local_hms(*occ), expected_hms); + } + } + + #[test] + fn expand_rrule_yearly_with_no_count_or_until_is_capped_by_the_horizon() { + let dtstart = ymd_hms_to_unix(2020, 11, 11, 17, 0, 0); // afternoon UTC, safe midnight margin + let occurrences = expand_rrule("FREQ=YEARLY;BYMONTHDAY=11;BYMONTH=11", dtstart); + assert!( + !occurrences.is_empty() && occurrences.len() <= RECURRENCE_HORIZON_YEARS as usize + 2 + ); + for occ in &occurrences { + let d = local_date(*occ); + assert_eq!((d.month(), d.day()), (11, 11)); + } + } + + #[test] + fn parse_vevents_expands_a_recurring_event_into_distinct_occurrences() { + let ics = "BEGIN:VEVENT\r\n\ +UID:series-1\r\n\ +SUMMARY:Weekly 1:1\r\n\ +DTSTART:20260112T163000Z\r\n\ +DTEND:20260112T170000Z\r\n\ +RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO\r\n\ +END:VEVENT\r\n"; + let events = parse_vevents(ics, "pst"); + assert_eq!(events.len(), 3); + let raw_uids: Vec<_> = events.iter().map(|e| e.event.raw_uid.clone()).collect(); + assert_eq!( + raw_uids.len(), + raw_uids + .iter() + .collect::>() + .len() + ); + for e in &events { + assert_eq!(e.event.subject.as_deref(), Some("Weekly 1:1")); + assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800); + } + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 74ba734..8aaf10a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -60,6 +60,11 @@ fn default_settings() -> Settings { sync_enabled: false, retention_max_age_days: None, retention_max_size_gb: None, + pst_last_path: None, + pst_auto_sync: false, + audio_output_device: None, + microphone_enabled: true, + audio_input_device: None, } } @@ -283,9 +288,39 @@ pub async fn start_recording( 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); - let capture = WasapiCapture - .start(&wav_path, frame_tx, event_tx) - .map_err(|e| WaError::new("audio", e.to_string()))?; + + // 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) = if settings.microphone_enabled { + let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx); + let capture = WasapiCapture + .start( + &wav_path, + settings.audio_output_device.as_deref(), + loop_sink, + event_tx.clone(), + ) + .map_err(|e| WaError::new("audio", e.to_string()))?; + let mic = WasapiCapture + .start_microphone(settings.audio_input_device.as_deref(), mic_sink, event_tx) + .map_err(|e| tracing::warn!("microphone capture unavailable: {e}")) + .ok(); + (capture, mic) + } 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) + }; // Fire-and-forget: exits on its own once `event_tx` drops at capture stop; // nothing downstream needs to join it. @@ -441,6 +476,7 @@ pub async fn start_recording( *guard = Some(RecordingSession { meeting_id: meeting_id.clone(), capture, + mic_capture, retention: args.record, wav_path, started_at: std::time::Instant::now(), @@ -489,8 +525,13 @@ pub async fn stop_recording( let summary = WasapiCapture .stop(session.capture) .map_err(|e| WaError::new("audio", e.to_string()))?; - // The capture thread has dropped its `FrameSink`; the transcription worker's - // `recv()` now returns `Err` and the worker exits on its own — join to + // 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(); @@ -613,6 +654,11 @@ pub async fn pause_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", @@ -635,6 +681,11 @@ pub async fn resume_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", @@ -903,6 +954,28 @@ pub async fn hardware_status() -> WaResult { })) } +/// 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" @@ -1939,6 +2012,46 @@ pub async fn confirm_action_items( Ok(()) } +/// Suggests 1-8 short topical tags from the transcript (T8.3, FR-SEARCH-2) — +/// same LLM path/prompt assembly as generate_summary, just a much shorter +/// non-streamed reply. Suggestions are NOT saved automatically; the caller +/// reviews/merges them and still calls set_tags to persist, same as a +/// manually typed tag. +#[tauri::command] +pub async fn generate_tags( + 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( + "llm", + "cannot generate tags while this meeting is still recording — wait until it's stopped", + )); + } + drop(guard); + + let settings = load_settings(); + let provider = llm_provider_from_settings(&settings).ok_or_else(|| { + WaError::new( + "llm", + "no LLM provider is configured — enable one in Settings first", + ) + })?; + + let meeting = state + .store + .get_meeting(&meeting_id) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + let prompt = build_prompt(&meeting, None); + let text = format!("{}\n\n{}", prompt.metadata, prompt.transcript); + provider + .suggest_tags(&text) + .await + .map_err(|e| WaError::new("llm", e.to_string())) +} + // ---- Calendar / .pst (Phase 6) ---- /// Import events + attendees from a `.pst` (T6.1/T6.2, FR-CAL-1). The @@ -1947,10 +2060,12 @@ pub async fn confirm_action_items( /// progress, so `pst://progress` is a start/done signal rather than /// per-item — real per-item progress would mean reimplementing readpst's /// internals, not worth it for a one-shot import. -#[tauri::command] -pub async fn import_pst( - app: AppHandle, - state: State<'_, AppState>, +/// +/// Split from the `#[tauri::command]` wrapper so the startup auto-sync pass +/// (T6.2, `pst_auto_sync`) can call the same logic without a `State` extractor. +pub(crate) async fn import_pst_core( + app: &AppHandle, + store: &dyn crate::storage::Store, path: String, password: Option, ) -> WaResult { @@ -1967,8 +2082,7 @@ pub async fn import_pst( serde_json::json!({ "processed": 0, "total": total }), ); - let imported = state - .store + let imported = store .import_calendar_events(events) .await .map_err(|e| WaError::new("storage", e.to_string()))?; @@ -1980,6 +2094,16 @@ pub async fn import_pst( Ok(imported) } +#[tauri::command] +pub async fn import_pst( + app: AppHandle, + state: State<'_, AppState>, + path: String, + password: Option, +) -> WaResult { + import_pst_core(&app, state.store.as_ref(), path, password).await +} + /// Browse imported calendar events (T6.3, FR-CAL-2). #[tauri::command] pub async fn list_calendar_events( @@ -2023,6 +2147,25 @@ pub async fn attach_meeting_to_event( .map_err(|e| WaError::new("storage", e.to_string())) } +/// Manually rename a meeting — recordings default to "Untitled meeting" with +/// no prior way to change that from the UI. +#[tauri::command] +pub async fn rename_meeting( + state: State<'_, AppState>, + meeting_id: MeetingId, + title: String, +) -> WaResult<()> { + let title = title.trim(); + if title.is_empty() { + return Err(WaError::new("storage", "title cannot be empty")); + } + state + .store + .rename_meeting(&meeting_id, title) + .await + .map_err(|e| WaError::new("storage", e.to_string())) +} + // ---- Sync / upload (Phase 9, ADR-0010) ---- /// Config payload for add/update (snake_case, matching the TS `SyncTargetConfig` @@ -2369,13 +2512,21 @@ pub async fn test_sync_target( (row.credential_ref.clone(), None) }; let target = crate::sync::WebDavTarget { - base_url: config.base_url.clone().or(row.base_url.clone()).unwrap_or_default(), + base_url: config + .base_url + .clone() + .or(row.base_url.clone()) + .unwrap_or_default(), provider_hint: config.provider_hint.clone().or(row.provider_hint.clone()), remote_base_path: config .remote_base_path .clone() .unwrap_or(row.remote_base_path.clone()), - username: config.username.clone().or(row.username.clone()).unwrap_or_default(), + username: config + .username + .clone() + .or(row.username.clone()) + .unwrap_or_default(), credential_ref, third_party: false, allow_plaintext_lan: config diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5c334f..c327ada 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,6 +44,10 @@ pub struct AppState { pub struct RecordingSession { pub meeting_id: models::MeetingId, pub capture: audio::CaptureHandle, + /// The user's microphone capture (FR-CAP-7), mixed into the transcript + /// stream. `None` when the mic is disabled in Settings or failed to open — + /// the meeting proceeds on loopback alone either way. + pub mic_capture: Option, /// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting. pub retention: bool, pub wav_path: PathBuf, @@ -161,6 +165,22 @@ pub fn run() { if commands::load_settings().sync_enabled { commands::pump_sync(&startup_app, store.as_ref()).await; } + + // Re-import the last .pst path if the user opted into auto-sync + // (T6.2). One-shot on startup, same as the sync-job resume above + // — no idle timer (NFR-RES-1). Re-import is dedup'd by + // (source, raw_uid), so this just catches up on new/changed events. + let pst_settings = commands::load_settings(); + if pst_settings.pst_auto_sync { + if let Some(path) = pst_settings.pst_last_path { + if let Err(e) = + commands::import_pst_core(&startup_app, store.as_ref(), path, None) + .await + { + tracing::warn!("startup PST auto-sync failed: {e:?}"); + } + } + } }); Ok(()) }) @@ -175,6 +195,8 @@ pub fn run() { commands::app_info, commands::open_url, commands::hardware_status, + commands::list_audio_devices, + commands::list_input_devices, commands::set_preferred_backend, commands::list_models, commands::download_npu_package, @@ -200,12 +222,14 @@ pub fn run() { commands::set_llm_provider, commands::generate_summary, commands::confirm_action_items, + commands::generate_tags, commands::llm_setup_suggestions, commands::pull_ollama_model, commands::import_pst, commands::list_calendar_events, commands::get_calendar_event, commands::attach_meeting_to_event, + commands::rename_meeting, commands::list_sync_targets, commands::add_sync_target, commands::update_sync_target, diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index f6aadc1..ecda5f0 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -42,6 +42,10 @@ pub type TokenSink = std::sync::mpsc::Sender; pub trait LlmProvider: Send + Sync { async fn status(&self) -> LlmStatus; async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result; + /// Suggests 1-8 short topical tags for a transcript (T8.3, FR-SEARCH-2). + /// Non-streaming — the reply is short enough that a single round trip is + /// simpler than wiring up another token-stream event for it. + async fn suggest_tags(&self, transcript: &str) -> Result, LlmError>; /// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1). fn is_local(&self) -> bool; } @@ -164,6 +168,69 @@ fn bullet_text(line: &str) -> Option { } } +// ---- Tag suggestion (T8.3, FR-SEARCH-2) ---- + +const TAG_INSTRUCTIONS: &str = "You generate short topical tags for a meeting transcript. Respond \ + with ONLY a comma-separated list of 1 to 8 short tags, nothing else — no numbering, no \ + explanation, no quotes. Each tag: lowercase, 1-3 words, hyphenated instead of spaces (e.g. \ + \"budget-review\" not \"budget review\")."; + +fn build_tag_messages(transcript: &str) -> Vec { + vec![ + serde_json::json!({ "role": "system", "content": TAG_INSTRUCTIONS }), + serde_json::json!({ "role": "user", "content": format!("Transcript:\n{transcript}") }), + ] +} + +/// Strips a leading "1. "/"1) " ordinal a model sometimes adds despite being +/// asked for a plain comma list — only when digits are immediately followed +/// by `.`/`)`, so a legitimate tag like "3d-printing" is left alone. +fn strip_ordinal_prefix(s: &str) -> &str { + let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + if digits_end > 0 && matches!(s[digits_end..].chars().next(), Some('.') | Some(')')) { + s[digits_end..].trim_start_matches(['.', ')']).trim_start() + } else { + s + } +} + +/// Lowercases, collapses whitespace/underscores to hyphens, and drops any +/// other punctuation — regardless of how well the model followed +/// `TAG_INSTRUCTIONS`, every tag that reaches the UI is chip-safe. +fn sanitize_tag(raw: &str) -> Option { + let cleaned: String = raw + .trim() + .to_ascii_lowercase() + .chars() + .filter_map(|c| match c { + c if c.is_ascii_alphanumeric() || c == '-' => Some(c), + ' ' | '_' => Some('-'), + _ => None, + }) + .collect(); + let cleaned = cleaned.trim_matches('-').to_string(); + (!cleaned.is_empty()).then_some(cleaned) +} + +/// Parses a model's tag-list reply into at most 8 deduped, sanitized tags. +/// Splits on comma AND newline since a model doesn't always follow the +/// requested comma-separated format. +fn parse_tags(text: &str) -> Vec { + let mut tags = Vec::new(); + for raw in text.split([',', '\n']) { + let Some(tag) = sanitize_tag(strip_ordinal_prefix(raw.trim())) else { + continue; + }; + if !tags.contains(&tag) { + tags.push(tag); + } + if tags.len() >= 8 { + break; + } + } + tags +} + /// Whether a host is on the user's own machine or private LAN — i.e. not the /// public internet or a third party. Loopback and RFC-1918 / link-local ranges /// (e.g. `192.168.0.0/24`, `10.0.0.0/8`) plus `*.local` count as local, so a @@ -347,6 +414,30 @@ impl LlmProvider for OllamaProvider { Ok(parse_summary(&full_text)) } + async fn suggest_tags(&self, transcript: &str) -> Result, LlmError> { + let body = serde_json::json!({ + "model": self.model, + "messages": build_tag_messages(transcript), + "stream": false, + }); + let resp = reqwest::Client::new() + .post(format!("{}/api/chat", self.base())) + .json(&body) + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + let chunk: OllamaChatChunk = resp + .json() + .await + .map_err(|e| LlmError::Request(e.to_string()))?; + Ok(parse_tags( + &chunk.message.map(|m| m.content).unwrap_or_default(), + )) + } + fn is_local(&self) -> bool { is_local_endpoint(&self.endpoint) } @@ -490,6 +581,52 @@ impl LlmProvider for OpenAiCompatProvider { Ok(parse_summary(&full_text)) } + async fn suggest_tags(&self, transcript: &str) -> Result, LlmError> { + // Reuses the same SSE streaming path as summarize() (this API has no + // simpler non-streaming reply shape worth a second response struct + // for) — just accumulates instead of forwarding to a TokenSink. + let body = serde_json::json!({ + "model": self.model, + "messages": build_tag_messages(transcript), + "stream": true, + }); + let req = self.auth( + reqwest::Client::new() + .post(format!("{}/v1/chat/completions", self.base())) + .json(&body), + ); + let resp = req + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + + let mut full_text = String::new(); + stream_lines(resp, |line| { + let Some(payload) = line.strip_prefix("data:") else { + return true; + }; + let payload = payload.trim(); + if payload == "[DONE]" { + return false; + } + let Ok(chunk) = serde_json::from_str::(payload) else { + return true; + }; + for choice in chunk.choices { + if let Some(content) = choice.delta.content { + full_text.push_str(&content); + } + } + true + }) + .await?; + + Ok(parse_tags(&full_text)) + } + fn is_local(&self) -> bool { self.credential_ref.is_none() && is_local_endpoint(&self.endpoint) } @@ -613,6 +750,11 @@ impl LlmProvider for AnthropicProvider { "Anthropic provider isn't built yet (Phase 10a)".to_string(), )) } + async fn suggest_tags(&self, _transcript: &str) -> Result, LlmError> { + Err(LlmError::Request( + "Anthropic provider isn't built yet (Phase 10a)".to_string(), + )) + } fn is_local(&self) -> bool { false } @@ -648,6 +790,45 @@ mod tests { assert!(summary.action_items.is_empty()); } + #[test] + fn parse_tags_splits_a_well_formed_comma_list() { + let tags = parse_tags("golang, webrtc, dtls, wasm"); + assert_eq!(tags, vec!["golang", "webrtc", "dtls", "wasm"]); + } + + #[test] + fn parse_tags_handles_newline_separated_and_numbered_replies() { + // Models don't always follow "comma-separated, nothing else" exactly. + let tags = parse_tags("1. Budget Review\n2) Q3 Planning\n3. hiring"); + assert_eq!(tags, vec!["budget-review", "q3-planning", "hiring"]); + } + + #[test] + fn parse_tags_dedupes_and_caps_at_eight() { + let text = (1..=10) + .map(|i| format!("tag{i}")) + .collect::>() + .join(", ") + + ", tag1"; + let tags = parse_tags(&text); + assert_eq!(tags.len(), 8); + assert_eq!(tags[0], "tag1"); + } + + #[test] + fn parse_tags_does_not_mangle_a_tag_that_starts_with_a_digit() { + // strip_ordinal_prefix must only fire for "." / ")", + // not any tag that happens to start with a number. + let tags = parse_tags("3d-printing, web3"); + assert_eq!(tags, vec!["3d-printing", "web3"]); + } + + #[test] + fn parse_tags_ignores_blank_entries_and_quotes() { + let tags = parse_tags("\"golang\", , \"web-dev\" ,,"); + assert_eq!(tags, vec!["golang", "web-dev"]); + } + #[test] fn is_local_endpoint_accepts_loopback_and_lan_but_rejects_public_hosts() { // Loopback. diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0decf5d..7ba4a31 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -207,6 +207,31 @@ pub struct Settings { // Storage retention policy (FR-STORE-2). None = no cap on that dimension. pub retention_max_age_days: Option, pub retention_max_size_gb: Option, + // Calendar / .pst (T6.2) — remembered so the user doesn't re-browse every + // launch. `pst_auto_sync` re-imports this path once at startup if set. + #[serde(default)] + pub pst_last_path: Option, + #[serde(default)] + pub pst_auto_sync: bool, + // Audio capture device override (FR-CAP-1). `Device::get_id()` string; + // None = system default render device (loopback / system audio). + #[serde(default)] + pub audio_output_device: Option, + // Microphone capture (FR-CAP-7): mix the user's own voice into the live + // transcript. Local-only, no egress; default ON. Turn off to transcribe just + // the system/loopback audio, as WA did before. + #[serde(default = "default_true")] + pub microphone_enabled: bool, + // Microphone device override — `Device::get_id()` string; None = system + // default capture device. + #[serde(default)] + pub audio_input_device: Option, +} + +/// serde default for a `bool` field that should be `true` when absent from an +/// older `settings.json` (so upgrading users get the microphone, FR-CAP-7). +fn default_true() -> bool { + true } // ---- Sync (ADR-0010) ---- diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index e4307d3..eb10ddc 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -234,12 +234,18 @@ pub trait Store: Send + Sync { /// pre-meeting context panel and the speaker-naming attendee dropdown. async fn get_calendar_event(&self, id: &str) -> Result; /// Link a meeting (current or historical) to a calendar event (T6.3/T6.6, - /// FR-CAL-2/4). Errs if either id doesn't exist. + /// FR-CAL-2/4). Errs if either id doesn't exist. Also mirrors the event's + /// subject onto the meeting's title when it has one — linking is meant to + /// say "this recording is that meeting," so a recording still sitting at + /// its default "Untitled meeting" name should follow it. async fn attach_meeting_to_event( &self, meeting_id: &MeetingId, event_id: &str, ) -> Result<(), StoreError>; + /// Manually rename a meeting — recordings otherwise default to "Untitled + /// meeting" with no other way to change that. + async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError>; /// Names a speaker AND links them to a known `Participant` (T6.5/T6.6, /// FR-SPK-4): the display name comes from the participant record, and /// the shared `participant_id` is what gives naming "continuity" across @@ -931,21 +937,49 @@ impl Store for SqliteStore { meeting_id: &MeetingId, event_id: &str, ) -> Result<(), StoreError> { - let exists: Option = - sqlx::query_scalar("SELECT id FROM calendar_events WHERE id = ?") + let row: Option<(String, Option)> = + sqlx::query_as("SELECT id, subject FROM calendar_events WHERE id = ?") .bind(event_id) .fetch_optional(&self.pool) .await?; - if exists.is_none() { + let Some((_, subject)) = row else { return Err(StoreError::NotFound(format!("calendar event {event_id}"))); - } - let result = - sqlx::query("UPDATE meetings SET calendar_event_id = ?, updated_at = ? WHERE id = ?") + }; + + let result = match subject.filter(|s| !s.is_empty()) { + Some(title) => sqlx::query( + "UPDATE meetings SET calendar_event_id = ?, title = ?, updated_at = ? WHERE id = ?", + ) + .bind(event_id) + .bind(title) + .bind(now_unix()) + .bind(meeting_id) + .execute(&self.pool) + .await?, + None => { + sqlx::query( + "UPDATE meetings SET calendar_event_id = ?, updated_at = ? WHERE id = ?", + ) .bind(event_id) .bind(now_unix()) .bind(meeting_id) .execute(&self.pool) - .await?; + .await? + } + }; + if result.rows_affected() == 0 { + return Err(StoreError::NotFound(meeting_id.clone())); + } + Ok(()) + } + + async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError> { + let result = sqlx::query("UPDATE meetings SET title = ?, updated_at = ? WHERE id = ?") + .bind(title) + .bind(now_unix()) + .bind(meeting_id) + .execute(&self.pool) + .await?; if result.rows_affected() == 0 { return Err(StoreError::NotFound(meeting_id.clone())); } @@ -1525,6 +1559,117 @@ mod tests { } } + #[tokio::test] + async fn rename_meeting_updates_the_title() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let id = store + .create_meeting(NewMeeting { + title: "Untitled meeting".to_string(), + calendar_event_id: None, + template_id: None, + }) + .await + .unwrap(); + store.rename_meeting(&id, "Sprint planning").await.unwrap(); + assert_eq!( + store.get_meeting(&id).await.unwrap().title, + "Sprint planning" + ); + } + + #[tokio::test] + async fn rename_meeting_errs_for_an_unknown_id() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let result = store.rename_meeting(&"no-such-id".to_string(), "x").await; + assert!(matches!(result, Err(StoreError::NotFound(_)))); + } + + #[tokio::test] + async fn attach_meeting_to_event_mirrors_the_events_subject_onto_the_title() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let meeting_id = store + .create_meeting(NewMeeting { + title: "Untitled meeting".to_string(), + calendar_event_id: None, + template_id: None, + }) + .await + .unwrap(); + store + .import_calendar_events(vec![ImportedEvent { + event: CalendarEvent { + id: "ev1".to_string(), + source: "pst".to_string(), + subject: Some("Jerry / Daniel - Weekly 1:1".to_string()), + organizer: None, + starts_at: None, + ends_at: None, + description: None, + raw_uid: Some("uid-1".to_string()), + }, + attendees: vec![], + }]) + .await + .unwrap(); + let event_id = store.list_calendar_events(None, None).await.unwrap()[0] + .id + .clone(); + + store + .attach_meeting_to_event(&meeting_id, &event_id) + .await + .unwrap(); + + let meeting = store.get_meeting(&meeting_id).await.unwrap(); + assert_eq!(meeting.title, "Jerry / Daniel - Weekly 1:1"); + assert_eq!( + meeting.calendar_event_id.as_deref(), + Some(event_id.as_str()) + ); + } + + #[tokio::test] + async fn attach_meeting_to_event_leaves_the_title_alone_when_the_event_has_no_subject() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let meeting_id = store + .create_meeting(NewMeeting { + title: "Untitled meeting".to_string(), + calendar_event_id: None, + template_id: None, + }) + .await + .unwrap(); + store + .import_calendar_events(vec![ImportedEvent { + event: CalendarEvent { + id: "ev1".to_string(), + source: "pst".to_string(), + subject: None, + organizer: None, + starts_at: None, + ends_at: None, + description: None, + raw_uid: Some("uid-2".to_string()), + }, + attendees: vec![], + }]) + .await + .unwrap(); + let event_id = store.list_calendar_events(None, None).await.unwrap()[0] + .id + .clone(); + + store + .attach_meeting_to_event(&meeting_id, &event_id) + .await + .unwrap(); + + assert_eq!( + store.get_meeting(&meeting_id).await.unwrap().title, + "Untitled meeting" + ); + } + #[tokio::test] async fn sync_target_crud_round_trips() { let store = SqliteStore::connect_in_memory().await.unwrap(); diff --git a/src/lib/api.ts b/src/lib/api.ts index a021996..84a10d6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -48,6 +48,14 @@ export interface HardwareStatus { directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean }; } +// One enumerated audio device — a render (playback) device for the loopback +// picker (FR-CAP-1) or a capture (microphone) device for the mic picker +// (FR-CAP-7). `id` is the persisted `Device::get_id()`; `name` is display-only. +export interface AudioDeviceInfo { + id: string; + name: string; +} + export interface LlmStatus { provider: string; // ollama|custom|off (Phase 10a adds anthropic|openai) reachable: boolean; @@ -283,6 +291,11 @@ export interface AppSettings { mcp_enabled: boolean; retention_max_age_days: number | null; retention_max_size_gb: number | null; + pst_last_path: string | null; + pst_auto_sync: boolean; + audio_output_device: string | null; + microphone_enabled: boolean; + audio_input_device: string | null; } // Feature brief — agent-ready spec distilled from a meeting (ADR-0011). @@ -335,6 +348,8 @@ export const api = { appInfo: () => invoke("app_info"), openUrl: (url: string) => invoke("open_url", { url }), hardwareStatus: () => invoke("hardware_status"), + listAudioDevices: () => invoke("list_audio_devices"), + listInputDevices: () => invoke("list_input_devices"), setPreferredBackend: (backend: BackendId | "auto") => invoke("set_preferred_backend", { args: { backend } }), downloadNpuPackage: () => invoke("download_npu_package"), @@ -394,6 +409,7 @@ export const api = { invoke("generate_summary", { meetingId, templateId }), confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) => invoke("confirm_action_items", { meetingId, items }), + generateTags: (meetingId: MeetingId) => invoke("generate_tags", { meetingId }), importPst: (path: string, password?: string) => invoke("import_pst", { path, password }), listCalendarEvents: (from?: number, to?: number) => @@ -402,6 +418,8 @@ export const api = { invoke("get_calendar_event", { eventId }), attachMeetingToEvent: (meetingId: MeetingId, eventId: string) => invoke("attach_meeting_to_event", { meetingId, eventId }), + renameMeeting: (meetingId: MeetingId, title: string) => + invoke("rename_meeting", { meetingId, title }), renameSpeaker: (meetingId: MeetingId, label: string, name: string) => invoke("rename_speaker", { meetingId, label, name }), mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) => diff --git a/src/lib/components/TagChip.svelte b/src/lib/components/TagChip.svelte new file mode 100644 index 0000000..e404921 --- /dev/null +++ b/src/lib/components/TagChip.svelte @@ -0,0 +1,92 @@ + + + + + {#if removable} + + {/if} + + + diff --git a/src/lib/stores/calendar.svelte.ts b/src/lib/stores/calendar.svelte.ts index b4d67f7..2b95bfd 100644 --- a/src/lib/stores/calendar.svelte.ts +++ b/src/lib/stores/calendar.svelte.ts @@ -1,7 +1,7 @@ // Imported calendar events (Phase 6, FR-CAL-*). Svelte 5 runes store, same // shape as settings.svelte.ts/meetings.svelte.ts. -import { api, events, type CalendarEvent } from "../api"; +import { api, errorMessage, events, type CalendarEvent } from "../api"; class CalendarStore { events = $state([]); @@ -30,7 +30,7 @@ class CalendarStore { await api.importPst(path, password); await this.load(); } catch (e) { - this.importError = e instanceof Error ? e.message : String(e); + this.importError = errorMessage(e); } finally { this.importing = false; this.importProgress = null; diff --git a/src/lib/stores/meetings.svelte.ts b/src/lib/stores/meetings.svelte.ts index a2090b7..e2ea25d 100644 --- a/src/lib/stores/meetings.svelte.ts +++ b/src/lib/stores/meetings.svelte.ts @@ -169,6 +169,15 @@ class MeetingsStore { await this.load(); } + /** Click-to-filter from a tag chip anywhere (T8.3, FR-SEARCH-2) — same + * mechanism as the sidebar's tag dropdown, just triggered elsewhere. + * Keeps any existing date filter, drops search mode (a tag filter and a + * text search are two different views over the same list). */ + async filterByTag(tag: string) { + this.searchResults = null; + await this.load({ ...this.filter, tag }); + } + /** `null` clears search mode and reverts the list view to `load()`'s results. */ async search(query: string | null) { if (!query || !query.trim()) { @@ -224,7 +233,17 @@ class MeetingsStore { /** Link a recording to a calendar event (T6.3/T6.6, FR-CAL-2/4). */ async attachEvent(id: MeetingId, eventId: string) { await api.attachMeetingToEvent(id, eventId); + // Attaching mirrors the event's subject onto the title server-side + // (FR-CAL-2) — refresh the list too, not just the detail view. if (this.selectedId === id) await this.select(id); + await this.load(); + } + + /** Manual rename (T2.2) — recordings otherwise default to "Untitled meeting". */ + async renameMeeting(id: MeetingId, title: string) { + await api.renameMeeting(id, title); + if (this.selectedId === id) await this.select(id); + await this.load(); } /** Free-text speaker rename (T4.4, FR-SPK-2) — the "add new name" escape diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index d4bc3e1..bd3b0e9 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -8,6 +8,7 @@ import { errorMessage, events, type AppSettings, + type AudioDeviceInfo, type SyncTargetInfo, type SyncTargetConfig, type HardwareStatus, @@ -31,6 +32,11 @@ const DEFAULT_SETTINGS: AppSettings = { mcp_enabled: false, // MCP server OFF by default (ADR-0011) retention_max_age_days: null, // no cap by default (FR-STORE-2) retention_max_size_gb: null, + pst_last_path: null, + pst_auto_sync: false, + audio_output_device: null, // system default render device (FR-CAP-1) + microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7) + audio_input_device: null, // system default capture device }; class SettingsStore { @@ -45,6 +51,8 @@ class SettingsStore { // Hardware + model management (Phase 3, T3.6/T3.7). hardware = $state(null); models = $state([]); + audioDevices = $state([]); + inputDevices = $state([]); downloadProgress = $state>({}); // Privacy self-check (T7.6, FR-SEC-2). @@ -69,6 +77,8 @@ class SettingsStore { this.backendStub = true; } await this.loadHardware(); + await this.loadAudioDevices(); + await this.loadInputDevices(); await this.loadModels(); await this.loadPrivacy(); await this.loadLlmStatus(); @@ -123,6 +133,32 @@ class SettingsStore { } } + async loadAudioDevices() { + try { + this.audioDevices = await api.listAudioDevices(); + } catch { + this.audioDevices = []; + } + } + + async setAudioOutputDevice(deviceId: string | null) { + await this.patch({ audio_output_device: deviceId }); + } + + async loadInputDevices() { + try { + this.inputDevices = await api.listInputDevices(); + } catch { + this.inputDevices = []; + } + } + + /** Set the microphone selection in one patch (FR-CAP-7): `enabled=false` + * disables mic capture entirely; `deviceId=null` uses the system default. */ + async setMicrophone(enabled: boolean, deviceId: string | null) { + await this.patch({ microphone_enabled: enabled, audio_input_device: deviceId }); + } + async loadModels() { try { this.models = await api.listModels(); diff --git a/src/lib/views/MeetingsList.svelte b/src/lib/views/MeetingsList.svelte index 8508d9d..49152e3 100644 --- a/src/lib/views/MeetingsList.svelte +++ b/src/lib/views/MeetingsList.svelte @@ -17,7 +17,10 @@ // Tag/date filters (T8.3, FR-SEARCH-2) apply to the plain list, not // full-text search — changing one drops out of search mode so the // filtered list is immediately visible rather than hidden behind results. - let tagFilter = $state(""); + // Writable derived (not $state+$effect) so this dropdown also reflects a + // tag filter triggered elsewhere (e.g. clicking a chip in the Tags panel), + // while still being directly editable via bind:value below. + let tagFilter = $derived(meetings.filter.tag ?? ""); let fromFilter = $state(""); let toFilter = $state(""); diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 3b71e3d..a559ea3 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -173,6 +173,34 @@ // ---- Calendar / .pst import (T6.1/T6.2/T6.3, FR-CAL-1/2) ---- let pstPath = $state(""); let pstPassword = $state(""); + let eventTitleFilter = $state(""); + let eventDateFilter = $state(""); + + function eventLocalYmd(unixSecs: number | null): string { + if (!unixSecs) return ""; + const d = new Date(unixSecs * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + } + + let filteredEvents = $derived( + calendar.events.filter((ev) => { + if (eventDateFilter && eventLocalYmd(ev.starts_at) !== eventDateFilter) return false; + if ( + eventTitleFilter && + !(ev.subject ?? "").toLowerCase().includes(eventTitleFilter.toLowerCase()) + ) + return false; + return true; + }), + ); + + // Prefills the remembered path once settings load, without clobbering + // whatever the user is actively typing/browsing to. + $effect(() => { + if (!pstPath && settings.settings.pst_last_path) { + pstPath = settings.settings.pst_last_path; + } + }); async function pickPstFile() { const path = await open({ filters: [{ name: "Outlook data file", extensions: ["pst"] }] }); @@ -182,6 +210,10 @@ if (!pstPath) return; await calendar.importPst(pstPath, pstPassword || undefined); pstPassword = ""; + if (!calendar.importError) await settings.patch({ pst_last_path: pstPath }); + } + function onToggleAutoSync(e: Event) { + settings.patch({ pst_auto_sync: (e.target as HTMLInputElement).checked }); } function formatEventDate(unixSecs: number | null): string { if (!unixSecs) return ""; @@ -531,6 +563,50 @@ to load.

+

Audio Devices

+ +

+ WhispAssist records whatever this device plays (loopback) — the other side of the call. + Pick a specific output if you don't want it following Windows' system default. +

+ + +

+ Adds your own voice to the live transcript so both sides of the meeting are captured. + Stays on your device — nothing is uploaded. Choose “Off” to transcribe only the system + audio above. +

+ {#if settings.hardware.npu?.present} {@const npu = settings.hardware.npu} {@const ready = npu.runtimeReady && npu.modelInstalled} @@ -697,6 +773,18 @@ > {/if} + +

+ Runs once at startup, not on a timer — re-import is safe to repeat (existing events are + matched and updated, not duplicated). +

{#if calendar.importError}

Import failed: {calendar.importError} — the file itself is untouched; check the path and try @@ -708,8 +796,21 @@ {#if calendar.events.length === 0}

No events imported yet.

{:else} +
+ + +
+

+ {filteredEvents.length} of {calendar.events.length} events +

    - {#each calendar.events as ev (ev.id)} + {#each filteredEvents as ev (ev.id)}
  • {ev.subject ?? "(untitled)"} {formatEventDate(ev.starts_at)} diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte index ed87151..576e572 100644 --- a/src/lib/views/SummaryPanel.svelte +++ b/src/lib/views/SummaryPanel.svelte @@ -4,8 +4,15 @@ import { meetings } from "../stores/meetings.svelte"; import { calendar } from "../stores/calendar.svelte"; import { settings } from "../stores/settings.svelte"; - import { api, type ActionItem, type CalendarEventDetail, type LlmStatus } from "../api"; + import { + api, + errorMessage, + type ActionItem, + type CalendarEventDetail, + type LlmStatus, + } from "../api"; import { renderMarkdown } from "../markdown"; + import TagChip from "../components/TagChip.svelte"; import { Tags, Sparkles, @@ -94,6 +101,35 @@ await meetings.attachEvent(m.id, eventId); } + // ---- Search/date filter for the event picker (a real mailbox import can + // be thousands of events) — defaults to the recording's own date since + // that's almost always the event being linked. ---- + let eventLinkSearch = $state(""); + let eventLinkDateFilter = $state(""); + $effect(() => { + const m = meetings.selected; + eventLinkDateFilter = m ? eventLocalYmd(m.started_at) : ""; + eventLinkSearch = ""; + }); + + function eventLocalYmd(unixSecs: number | null): string { + if (!unixSecs) return ""; + const d = new Date(unixSecs * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + } + + let filteredLinkEvents = $derived( + calendar.events.filter((ev) => { + if (eventLinkDateFilter && eventLocalYmd(ev.starts_at) !== eventLinkDateFilter) return false; + if ( + eventLinkSearch && + !(ev.subject ?? "").toLowerCase().includes(eventLinkSearch.toLowerCase()) + ) + return false; + return true; + }), + ); + // ---- Attendee-aware speaker naming (T6.5, FR-SPK-4) ---- const NEW_NAME = "__new__"; let addingNameFor = $state(null); @@ -121,24 +157,79 @@ } // ---- Tags (T8.3, FR-SEARCH-2) ---- - // Writable derived: reflects meetings.selected.tags, but typing (bind:value) - // locally overrides it until the selection changes again. - let tagsInput = $derived((meetings.selected?.tags ?? []).join(", ")); + // pendingTags is the editable working copy — resynced from + // meetings.selected.tags whenever a *different* meeting is selected, same + // guard pattern as notesText in TranscriptNotes.svelte, so it isn't + // clobbered by other reactivity while the user is mid-edit. + let pendingTags = $state([]); + let tagDraft = $state(""); + let loadedTagsForId: string | null = null; + $effect(() => { + const m = meetings.selected; + if (m && m.id !== loadedTagsForId) { + pendingTags = [...m.tags]; + tagDraft = ""; + loadedTagsForId = m.id; + } else if (!m) { + loadedTagsForId = null; + } + }); + + function addTag(raw: string) { + const t = raw.trim().toLowerCase(); + if (t && !pendingTags.includes(t)) pendingTags.push(t); + } + // GitHub-topics-style input: a comma commits everything before it as its + // own chip immediately, leaving whatever's after as the live draft. + function onTagInput() { + if (!tagDraft.includes(",")) return; + const parts = tagDraft.split(","); + tagDraft = parts.pop() ?? ""; + parts.forEach(addTag); + } + function onTagInputKeydown(e: KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + commitDraft(); + } + } + function commitDraft() { + if (tagDraft.trim()) addTag(tagDraft); + tagDraft = ""; + } + function removeTag(t: string) { + pendingTags = pendingTags.filter((x) => x !== t); + } + let savingTags = $state(false); async function saveTags() { const m = meetings.selected; if (!m) return; + commitDraft(); savingTags = true; try { - const tags = tagsInput - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - await meetings.setTags(m.id, tags); + await meetings.setTags(m.id, pendingTags); } finally { savingTags = false; } } + + let generatingTags = $state(false); + let tagGenError = $state(null); + async function generateTagsNow() { + const m = meetings.selected; + if (!m) return; + generatingTags = true; + tagGenError = null; + try { + const suggested = await api.generateTags(m.id); + suggested.forEach(addTag); + } catch (e) { + tagGenError = errorMessage(e); + } finally { + generatingTags = false; + } + }
    @@ -185,21 +276,42 @@ {#if !meetings.selected}

    Select a meeting to tag it.

    {:else} - e.key === "Enter" && saveTags()} - /> +
    + {#each pendingTags as t (t)} + removeTag(t)} + onClick={() => meetings.filterByTag(t)} + /> + {/each} + +
    {#each meetings.allTags as t (t)} {/each} - +
    + + +
    + {#if tagGenError} +

    {tagGenError}

    + {/if} {/if}

    @@ -275,11 +387,15 @@ {#if !meetings.selected}

    Select a meeting to link it to a calendar event.

    {:else} +
    + + +