Feature chore bug 002 #16
@@ -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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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<CaptureHandle, AudioError>;
|
||||
/// 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<CaptureHandle, AudioError>;
|
||||
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
||||
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
||||
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
|
||||
}
|
||||
// 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 {
|
||||
|
||||
Generated
+1
@@ -5980,6 +5980,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"docx-rs",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+387
-24
@@ -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<CaptureHandle, AudioError>;
|
||||
/// 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<CaptureHandle, AudioError>;
|
||||
@@ -92,15 +107,67 @@ pub trait AudioCapture: Send + Sync {
|
||||
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
|
||||
}
|
||||
|
||||
/// 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<Vec<AudioDeviceInfo>, 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<Vec<AudioDeviceInfo>, 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<Vec<AudioDeviceInfo>, 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<CaptureHandle, AudioError> {
|
||||
@@ -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<CaptureHandle, AudioError> {
|
||||
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<CaptureHandle, AudioError> {
|
||||
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<CaptureSession, AudioError> {
|
||||
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<wasapi::Device, AudioError> {
|
||||
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<CaptureSession, AudioError> {
|
||||
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<Vec<f32>, 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<f32>,
|
||||
mic: std::collections::VecDeque<f32>,
|
||||
}
|
||||
|
||||
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<f32> {
|
||||
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::<Vec<f32>>(8);
|
||||
let (mic_tx, mic_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(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<Vec<f32>>,
|
||||
mic_rx: std::sync::mpsc::Receiver<Vec<f32>>,
|
||||
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::<Vec<f32>>(64);
|
||||
let (event_tx, _event_rx) = std::sync::mpsc::sync_channel::<CaptureEvent>(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(&[]);
|
||||
|
||||
+423
-16
@@ -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<ImportedEvent> {
|
||||
let mut description = None;
|
||||
let mut starts_at = None;
|
||||
let mut ends_at = None;
|
||||
let mut rrule: Option<String> = None;
|
||||
let mut attendees: Vec<AttendeeInfo> = Vec::new();
|
||||
|
||||
for line in &lines {
|
||||
@@ -217,22 +225,49 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
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<ImportedEvent> {
|
||||
"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<ImportedEvent> {
|
||||
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<usize>,
|
||||
until: Option<i64>,
|
||||
byday: Vec<u32>, // weekday indices, 0=SU..6=SA
|
||||
bymonthday: Vec<u32>, // 1..31
|
||||
bymonth: Vec<u32>, // 1..12
|
||||
}
|
||||
|
||||
fn weekday_code_to_index(code: &str) -> Option<u32> {
|
||||
// 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<Rrule> {
|
||||
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::<u32>().ok())),
|
||||
"BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::<u32>().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<i64> {
|
||||
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::<std::collections::HashSet<_>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+164
-13
@@ -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::<Vec<f32>>(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::<crate::audio::CaptureEvent>(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<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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<Vec<crate::audio::AudioDeviceInfo>> {
|
||||
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<Vec<crate::audio::AudioDeviceInfo>> {
|
||||
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<Vec<String>> {
|
||||
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<String>,
|
||||
) -> WaResult<u32> {
|
||||
@@ -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<String>,
|
||||
) -> WaResult<u32> {
|
||||
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
|
||||
|
||||
@@ -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::CaptureHandle>,
|
||||
/// 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,
|
||||
|
||||
@@ -42,6 +42,10 @@ pub type TokenSink = std::sync::mpsc::Sender<String>;
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
async fn status(&self) -> LlmStatus;
|
||||
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError>;
|
||||
/// 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<Vec<String>, 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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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<serde_json::Value> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Vec<String>, 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<Vec<String>, 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::<OpenAiChatChunk>(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<Vec<String>, 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::<Vec<_>>()
|
||||
.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 "<digits>." / "<digits>)",
|
||||
// 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.
|
||||
|
||||
@@ -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<u32>,
|
||||
pub retention_max_size_gb: Option<u32>,
|
||||
// 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<String>,
|
||||
#[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<String>,
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
/// 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) ----
|
||||
|
||||
@@ -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<CalendarEventDetail, StoreError>;
|
||||
/// 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<String> =
|
||||
sqlx::query_scalar("SELECT id FROM calendar_events WHERE id = ?")
|
||||
let row: Option<(String, Option<String>)> =
|
||||
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();
|
||||
|
||||
@@ -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<AppInfo>("app_info"),
|
||||
openUrl: (url: string) => invoke<void>("open_url", { url }),
|
||||
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
|
||||
listAudioDevices: () => invoke<AudioDeviceInfo[]>("list_audio_devices"),
|
||||
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
|
||||
setPreferredBackend: (backend: BackendId | "auto") =>
|
||||
invoke<void>("set_preferred_backend", { args: { backend } }),
|
||||
downloadNpuPackage: () => invoke<void>("download_npu_package"),
|
||||
@@ -394,6 +409,7 @@ export const api = {
|
||||
invoke<void>("generate_summary", { meetingId, templateId }),
|
||||
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
|
||||
invoke<void>("confirm_action_items", { meetingId, items }),
|
||||
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
|
||||
|
||||
importPst: (path: string, password?: string) => invoke<number>("import_pst", { path, password }),
|
||||
listCalendarEvents: (from?: number, to?: number) =>
|
||||
@@ -402,6 +418,8 @@ export const api = {
|
||||
invoke<CalendarEventDetail>("get_calendar_event", { eventId }),
|
||||
attachMeetingToEvent: (meetingId: MeetingId, eventId: string) =>
|
||||
invoke<void>("attach_meeting_to_event", { meetingId, eventId }),
|
||||
renameMeeting: (meetingId: MeetingId, title: string) =>
|
||||
invoke<void>("rename_meeting", { meetingId, title }),
|
||||
renameSpeaker: (meetingId: MeetingId, label: string, name: string) =>
|
||||
invoke<void>("rename_speaker", { meetingId, label, name }),
|
||||
mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) =>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
// GitHub-topic-style pill (T8.3, FR-SEARCH-2). Clicking the label filters
|
||||
// the meeting list to this tag; the optional 'x' removes it from whatever
|
||||
// list it's rendered in (not a global delete — the caller decides what
|
||||
// "remove" means).
|
||||
import { X } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
let { tag, removable = false, onRemove, onClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="chip" class:clickable={!!onClick}>
|
||||
<button
|
||||
type="button"
|
||||
class="label"
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
title={onClick ? `Filter meetings tagged "${tag}"` : undefined}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{#if removable}
|
||||
<button type="button" class="remove" onclick={onRemove} aria-label={`Remove tag ${tag}`}>
|
||||
<X size={10} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.label {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0.15rem 0.65rem;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: default;
|
||||
}
|
||||
.label:disabled {
|
||||
opacity: 1; /* a non-clickable chip should still read as fully legible */
|
||||
}
|
||||
.chip.clickable .label {
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.clickable .label:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.label:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.remove {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
margin: 0 0.3rem 0 -0.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
opacity 150ms ease-out;
|
||||
}
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.remove:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -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<CalendarEvent[]>([]);
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HardwareStatus | null>(null);
|
||||
models = $state<ModelInfo[]>([]);
|
||||
audioDevices = $state<AudioDeviceInfo[]>([]);
|
||||
inputDevices = $state<AudioDeviceInfo[]>([]);
|
||||
downloadProgress = $state<Record<string, { received: number; total: number | null }>>({});
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
<h4>Audio Devices</h4>
|
||||
<label
|
||||
>Recording device
|
||||
<select
|
||||
value={settings.settings.audio_output_device ?? ""}
|
||||
onchange={(e) =>
|
||||
settings.setAudioOutputDevice((e.target as HTMLSelectElement).value || null)}
|
||||
>
|
||||
<option value="">Default system audio</option>
|
||||
{#each settings.audioDevices as d (d.id)}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<label
|
||||
>Microphone
|
||||
<select
|
||||
value={!settings.settings.microphone_enabled
|
||||
? "off"
|
||||
: (settings.settings.audio_input_device ?? "")}
|
||||
onchange={(e) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
if (v === "off") settings.setMicrophone(false, null);
|
||||
else settings.setMicrophone(true, v || null);
|
||||
}}
|
||||
>
|
||||
<option value="off">Off — don't capture my microphone</option>
|
||||
<option value="">Default microphone</option>
|
||||
{#each settings.inputDevices as d (d.id)}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{#if settings.hardware.npu?.present}
|
||||
{@const npu = settings.hardware.npu}
|
||||
{@const ready = npu.runtimeReady && npu.modelInstalled}
|
||||
@@ -697,6 +773,18 @@
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.settings.pst_auto_sync}
|
||||
onchange={onToggleAutoSync}
|
||||
/>
|
||||
<span>Re-import this file automatically on launch</span>
|
||||
</label>
|
||||
<p class="muted small">
|
||||
Runs once at startup, not on a timer — re-import is safe to repeat (existing events are
|
||||
matched and updated, not duplicated).
|
||||
</p>
|
||||
{#if calendar.importError}
|
||||
<p class="error">
|
||||
Import failed: {calendar.importError} — the file itself is untouched; check the path and try
|
||||
@@ -708,8 +796,21 @@
|
||||
{#if calendar.events.length === 0}
|
||||
<p class="muted">No events imported yet.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>Search title
|
||||
<input type="text" bind:value={eventTitleFilter} placeholder="Meeting name…" />
|
||||
</label>
|
||||
<label
|
||||
>Date
|
||||
<input type="date" bind:value={eventDateFilter} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted">
|
||||
{filteredEvents.length} of {calendar.events.length} events
|
||||
</p>
|
||||
<ul class="events">
|
||||
{#each calendar.events as ev (ev.id)}
|
||||
{#each filteredEvents as ev (ev.id)}
|
||||
<li>
|
||||
<span class="name">{ev.subject ?? "(untitled)"}</span>
|
||||
<span class="muted">{formatEventDate(ev.starts_at)}</span>
|
||||
|
||||
@@ -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<string | null>(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<string[]>([]);
|
||||
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<string | null>(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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
@@ -185,21 +276,42 @@
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to tag it.</p>
|
||||
{:else}
|
||||
<input
|
||||
class="grow"
|
||||
list="known-tags"
|
||||
placeholder="project, client, topic…"
|
||||
bind:value={tagsInput}
|
||||
onkeydown={(e) => e.key === "Enter" && saveTags()}
|
||||
/>
|
||||
<div class="tag-editor">
|
||||
{#each pendingTags as t (t)}
|
||||
<TagChip
|
||||
tag={t}
|
||||
removable
|
||||
onRemove={() => removeTag(t)}
|
||||
onClick={() => meetings.filterByTag(t)}
|
||||
/>
|
||||
{/each}
|
||||
<input
|
||||
class="tag-input"
|
||||
list="known-tags"
|
||||
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
|
||||
bind:value={tagDraft}
|
||||
oninput={onTagInput}
|
||||
onkeydown={onTagInputKeydown}
|
||||
onblur={commitDraft}
|
||||
/>
|
||||
</div>
|
||||
<datalist id="known-tags">
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<button class="link" onclick={saveTags} disabled={savingTags}>
|
||||
{savingTags ? "Saving…" : "Save tags"}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{generatingTags ? "Generating…" : "Generate tags"}
|
||||
</button>
|
||||
<button class="link" onclick={saveTags} disabled={savingTags}>
|
||||
{savingTags ? "Saving…" : "Save tags"}
|
||||
</button>
|
||||
</div>
|
||||
{#if tagGenError}
|
||||
<p class="error">{tagGenError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
||||
@@ -275,11 +387,15 @@
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to link it to a calendar event.</p>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<input type="text" bind:value={eventLinkSearch} placeholder="Search event title…" />
|
||||
<input type="date" bind:value={eventLinkDateFilter} />
|
||||
</div>
|
||||
<label class="pick"
|
||||
>Linked event
|
||||
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
|
||||
<option value="" disabled>{eventDetail ? "Change event…" : "Link an event…"}</option>
|
||||
{#each calendar.events as ev (ev.id)}
|
||||
{#each filteredLinkEvents as ev (ev.id)}
|
||||
<option value={ev.id}
|
||||
>{ev.subject ?? "(untitled)"} — {formatEventTime(ev.starts_at)}</option
|
||||
>
|
||||
@@ -290,6 +406,8 @@
|
||||
<p class="muted small">
|
||||
No events imported yet — import a <code>.pst</code> from Settings → Calendar.
|
||||
</p>
|
||||
{:else if filteredLinkEvents.length === 0}
|
||||
<p class="muted small">No events match this search/date — try clearing one.</p>
|
||||
{/if}
|
||||
|
||||
{#if eventDetail}
|
||||
@@ -400,6 +518,30 @@
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
.tag-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.tag-input {
|
||||
flex: 1;
|
||||
min-width: 6rem;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.tag-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.muted.small {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Recordings default to "Untitled meeting" (T2.2) — this is the only
|
||||
// rename affordance, since nothing else in the UI shows the title at all.
|
||||
async function onTitleChange(e: Event) {
|
||||
const m = meetings.selected;
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
if (!m) return;
|
||||
if (!value) {
|
||||
(e.target as HTMLInputElement).value = m.title; // revert an empty edit
|
||||
return;
|
||||
}
|
||||
if (value !== m.title) await meetings.renameMeeting(m.id, value);
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
const id = meetings.selected?.id;
|
||||
if (!id) return;
|
||||
@@ -128,6 +141,12 @@
|
||||
<div class="wrap">
|
||||
{#if meetings.selected}
|
||||
{@const m = meetings.selected}
|
||||
<input
|
||||
class="meeting-title"
|
||||
value={m.title}
|
||||
onchange={onTitleChange}
|
||||
aria-label="Meeting title"
|
||||
/>
|
||||
<div class="split">
|
||||
<div class="pane transcript">
|
||||
<h4><MessageSquareText size={14} aria-hidden="true" /> Transcript</h4>
|
||||
@@ -229,6 +248,22 @@
|
||||
<style>
|
||||
.wrap {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.meeting-title {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
color: inherit;
|
||||
}
|
||||
.meeting-title:hover,
|
||||
.meeting-title:focus {
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
}
|
||||
.pad {
|
||||
padding: 1rem;
|
||||
@@ -249,7 +284,8 @@
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.pane {
|
||||
overflow: auto;
|
||||
|
||||
Reference in New Issue
Block a user