Feature chore bug 009 #24

Merged
iamdoubz merged 39 commits from feature_chore_bug_009 into main 2026-07-15 18:13:11 -05:00
24 changed files with 2279 additions and 261 deletions
+94
View File
@@ -0,0 +1,94 @@
# WhispAssist v0.7.1
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
A quality-of-life release focused on **importing meetings, taking notes, and living in the
background**. Adding a meeting from a file or link now runs without freezing the app and shows you
exactly where it's up to; notes gained AI cleanup and quick formatting; and WhispAssist can now mute
your mic mid-meeting and tuck itself into the system tray. No feature here changes the privacy
posture: everything optional stays off-by-default and local-first.
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
---
## ✨ New
### Add a meeting — now in the background, with a progress tracker
Importing a recording (a local audio/video file or a YouTube/streaming/direct URL) no longer blocks
the app while it works. Click **Import** and the meeting appears in your list immediately with a
**four-step progress tracker***Transcode → Transcribe → Identify speakers → Finalize* — where the
current step pulses and finished steps show how long they took. A 25-minute video that used to lock
the window for ~13 minutes now transcribes quietly in the background.
- **Pick the transcription model** right in the dialog, and see **"Transcribed with …"** on the
finished meeting so you always know how it was produced.
- **One-click links** to download `ffmpeg` and `yt-dlp` (still external, not bundled).
- A failed import stays in your list marked **error** instead of vanishing.
### Better notes
- **AI-enhance** (✨): turn rough notes into clean, structured notes using your local LLM, with a
one-step **Undo**. Off unless you have a local model configured.
- **Slash commands & a formatting toolbar**: type `/todo`, `/h1`, `/quote`, … or use the toolbar for
headings, lists, checkboxes, quotes, and dividers.
- **Fix:** notes no longer show stale text after re-transcribing a meeting — the pane updates in
place, no restart needed.
### Mute your microphone — press **M**
Mute/unmute the mic mid-meeting with the **M** key or the new mic button by the level meter. The mic
channel goes silent (recording, live transcript, and meter) while system/loopback audio keeps
capturing.
### Close to system tray
Closing the window now **keeps WhispAssist running in the background** instead of quitting. Reopen it
from the tray icon; the tray's **Quit** exits fully. On by default — toggle it in
**Settings ▸ Recording ▸ Close to system tray**. (This release also fixes a bug that showed **two**
WhispAssist icons in the tray — there's now just one.)
### Privacy & hardware odds and ends
- **Vault lock card** in **Settings ▸ Privacy**: lock/unlock the encrypted store and change its
password at a glance.
- **Test your audio devices**: a live level meter for your mic and system audio, plus a test tone.
- **Quick hardware stress test**: benchmark the available backends against your installed models and
apply the fastest real-time combination.
- Tidier recording header.
---
## 📦 Install
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
(and **`yt-dlp`** for URLs) on your PATH — the Import dialog now links to both.
1. Download **`WhispAssist_0.7.1_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.7.1_x64-setup.exe`**).
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
3. Launch **WhispAssist** from the Start menu.
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
without admin rights, does **not** add itself to startup unless you opt in, and keeps all data under
`%LOCALAPPDATA%\WhispAssist`.
Deploying to many machines? See [`docs/enterprise-deployment.md`](docs/enterprise-deployment.md).
## 🔐 Checksums (SHA-256)
```
bd96a059db3658a9bee81161edc709cfc4741ed99d6c66a12194403b339f9369 WhispAssist_0.7.1_x64_en-US.msi
711fa7df618c8fc3f03c18d543abad2288d56f750b532288204e0ec0f9426595 WhispAssist_0.7.1_x64-setup.exe
```
Verify after download:
```powershell
Get-FileHash .\WhispAssist_0.7.1_x64_en-US.msi -Algorithm SHA256
```
---
## Privacy, unchanged
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
MCP server is loopback-only and adds no egress; the deployment file never carries secrets. The
reachable-host allowlist is derived from your settings and enforced in the core.
+3
View File
@@ -386,6 +386,9 @@ segment (the M1 grounding invariant, asserted by the golden-transcript test).
// via `set_auto_start` also writes a per-user `HKCU\...\Run` entry (no admin);
// startup reconciles the OS entry to this flag (e.g. after a reinstall).
"auto_start": false,
// Closing the window hides WhispAssist to the system tray (keep running in background) instead of
// quitting; ON by default. Tray "Quit" is the real exit. Enforced in the Rust on_window_event handler.
"close_to_tray": true,
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6). Opt-in, explicit consent via OAuth
// PKCE — OFF by default. `credential_ref` points into the OS credential store; the token itself
// is never written here (same invariant as sync credentials, FR-SYNC-6).
+20
View File
@@ -23,6 +23,9 @@ start_recording(input: { meetingTitle?: string; calendarEventId?: string; record
stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef
pause_recording(input: { meetingId: MeetingId }): void
resume_recording(input: { meetingId: MeetingId }): void
// Mute/unmute the mic mid-meeting (FR-CAP-7): mic channel goes silent (recording + transcript + meter),
// loopback keeps capturing. Returns the new muted state; emits recording://mic. Errs if the mic is off.
toggle_microphone_mute(input: { meetingId: MeetingId }): boolean
set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1)
acknowledge_recording_consent(): void // one-time (FR-REC-2)
@@ -40,6 +43,10 @@ hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: stri
set_preferred_backend(input: { backend: BackendId | "auto" }): void
// Launch-at-login (NFR-RES-4). Writes/removes a per-user OS Run entry (no admin) and persists auto_start. Opt-in, off by default.
set_auto_start(input: { enabled: boolean }): void
// Device test: opens a mic ("input") or the render device in loopback ("loopback") for a few seconds and streams device://level (no recording, no retained audio). Refused while recording.
monitor_audio_level(input: { kind: "input" | "loopback"; deviceId?: string; durationMs?: number }): void
// Quick stress test: benchmarks each available backend × installed model (≤3 sizes) on a fixed sample, returns per-pair real-time factor + the most-accurate real-time-capable recommendation. Emits stress://progress. Refused while recording.
stress_test_hardware(): { results: { backend: string; model: string; rtf: number; realtime: boolean }[]; recommended: { backend: string; model: string } | null }
// ---- Transcription / models ----
// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it.
@@ -81,6 +88,8 @@ export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf"
// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an
// edit that didn't alter a file uploads nothing.
update_notes(input: { meetingId: MeetingId; markdown: string }): void
// AI-enhance rough notes into structured Markdown grounded in the transcript (Granola-style), via the configured LlmProvider (no new egress). Takes the live buffer, returns the enhanced text WITHOUT persisting — the UI keeps or undoes it. Refused while recording; errors with no provider.
enhance_notes(input: { meetingId: MeetingId; notes: string }): string
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
set_tags(input: { meetingId: MeetingId; tags: string[] }): void
@@ -95,6 +104,13 @@ bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "
// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original
// title/date/duration/speakers/tags/action items preserved). Returns the count imported.
import_meeting_bundle(input: { dir: string }): number
// Add a meeting from an existing recording: a local audio/video file path or a URL (YouTube/
// streaming page or direct media URL). Needs ffmpeg (+ yt-dlp for URLs) on PATH; neither bundled.
// `model` overrides the Settings whisper model for this import (recorded as meeting.model_used).
// Returns the new meeting id IMMEDIATELY (status "transcribing"); transcode→transcribe→diarize→
// finalize run in the background, streaming import://progress and ending with transcript://finalized.
// A failed import is left in the list with status "error" (not deleted).
import_media(input: { source: string; title?: string; model?: string }): MeetingId
// ---- LLM / AI provider (ADR-0007/0011) ----
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
@@ -185,8 +201,10 @@ privacy_self_check(): {
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
"recording://mic" { meetingId, muted: boolean } // mic mute toggled (FR-CAP-7)
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2); may re-emit a committed segment with a refined `speaker` — replace by `segment.id`
"transcript://finalized" { meetingId, segmentCount }
"import://progress" { meetingId, phase: "prepare"|"transcribe"|"diarize"|"finalize", state: "active"|"done"|"error", elapsedMs: number|null, error: string|null } // background import_media tracker
"diarization://updated" { meetingId, speakers: SpeakerInfo[] } // post-pass AND live 15s provisional passes (FR-SPK); carries "You" once the mic voiceprint matches
"llm://token" { meetingId, text } // streamed summary (FR-LLM-4)
"llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer
@@ -195,6 +213,8 @@ privacy_self_check(): {
"calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4)
"calendar://progress" { processed, total } // MS Graph import (M4.4)
"hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4)
"device://level" { kind: "input"|"loopback", rms?, peak?, done?: boolean } // Settings device test meter; done=window ended
"stress://progress" { backend: string, model: string } // quick stress test, per pairing benchmarked
"recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3)
"sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5
"sync://done" { meetingId, targetId, uploaded: number, failed: number }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "whispassist",
"private": true,
"version": "0.7.0",
"version": "0.7.2",
"type": "module",
"description": "Privacy-first, fully local Windows meeting assistant.",
"license": "MIT OR Apache-2.0",
+1 -1
View File
@@ -6088,7 +6088,7 @@ dependencies = [
[[package]]
name = "whispassist"
version = "0.7.0"
version = "0.7.2"
dependencies = [
"argon2",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "whispassist"
version = "0.7.0"
version = "0.7.2"
description = "Privacy-first, fully local Windows meeting assistant"
authors = ["WhispAssist contributors"]
license = "MIT OR Apache-2.0"
+32 -1
View File
@@ -45,9 +45,26 @@ pub enum AudioError {
pub struct CaptureHandle {
running: Arc<AtomicBool>,
paused: Arc<AtomicBool>,
/// Mic-only (FR-CAP-7): when set, the microphone stream emits silence instead
/// of real samples — the recording's mic-left channel and the live transcript
/// go quiet, the meter drops to zero, while loopback keeps recording. Toggled
/// live via `set_muted` (the "press M to mute" control).
muted: Arc<AtomicBool>,
thread: JoinHandle<Result<CaptureSummary, AudioError>>,
}
impl CaptureHandle {
/// Mute/unmute this stream live. Only meaningful for the microphone capture.
pub fn set_muted(&self, muted: bool) {
self.muted.store(muted, Ordering::SeqCst);
}
/// Whether this stream is currently muted.
pub fn is_muted(&self) -> bool {
self.muted.load(Ordering::SeqCst)
}
}
/// Where captured frames are delivered for live transcription: mono f32 @ 16kHz,
/// bounded so a slow/absent consumer can never stall the capture thread.
pub type FrameSink = SyncSender<Vec<f32>>;
@@ -317,8 +334,10 @@ impl WasapiCapture {
) -> Result<CaptureHandle, AudioError> {
let running = Arc::new(AtomicBool::new(true));
let paused = Arc::new(AtomicBool::new(false));
let muted = Arc::new(AtomicBool::new(false));
let running_th = running.clone();
let paused_th = paused.clone();
let muted_th = muted.clone();
let wav_path = wav_path.map(Path::to_path_buf);
let device_id = device_id.map(str::to_string);
@@ -333,6 +352,7 @@ impl WasapiCapture {
&event_sink,
&running_th,
&paused_th,
&muted_th,
bridge.as_ref(),
voice_sample.as_ref(),
split,
@@ -344,6 +364,7 @@ impl WasapiCapture {
Ok(CaptureHandle {
running,
paused,
muted,
thread,
})
}
@@ -610,6 +631,9 @@ fn capture_loop(
event_sink: &EventSink,
running: &AtomicBool,
paused: &AtomicBool,
// Mic-only live mute (FR-CAP-7): zeroes the decoded mic samples so the
// recording, transcript, and meter all go silent while loopback continues.
muted: &AtomicBool,
bridge: Option<&Arc<MicBridge>>,
voice_sample: Option<&Arc<VoiceSample>>,
// FR-SPK: when true, the loopback WAV is stereo L=mic / R=loopback (the mic
@@ -762,7 +786,14 @@ fn capture_loop(
write_wav_bytes(w, &bytes, &session.format, &mic)?
};
}
let mono = decode_mono_f32(&bytes, &session.format)?;
let mut mono = decode_mono_f32(&bytes, &session.format)?;
// Mic muted: replace the decoded samples with silence before anything
// downstream sees them — the recording's mic channel, the bridge, the
// transcript feed, the meter, and the voiceprint sample all go quiet.
// Loopback (`is_loopback`) is never muted this way.
if !is_loopback && muted.load(Ordering::Relaxed) {
mono.iter_mut().for_each(|s| *s = 0.0);
}
// Mic: feed the shared bridge (resampled to the loopback's rate) so the
// loopback thread can fold it into the recording.
+670 -80
View File
@@ -88,6 +88,7 @@ pub(crate) fn default_settings() -> Settings {
mcp_expose: "none".into(),
mcp_expose_recordings: false,
auto_start: false,
close_to_tray: true,
}
}
@@ -119,6 +120,32 @@ fn model_id_for(settings: &Settings) -> String {
}
}
/// The model id to *report* for a session on `backend`: the ONNX engine
/// (NPU/DirectML) ignores the configured ggml model and always runs its own
/// ONNX artifacts, so recording the ggml id would be a lie — e.g. a meeting
/// shown as "medium.en-q5_0 · npu" actually transcribed with ONNX base.en.
/// Mirrors the exact routing condition in `load_transcriber`; if the engine
/// fails to load at runtime the worker falls back to whisper.cpp and the
/// backend is corrected via `hardware://changed`, but this reported model
/// isn't — acceptable for that rare failure path.
fn effective_model_id(backend: BackendId, requested: &str) -> String {
#[cfg(feature = "npu")]
{
use crate::hardware::{resolve_accel, AccelPath};
use crate::transcription::onnx_models;
if matches!(
resolve_accel(backend),
AccelPath::OnnxOpenVino | AccelPath::OnnxDirectML
) && onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL)
{
return format!("{} (onnx)", onnx_models::DEFAULT_ONNX_MODEL);
}
}
#[cfg(not(feature = "npu"))]
let _ = backend;
requested.to_string()
}
/// Picks the backend to transcribe with: "low overhead" always forces CPU;
/// otherwise resolve the user's preferred backend (or auto-detect) against
/// what's actually available (T3.2, T3.5).
@@ -165,7 +192,22 @@ fn load_transcriber(
if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) {
let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL);
match OnnxTranscriber::load(&dir, backend, language) {
Ok(t) => return Ok((Box::new(t), backend)),
Ok(t) => {
// The definitive "is the accelerator actually engaged" line
// (visible with `npm run tauri dev`): the encoder runs on
// the EP below; the autoregressive decoder always runs on
// the CPU EP, which is why Task Manager shows only short
// periodic NPU spikes alongside sustained CPU load.
tracing::info!(
"transcription engine: ONNX {} (encoder EP: {}, decoder: CPU)",
onnx_models::DEFAULT_ONNX_MODEL,
match path {
AccelPath::OnnxOpenVino => "OpenVINO/NPU",
_ => "DirectML/GPU",
}
);
return Ok((Box::new(t), backend));
}
Err(e) => tracing::warn!("ONNX engine load failed ({e}); falling back to CPU"),
}
} else {
@@ -256,7 +298,8 @@ fn diarizer_from_installed_models() -> Option<SherpaDiarizer> {
/// Split attribution (FR-SPK, supersedes `phase3_attribute`): diarize the far
/// side (right/loopback channel) into `Speaker N`, take "You" straight from
/// left-channel (mic) voice activity, merge + assign + name. Shared by
/// left-channel (mic) voice activity, attribute per channel
/// (`diarization::assign_split`) + name. Shared by
/// `stop_recording` and `reprocess_transcript`; both read it back from the file
/// so they agree. `None` if the far-side pass fails (caller falls back).
async fn attribute_split(
@@ -267,16 +310,19 @@ async fn attribute_split(
let diarizer_for_task = diarizer.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
// Right channel = loopback/far side; diarize it alone (mic never in it).
// Its VAD is the "was the far side talking at all" evidence assign_split
// weighs against the mic channel.
let far = crate::audio::read_wav_channel_16k(&wav_path, 1).map_err(|e| e.to_string())?;
let far_vad = crate::audio::vad_spans(&far);
let far_spans = diarizer_for_task
.diarize_samples(far)
.map_err(|e| e.to_string())?;
// Left channel = mic; its voice activity is "You".
let mic = crate::audio::read_wav_channel_16k(&wav_path, 0).map_err(|e| e.to_string())?;
Ok::<_, String>((far_spans, crate::audio::vad_spans(&mic)))
Ok::<_, String>((far_spans, far_vad, crate::audio::vad_spans(&mic)))
})
.await;
let (far_spans, you_spans) = match result {
let (far_spans, far_vad, you_spans) = match result {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
tracing::warn!("split attribution failed: {e}");
@@ -287,6 +333,8 @@ async fn attribute_split(
return None;
}
};
crate::diarization::assign_split(segments, &you_spans, &far_vad, &far_spans);
// Merged span timeline, only for first-appearance naming order below.
let mut spans: Vec<SpeakerSpan> = you_spans
.iter()
.map(|&(start_ms, end_ms)| SpeakerSpan {
@@ -297,7 +345,6 @@ async fn attribute_split(
.collect();
spans.extend(far_spans);
spans.sort_by_key(|s| s.start_ms);
diarizer.assign(segments, &spans);
// Uniform naming: "You" -> "You", far speakers -> "Speaker 2", "Speaker 3"….
let labels = crate::diarization::voiceprint::first_appearance_order(&spans);
Some(crate::diarization::voiceprint::build_name_map(&labels, "You"))
@@ -573,7 +620,8 @@ pub async fn start_recording(
let wav_path_for_diar = wav_path.clone();
let segments_for_diar = segments.clone();
let names_for_diar = speaker_names.clone();
let voice_sample_for_diar = mic_voice_sample.clone();
// Same condition as `audio_layout` below: mic on → split stereo file.
let split_layout = settings.microphone_enabled;
tauri::async_runtime::spawn(async move {
// ponytail: reprocesses the whole recording-so-far each tick
// rather than incremental/windowed segmentation — sherpa-onnx's
@@ -596,56 +644,83 @@ pub async fn start_recording(
break; // recording stopped (or a new one started) — nothing left to do
}
let diarizer_for_pass = diarizer.clone();
let wav_path = wav_path_for_diar.clone();
let spans = tauri::async_runtime::spawn_blocking(move || {
diarizer_for_pass.diarize(&wav_path)
})
.await;
let spans = match spans {
Ok(Ok(spans)) => spans,
Ok(Err(e)) => {
tracing::warn!("live diarization pass failed: {e}");
continue;
// Split recording: the exact same channel-based attribution as
// the post-stop pass — mic (left) is always "You", the far side
// (right) is diarized alone. The old whole-mix pass + voiceprint
// cosine match never reliably showed "You" live (clusters over
// the summed mix reshuffle every tick and the match often missed
// its threshold), so "You" only appeared after stop.
let (speakers, changed) = if split_layout {
let mut snapshot = segments_for_diar
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if attribute_split(
diarizer.clone(),
wav_path_for_diar.clone(),
&mut snapshot,
)
.await
.map(|auto_names| {
let mut names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
// Never overwrite a name already set (user rename or a
// prior pass) — same guard as the post-stop pass.
for (label, name) in auto_names {
names.entry(label).or_insert(name);
}
})
.is_none()
{
continue; // pass failed (already logged); retry next tick
}
Err(e) => {
tracing::warn!("live diarization task failed: {e}");
continue;
}
};
let (speakers, changed) = {
let relabeled: HashMap<u64, String> = snapshot
.into_iter()
.map(|s| (s.id, s.speaker))
.collect();
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
// Snapshot prior labels so only segments whose speaker
// actually changed this pass get re-emitted (ids are stable;
// the frontend replaces by id).
// the frontend replaces by id). Segments committed while the
// pass ran keep their placeholder until the next tick.
let before: HashMap<u64, String> =
segs.iter().map(|s| (s.id, s.speaker.clone())).collect();
for seg in segs.iter_mut() {
if let Some(speaker) = relabeled.get(&seg.id) {
seg.speaker = speaker.clone();
}
}
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
let changed: Vec<TranscriptSegment> = segs
.iter()
.filter(|s| before.get(&s.id) != Some(&s.speaker))
.cloned()
.collect();
(speaker_infos_from_segments(&segs, &names), changed)
} else {
// Summed recording (mic off): whole-signal pass. No mic
// channel → no live "You" (the mic voice sample doesn't
// exist either), matching the post-stop behavior.
let diarizer_for_pass = diarizer.clone();
let wav_path = wav_path_for_diar.clone();
let spans = tauri::async_runtime::spawn_blocking(move || {
diarizer_for_pass.diarize(&wav_path)
})
.await;
let spans = match spans {
Ok(Ok(spans)) => spans,
Ok(Err(e)) => {
tracing::warn!("live diarization pass failed: {e}");
continue;
}
Err(e) => {
tracing::warn!("live diarization task failed: {e}");
continue;
}
};
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
let before: HashMap<u64, String> =
segs.iter().map(|s| (s.id, s.speaker.clone())).collect();
diarizer.assign(&mut segs, &spans);
// Live "You": match the mic voiceprint against this pass's
// clusters. Clusters re-shuffle every tick so match every
// tick; never overwrite a name already set (user rename or a
// prior pass) — same guard as the post-stop pass.
if let Some(voice_sample) = &voice_sample_for_diar {
let mic_samples = voice_sample.samples();
match crate::diarization::voiceprint::match_mic_speaker(
&diarization_embedding_model_file(),
&mic_samples,
&wav_path_for_diar,
&spans,
) {
Ok(auto_names) => {
let mut names =
names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
for (label, name) in auto_names {
names.entry(label).or_insert(name);
}
}
Err(e) => tracing::warn!("live voiceprint match failed: {e}"),
}
}
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
let changed: Vec<TranscriptSegment> = segs
.iter()
@@ -681,7 +756,10 @@ pub async fn start_recording(
transcription_worker,
segments,
active_backend,
model_id,
// Report the model the routed engine will actually run (the ONNX
// engine ignores the configured ggml model), so the meeting's
// "transcribed with … · npu" line is truthful (T3.4/T3.5).
model_id: effective_model_id(backend, &model_id),
language: language_state,
diarizer,
speaker_names,
@@ -760,9 +838,17 @@ pub async fn stop_recording(
let mut attributed = false;
if session.audio_layout == "split" {
if let Some(diarizer) = session.diarizer.clone() {
if let Some(names) =
if let Some(mut names) =
attribute_split(diarizer, session.wav_path.clone(), &mut segments).await
{
// Renames made *during* the recording are user-authored — they
// win over the automatic "You"/"Speaker N" defaults instead of
// being silently dropped at finalize.
if let Ok(user_names) = session.speaker_names.lock() {
for (label, name) in user_names.iter() {
names.insert(label.clone(), name.clone());
}
}
speaker_names = names;
attributed = true;
}
@@ -1048,6 +1134,43 @@ pub async fn resume_recording(
Ok(())
}
/// Toggle the microphone mute state for the active recording (FR-CAP-7): the
/// mic channel goes silent (recording + live transcript + meter) while loopback
/// keeps capturing. Bound to the "M" key in the UI. Returns the new muted state.
/// Errors if this meeting was started with the mic off (nothing to mute).
#[tauri::command]
pub async fn toggle_microphone_mute(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<bool> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
let mic = session
.mic_capture
.as_ref()
.ok_or_else(|| WaError::new("audio", "the microphone is off for this meeting"))?;
let muted = !mic.is_muted();
mic.set_muted(muted);
drop(guard);
let _ = app.emit(
"recording://mic",
serde_json::json!({ "meetingId": meeting_id, "muted": muted }),
);
crate::update_tray_tooltip(
&app,
if muted {
"WhispAssist — recording (mic muted)"
} else {
"WhispAssist — recording"
},
);
Ok(muted)
}
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
#[tauri::command]
pub async fn set_recording_retention(
@@ -1222,6 +1345,55 @@ async fn refresh_notes_and_notify(
Ok(meeting.speakers)
}
/// `notes.md` bakes display names into its `**Name:**` dialogue tags at
/// finalize, so a post-meeting rename must rewrite them or the Notes pane
/// (and every export) keeps the old name forever. Targeted tag replace, not
/// a regenerate, so the user's own edits to notes.md are preserved.
async fn rename_speaker_in_notes(
state: &State<'_, AppState>,
meeting_id: &MeetingId,
old_name: &str,
new_name: &str,
) -> WaResult<()> {
if old_name == new_name || new_name.is_empty() {
return Ok(());
}
let meeting = state
.store
.get_meeting(meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let old_tag = format!("**{old_name}:**");
if meeting.notes_markdown.contains(&old_tag) {
let updated = meeting
.notes_markdown
.replace(&old_tag, &format!("**{new_name}:**"));
state
.store
.update_notes(meeting_id, &updated)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
Ok(())
}
/// A speaker's current name as notes.md renders it: display name, else the
/// raw label. `None` if the meeting/speaker can't be read (nothing to rewrite).
async fn current_speaker_name(
state: &State<'_, AppState>,
meeting_id: &MeetingId,
label: &str,
) -> Option<String> {
let meeting = state.store.get_meeting(meeting_id).await.ok()?;
let speaker = meeting.speakers.iter().find(|s| s.label == label)?;
Some(
speaker
.display_name
.clone()
.unwrap_or_else(|| label.to_string()),
)
}
/// Name a speaker; applies to that speaker's past & future segments (T4.4,
/// FR-SPK-2). Segments only ever carry the internal label ("S1"…) — never
/// rewritten — so persisting the label→name mapping here is enough to cover
@@ -1236,6 +1408,9 @@ pub async fn rename_speaker(
label: String,
name: String,
) -> WaResult<()> {
// Resolve the name notes.md currently shows *before* the rename lands, so
// the finalized-meeting branch below can rewrite its dialogue tags.
let old_name = current_speaker_name(&state, &meeting_id, &label).await;
state
.store
.rename_speaker(&meeting_id, &label, &name)
@@ -1271,6 +1446,9 @@ pub async fn rename_speaker(
}
None => {
drop(guard);
if let Some(old_name) = old_name {
rename_speaker_in_notes(&state, &meeting_id, &old_name, &name).await?;
}
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
}
}
@@ -1333,12 +1511,22 @@ pub async fn map_speaker_to_participant(
}
drop(guard);
let old_name = current_speaker_name(&state, &meeting_id, &label).await;
state
.store
.map_speaker_to_participant(&meeting_id, &label, &participant_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
let speakers = refresh_notes_and_notify(&app, &state, &meeting_id).await?;
// Rewrite notes.md's baked-in dialogue tags to the participant's name,
// same as a free-text rename (the Notes pane must follow the Speakers pane).
let new_name = speakers
.iter()
.find(|s| s.label == label)
.and_then(|s| s.display_name.clone());
if let (Some(old_name), Some(new_name)) = (old_name, new_name) {
rename_speaker_in_notes(&state, &meeting_id, &old_name, &new_name).await?;
}
Ok(())
}
@@ -1465,6 +1653,218 @@ pub async fn set_auto_start(app: AppHandle, enabled: bool) -> WaResult<()> {
save_settings(&settings)
}
/// Live level meter for a device, without starting a recording (FR-CAP-5): open
/// the selected mic (`"input"`) or the render device in loopback (`"loopback"`)
/// for a few seconds and stream `device://level` events so Settings ▸ Hardware
/// can show whether audio is coming through. Reuses the normal capture path;
/// discards frames (no transcription, no retained audio). One capture at a time,
/// so it refuses while a recording is active.
/// ponytail: transient monitor handle; ceiling = one monitor at a time.
#[tauri::command]
pub async fn monitor_audio_level(
app: AppHandle,
state: State<'_, AppState>,
kind: String,
device_id: Option<String>,
duration_ms: Option<u64>,
) -> WaResult<()> {
use crate::audio::{AudioCapture, CaptureEvent, WasapiCapture};
if state.session.lock().await.is_some() {
return Err(WaError::new(
"audio",
"stop the current recording before testing a device",
));
}
let duration =
std::time::Duration::from_millis(duration_ms.unwrap_or(6000).clamp(1000, 20_000));
// Bounded channels so a slow consumer can't stall capture; frames are dropped.
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<CaptureEvent>(64);
std::thread::spawn(move || frame_rx.into_iter().for_each(drop));
let app_ev = app.clone();
let kind_ev = kind.clone();
std::thread::spawn(move || {
for event in event_rx {
if let CaptureEvent::Level(level) = event {
let _ = app_ev.emit(
"device://level",
serde_json::json!({ "kind": kind_ev, "rms": level.rms, "peak": level.peak }),
);
}
}
});
// `start` (loopback) needs a WAV path; write to a temp file and delete it after.
let tmp_wav = std::env::temp_dir().join(format!("wa-monitor-{}.wav", now_unix()));
let handle = match kind.as_str() {
"input" => WasapiCapture.start_microphone(device_id.as_deref(), frame_tx, event_tx),
"loopback" => WasapiCapture.start(&tmp_wav, device_id.as_deref(), frame_tx, event_tx),
_ => return Err(WaError::new("audio", "kind must be 'input' or 'loopback'")),
}
.map_err(|e| WaError::new("audio", e.to_string()))?;
tauri::async_runtime::spawn_blocking(move || {
std::thread::sleep(duration);
let _ = WasapiCapture.stop(handle);
})
.await
.map_err(|e| WaError::new("audio", e.to_string()))?;
if kind == "loopback" {
let _ = std::fs::remove_file(&tmp_wav);
}
let _ = app.emit(
"device://level",
serde_json::json!({ "kind": kind, "done": true }),
);
Ok(())
}
/// One row of the quick hardware stress test.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StressResult {
pub backend: String,
pub model: String,
pub rtf: f64,
pub realtime: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StressRecommendation {
pub backend: String,
pub model: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StressTestResult {
pub results: Vec<StressResult>,
pub recommended: Option<StressRecommendation>,
}
/// Real-time recommendation: among rows that keep up with live speech
/// (`rtf < 1`), prefer the **largest** model (most accurate), breaking ties by
/// the **lowest** rtf (most headroom). `sizes` maps model id → size_mb (an
/// accuracy proxy). `None` when nothing runs in real time.
fn pick_realtime_recommendation(
results: &[StressResult],
sizes: &HashMap<String, u32>,
) -> Option<StressRecommendation> {
results
.iter()
.filter(|r| r.realtime)
.max_by(|a, b| {
let sa = sizes.get(&a.model).copied().unwrap_or(0);
let sb = sizes.get(&b.model).copied().unwrap_or(0);
sa.cmp(&sb)
.then(b.rtf.partial_cmp(&a.rtf).unwrap_or(std::cmp::Ordering::Equal))
})
.map(|r| StressRecommendation {
backend: r.backend.clone(),
model: r.model.clone(),
})
}
/// Quick hardware stress test (FR-HW): benchmark each available backend against
/// the installed whisper models on a fixed sample, measure real-time factor
/// (elapsed / audio seconds), and recommend the most accurate model that still
/// keeps up with live speech. Heavy (loads + runs each model) but explicit and
/// progress-reported; runs off the async thread.
/// ponytail: benchmarks installed models only, capped to 3 sizes.
#[tauri::command]
pub async fn stress_test_hardware(
app: AppHandle,
state: State<'_, AppState>,
) -> WaResult<StressTestResult> {
if state.session.lock().await.is_some() {
return Err(WaError::new(
"hardware",
"stop the current recording before running the stress test",
));
}
// Fixed ~10s 16kHz mono sample. RTF timing is ~content-independent, so a
// synthetic quiet tone is enough — no bundled speech clip needed.
const SAMPLE_SECS: f64 = 10.0;
let n = (16_000.0 * SAMPLE_SECS) as usize;
let samples: Vec<f32> = (0..n).map(|i| (i as f32 * 0.05).sin() * 0.1).collect();
let wav = std::env::temp_dir().join("wa-stress-sample.wav");
crate::audio::write_wav_mono_16k(&wav, &samples)
.map_err(|e| WaError::new("hardware", e.to_string()))?;
let backends: Vec<BackendId> = WinHardwareDetector
.detect()
.into_iter()
.filter(|b| b.available)
.map(|b| b.id)
.collect();
// Installed models, smallest → largest, capped to bound runtime.
let mut models: Vec<(String, u32)> = model_catalog::list("")
.into_iter()
.filter(|m| m.installed)
.map(|m| (m.id, m.size_mb))
.collect();
models.sort_by_key(|(_, sz)| *sz);
models.truncate(3);
let sizes: HashMap<String, u32> = models.iter().cloned().collect();
if backends.is_empty() || models.is_empty() {
let _ = std::fs::remove_file(&wav);
return Err(WaError::new(
"hardware",
"no installed whisper model to benchmark — download one in Settings first",
));
}
let wav_for_task = wav.clone();
let app_for_task = app.clone();
let results = tauri::async_runtime::spawn_blocking(move || {
let mut out: Vec<StressResult> = Vec::new();
for backend in &backends {
for (model_id, _size) in &models {
let path = whisper_model_file(model_id);
if !path.exists() {
continue;
}
let _ = app_for_task.emit(
"stress://progress",
serde_json::json!({ "backend": backend.as_str(), "model": model_id }),
);
let start = std::time::Instant::now();
let ok = match load_transcriber(*backend, &path, None) {
Ok((t, _used)) => t.transcribe_file(&wav_for_task).is_ok(),
Err(e) => {
tracing::warn!("stress test load failed ({backend:?}/{model_id}): {e}");
false
}
};
if !ok {
continue;
}
let rtf = start.elapsed().as_secs_f64() / SAMPLE_SECS;
out.push(StressResult {
backend: backend.as_str().to_string(),
model: model_id.clone(),
rtf,
realtime: rtf < 1.0,
});
}
}
out
})
.await
.map_err(|e| WaError::new("hardware", e.to_string()))?;
let _ = std::fs::remove_file(&wav);
let recommended = pick_realtime_recommendation(&results, &sizes);
Ok(StressTestResult {
results,
recommended,
})
}
/// True when the NPU ONNX Whisper model is downloaded (false on non-NPU builds).
fn npu_model_installed() -> bool {
#[cfg(feature = "npu")]
@@ -1959,7 +2359,7 @@ pub async fn reprocess_transcript(
recorded: meeting.recorded,
language: resolved_language,
backend_used: Some(backend.as_str().to_string()),
model_used: Some(model),
model_used: Some(effective_model_id(backend, &model)),
audio_layout: None, // reprocess preserves the recorded layout
},
)
@@ -1999,21 +2399,49 @@ fn default_title_from_source(source: &str) -> Option<String> {
.filter(|s| !s.trim().is_empty())
}
/// Emit one `import://progress` tick for the Domino's-tracker UI. `state` is
/// `active` (this phase is running), `done` (finished; `elapsed_ms` set), or
/// `error` (`message` set). `phase` is one of prepare/transcribe/diarize/finalize.
fn emit_import_progress(
app: &AppHandle,
meeting_id: &str,
phase: &str,
state: &str,
elapsed_ms: Option<u128>,
message: Option<&str>,
) {
let _ = app.emit(
"import://progress",
serde_json::json!({
"meetingId": meeting_id,
"phase": phase,
"state": state,
"elapsedMs": elapsed_ms.map(|m| m as u64),
"error": message,
}),
);
}
/// Manually add a meeting from an existing recording: a local audio/video file
/// or a URL (YouTube/streaming page, or a direct media URL). Shells out to
/// `ffmpeg` (transcode) and — for URLs — `yt-dlp` (both external, not bundled;
/// a missing tool is a clear error). The produced 16kHz-mono WAV becomes the
/// meeting's retained `audio.wav`, then goes through the same
/// transcription + diarization + finalize path as a live recording.
/// or a URL (YouTube/streaming page, or a direct media URL). Creates the meeting
/// row (status `transcribing`) and returns its id **immediately**; the heavy
/// transcode → transcribe → diarize → finalize work runs detached so the UI is
/// never blocked (a 25-min video can take 10+ min). Progress streams via
/// `import://progress` and completion via `transcript://finalized`. `model`
/// overrides the Settings whisper model for this one import (so the user picks
/// and can see what it was transcribed with); omit to use the Settings default.
#[tauri::command]
pub async fn import_media(
app: AppHandle,
state: State<'_, AppState>,
source: String,
title: Option<String>,
model: Option<String>,
) -> WaResult<MeetingId> {
let settings = load_settings();
let model_id = model_id_for(&settings);
let model_id = model
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| model_id_for(&settings));
let backend = backend_for(&settings);
let model_path = whisper_model_file(&model_id);
if !model_path.exists() {
@@ -2042,12 +2470,47 @@ pub async fn import_media(
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
// `create_meeting` starts rows as `recording`; mark this one `transcribing`
// so the meetings-list badge reads as an import in flight, not a live mic.
let _ = state
.store
.set_meeting_status(&meeting_id, "transcribing")
.await;
// Run the pipeline detached and return now — the dialog closes and the
// meeting appears in the list with a live tracker fed by the events below.
let store = state.store.clone();
let dir = meeting_dir(&meeting_id);
let wav_path = dir.join("audio.wav");
let mid = meeting_id.clone();
tauri::async_runtime::spawn(run_import_pipeline(
app, store, mid, source, wav_path, dir, backend, model_id, model_path, language,
));
Ok(meeting_id)
}
// Transcode into the meeting's audio.wav off the async runtime (shells out
// to ffmpeg/yt-dlp). On any failure, drop the empty meeting so a bad import
// doesn't leave a husk row behind.
/// The detached body of `import_media`: transcode, transcribe, diarize, finalize,
/// emitting an `import://progress` tick at the start and end of each phase. On
/// the first failure it marks the meeting `error` (kept in the list, not
/// deleted, so the user sees the failed import) and stops.
#[allow(clippy::too_many_arguments)]
async fn run_import_pipeline(
app: AppHandle,
store: Arc<dyn crate::storage::Store>,
meeting_id: MeetingId,
source: String,
wav_path: std::path::PathBuf,
dir: std::path::PathBuf,
backend: BackendId,
model_id: String,
model_path: std::path::PathBuf,
language: Option<String>,
) {
use std::time::Instant;
// Phase 1: prepare — transcode (and, for URLs, yt-dlp download) to audio.wav.
emit_import_progress(&app, &meeting_id, "prepare", "active", None, None);
let t = Instant::now();
let transcode = tauri::async_runtime::spawn_blocking({
let source = source.clone();
let wav_path = wav_path.clone();
@@ -2060,15 +2523,19 @@ pub async fn import_media(
r
}
})
.await
.map_err(|e| WaError::new("import", e.to_string()))?;
if let Err(e) = transcode {
let _ = state.store.delete_meeting(&meeting_id).await;
return Err(WaError::new("import", e.to_string()));
.await;
match transcode {
Ok(Ok(())) => {
emit_import_progress(&app, &meeting_id, "prepare", "done", Some(t.elapsed().as_millis()), None)
}
Ok(Err(e)) => return fail_import(&app, &store, &meeting_id, "prepare", &e.to_string()).await,
Err(e) => return fail_import(&app, &store, &meeting_id, "prepare", &e.to_string()).await,
}
// Transcribe the produced WAV (same batch path as reprocess_transcript).
let (mut segments, resolved_language) = tauri::async_runtime::spawn_blocking({
// Phase 2: transcribe (same batch path as reprocess_transcript).
emit_import_progress(&app, &meeting_id, "transcribe", "active", None, None);
let t = Instant::now();
let transcribed = tauri::async_runtime::spawn_blocking({
let wav_path = wav_path.clone();
let model_path = model_path.clone();
let requested_language = language.clone();
@@ -2082,12 +2549,20 @@ pub async fn import_media(
Ok::<_, crate::transcription::TrxError>((segments, resolved))
}
})
.await
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
.await;
let (mut segments, resolved_language) = match transcribed {
Ok(Ok(v)) => {
emit_import_progress(&app, &meeting_id, "transcribe", "done", Some(t.elapsed().as_millis()), None);
v
}
Ok(Err(e)) => return fail_import(&app, &store, &meeting_id, "transcribe", &e.to_string()).await,
Err(e) => return fail_import(&app, &store, &meeting_id, "transcribe", &e.to_string()).await,
};
// One diarization pass if the models are installed, exactly like
// stop_recording — otherwise every line stays the single "S1" placeholder.
// Phase 3: diarize — one pass if the models are installed, else every line
// stays the single "S1" placeholder (same as stop_recording).
emit_import_progress(&app, &meeting_id, "diarize", "active", None, None);
let t = Instant::now();
let diarizer: Option<Arc<dyn Diarizer>> =
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
.await
@@ -2103,15 +2578,18 @@ pub async fn import_media(
Err(e) => tracing::warn!("import diarization task failed: {e}"),
}
}
emit_import_progress(&app, &meeting_id, "diarize", "done", Some(t.elapsed().as_millis()), None);
// Phase 4: finalize — persist segments, notes, seal audio.
emit_import_progress(&app, &meeting_id, "finalize", "active", None, None);
let t = Instant::now();
let speakers = speaker_infos_from_segments(&segments, &HashMap::new());
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(0);
state
.store
if let Err(e) = store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
@@ -2121,12 +2599,14 @@ pub async fn import_media(
recorded: true, // the imported WAV is the recording — keep it
language: resolved_language,
backend_used: Some(backend.as_str().to_string()),
model_used: Some(model_id.clone()),
model_used: Some(effective_model_id(backend, &model_id)),
audio_layout: Some("summed".to_string()), // single-source import
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
{
return fail_import(&app, &store, &meeting_id, "finalize", &e.to_string()).await;
}
let notes_md = crate::notes::MarkdownNotes.merge(
&segments,
@@ -2135,7 +2615,7 @@ pub async fn import_media(
None,
None,
);
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
let _ = store.update_notes(&meeting_id, &notes_md).await;
// Seal the retained recording at rest when the vault is unlocked (T8.8),
// matching stop_recording so imports aren't left as plaintext outliers.
@@ -2146,12 +2626,25 @@ pub async fn import_media(
}
}
}
emit_import_progress(&app, &meeting_id, "finalize", "done", Some(t.elapsed().as_millis()), None);
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
Ok(meeting_id)
}
/// Mark a failed background import `error` (kept in the list) and emit the error
/// tick for the phase that failed.
async fn fail_import(
app: &AppHandle,
store: &Arc<dyn crate::storage::Store>,
meeting_id: &str,
phase: &str,
message: &str,
) {
let _ = store.set_meeting_status(&meeting_id.to_string(), "error").await;
emit_import_progress(app, meeting_id, phase, "error", None, Some(message));
}
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
@@ -3440,6 +3933,60 @@ pub async fn generate_tags(
.map_err(|e| WaError::new("llm", e.to_string()))
}
/// AI-enhance the user's rough notes into structured Markdown grounded in the
/// transcript (Granola-style). Reuses the configured `LlmProvider` (local by
/// default → no new egress). The caller passes the live editor buffer so we
/// enhance exactly what the user sees, not a possibly-stale saved copy; we
/// return the enhanced Markdown without persisting it — the UI decides to keep
/// or undo it. Refuses a still-recording meeting; errors clearly with no
/// provider configured. Invents nothing beyond the notes + transcript.
#[tauri::command]
pub async fn enhance_notes(
state: State<'_, AppState>,
meeting_id: MeetingId,
notes: String,
) -> WaResult<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 enhance notes 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 system = "You expand a user's rough meeting notes into clear, well-structured Markdown. \
Use ONLY facts stated in the transcript and the user's own notes — never invent details, names, \
numbers, or decisions. Preserve the user's intent and any structure they started. Output Markdown \
only, with no preamble or commentary.";
let notes = notes.trim();
let user = format!(
"My rough notes:\n{}\n\nTranscript:\n{}",
if notes.is_empty() { "(none yet)" } else { notes },
prompt.transcript
);
provider
.complete(system, &user)
.await
.map(|s| s.trim().to_string())
.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
@@ -4929,6 +5476,49 @@ pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult<serde_js
mod tests {
use super::*;
fn row(backend: &str, model: &str, rtf: f64) -> StressResult {
StressResult {
backend: backend.into(),
model: model.into(),
rtf,
realtime: rtf < 1.0,
}
}
#[test]
fn recommendation_picks_largest_realtime_model() {
let sizes = HashMap::from([
("tiny".to_string(), 32),
("base".to_string(), 60),
("small".to_string(), 190),
]);
let results = vec![
row("cpu", "tiny", 0.4),
row("cpu", "base", 0.9),
row("cpu", "small", 1.4), // too slow — excluded
row("vulkan", "small", 0.6),
];
let rec = pick_realtime_recommendation(&results, &sizes).unwrap();
// small is the largest model that still runs in real time (on vulkan).
assert_eq!(rec.model, "small");
assert_eq!(rec.backend, "vulkan");
}
#[test]
fn recommendation_breaks_size_ties_by_lowest_rtf() {
let sizes = HashMap::from([("base".to_string(), 60)]);
let results = vec![row("cpu", "base", 0.8), row("vulkan", "base", 0.3)];
let rec = pick_realtime_recommendation(&results, &sizes).unwrap();
assert_eq!(rec.backend, "vulkan"); // same model, more headroom
}
#[test]
fn recommendation_is_none_when_nothing_is_realtime() {
let sizes = HashMap::from([("small".to_string(), 190)]);
let results = vec![row("cpu", "small", 1.2)];
assert!(pick_realtime_recommendation(&results, &sizes).is_none());
}
/// Extracts the real hosted DirectML bundle (LZMA2+BCJ 7z) and checks the
/// DLL lands flat. Skips if the binary isn't present (e.g. a lean checkout).
#[cfg(feature = "npu")]
+101
View File
@@ -58,6 +58,55 @@ pub fn assign_by_overlap(segments: &mut [TranscriptSegment], spans: &[SpeakerSpa
}
}
/// Split-layout attribution (FR-SPK): decides per segment between "You" (mic
/// channel voice activity) and the far side's diarized speakers by comparing
/// the *total* voiced overlap on each channel, not by picking the single
/// longest span — a long far-side diarizer span could otherwise swallow a
/// segment the user spoke most of, showing their words under "Speaker N".
/// The mic channel is physically the user's voice alone, so channel evidence
/// outranks cluster evidence; ties go to "You" (mislabeling the user's own
/// words as someone else is the worse failure). A segment with no voiced
/// overlap on either channel keeps its prior label rather than guessing.
// ponytail: whole-segment labels — a segment genuinely containing both sides
// still gets one speaker; the upgrade path is transcribing each channel
// separately so segments can never mix voices.
pub fn assign_split(
segments: &mut [TranscriptSegment],
you_spans: &[(u64, u64)],
far_vad: &[(u64, u64)],
far_spans: &[SpeakerSpan],
) {
fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 {
a1.min(b1).saturating_sub(a0.max(b0))
}
for seg in segments.iter_mut() {
let mic_ms: u64 = you_spans
.iter()
.map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e))
.sum();
let far_ms: u64 = far_vad
.iter()
.map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e))
.sum();
if mic_ms == 0 && far_ms == 0 {
continue;
}
if mic_ms >= far_ms {
seg.speaker = "You".to_string();
} else if let Some(span) = far_spans
.iter()
.map(|sp| (overlap(seg.start_ms, seg.end_ms, sp.start_ms, sp.end_ms), sp))
.filter(|(o, _)| *o > 0)
.max_by_key(|(o, _)| *o)
.map(|(_, sp)| sp)
{
seg.speaker = span.speaker.clone();
}
// Far side voiced but no diarizer span overlaps (e.g. a sub-700ms span
// was filtered): keep the prior label rather than guess.
}
}
/// sherpa-onnx-backed diarizer: pyannote segmentation + speaker-embedding +
/// fast clustering (ADR-0005, T4.1). `Diarize::compute` needs `&mut self`; it's
/// wrapped in a `Mutex` to satisfy `Diarizer: Sync` — diarization is a
@@ -210,6 +259,58 @@ mod overlap_tests {
assign_by_overlap(&mut segments, &[]);
assert_eq!(segments[0].speaker, "S1");
}
#[test]
fn split_labels_a_mic_dominant_segment_you_even_against_a_longer_far_span() {
// The user spoke 0-4000ms; the far side 4000-6000ms — but the far
// cluster span covers the whole window, so the old merged max-overlap
// pick handed the entire segment (the user's words included) to the
// far speaker. Channel totals must side with the mic instead.
let mut segments = vec![segment(0, 6000)];
let you = vec![(0u64, 4000u64)];
let far_vad = vec![(4000u64, 6000u64)];
let far_spans = vec![span(0, 6000, "S1")]; // long far cluster span
assign_split(&mut segments, &you, &far_vad, &far_spans);
assert_eq!(segments[0].speaker, "You");
}
#[test]
fn split_ties_go_to_you() {
let mut segments = vec![segment(0, 2000)];
let you = vec![(0u64, 1000u64)];
let far_vad = vec![(1000u64, 2000u64)];
let far_spans = vec![span(1000, 2000, "S1")];
assign_split(&mut segments, &you, &far_vad, &far_spans);
assert_eq!(segments[0].speaker, "You");
}
#[test]
fn split_assigns_the_best_far_span_when_the_far_side_dominates() {
let mut segments = vec![segment(0, 3000)];
let you = vec![(0u64, 500u64)];
let far_vad = vec![(500u64, 3000u64)];
let far_spans = vec![span(500, 1000, "S1"), span(1000, 3000, "S2")];
assign_split(&mut segments, &you, &far_vad, &far_spans);
assert_eq!(segments[0].speaker, "S2");
}
#[test]
fn split_keeps_the_prior_label_when_both_channels_are_silent() {
let mut segments = vec![segment(5000, 6000)];
segments[0].speaker = "S9".to_string();
assign_split(&mut segments, &[(0, 1000)], &[(0, 1000)], &[span(0, 1000, "S1")]);
assert_eq!(segments[0].speaker, "S9");
}
#[test]
fn split_keeps_the_prior_label_when_far_is_voiced_but_no_far_span_overlaps() {
// Far VAD hears speech but every diarizer span was filtered (sub-700ms):
// don't guess a label.
let mut segments = vec![segment(0, 1000)];
segments[0].speaker = "S3".to_string();
assign_split(&mut segments, &[], &[(0, 1000)], &[span(2000, 3000, "S1")]);
assert_eq!(segments[0].speaker, "S3");
}
}
#[cfg(all(test, feature = "diarization"))]
+52 -2
View File
@@ -28,7 +28,8 @@ pub mod vault;
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use std::thread::JoinHandle;
use tauri::tray::TrayIcon;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri::Manager;
use tokio::sync::Mutex;
@@ -174,10 +175,34 @@ pub fn run() {
session: Mutex::new(None),
})
.setup(move |app| {
// Single tray icon (the `trayIcon` in tauri.conf.json was removed so
// this is the only one). It carries a Show/Quit menu and, on
// left-click, restores the window — the always-available way back
// from "close to tray".
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
let tray = tauri::tray::TrayIconBuilder::new()
let show_item = MenuItem::with_id(app, "show", "Show WhispAssist", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let tray = TrayIconBuilder::new()
.icon(icon)
.tooltip("WhispAssist — idle")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
app.manage(TrayHandle(tray));
@@ -321,6 +346,17 @@ pub fn run() {
});
Ok(())
})
// Close to tray (keep running in background): when the setting is on,
// the window X hides instead of quitting; the tray "Quit" is the real
// exit. Off → default behavior (closing the window quits the app).
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if commands::load_settings().close_to_tray {
api.prevent_close();
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
commands::start_recording,
commands::stop_recording,
@@ -328,6 +364,7 @@ pub fn run() {
commands::recording_playback_path,
commands::pause_recording,
commands::resume_recording,
commands::toggle_microphone_mute,
commands::set_recording_retention,
commands::acknowledge_recording_consent,
commands::update_live_notes,
@@ -340,6 +377,8 @@ pub fn run() {
commands::list_input_devices,
commands::set_preferred_backend,
commands::set_auto_start,
commands::monitor_audio_level,
commands::stress_test_hardware,
commands::list_models,
commands::list_whisper_languages,
commands::download_npu_package,
@@ -368,6 +407,7 @@ pub fn run() {
commands::generate_summary,
commands::confirm_action_items,
commands::generate_tags,
commands::enhance_notes,
commands::llm_setup_suggestions,
commands::pull_ollama_model,
commands::import_pst,
@@ -412,6 +452,16 @@ pub fn run() {
.expect("error while running WhispAssist");
}
/// Restore the main window from the tray (show + unminimize + focus). Shared by
/// the tray left-click and the "Show WhispAssist" menu item.
fn show_main_window(app: &tauri::AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
/// Used by `commands.rs` to keep the tray tooltip honest about capture state (FR-CAP-4).
pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) {
if let Some(tray) = app.try_state::<TrayHandle>() {
+5
View File
@@ -360,6 +360,11 @@ pub struct Settings {
/// enterprise deploy file may set this to `true` (see `deploy.rs`).
#[serde(default)]
pub auto_start: bool,
/// Closing the window hides WhispAssist to the system tray instead of
/// quitting, so it keeps running in the background (tray "Quit" really
/// exits). ON by default; the tray icon is the always-available way back.
#[serde(default = "default_true")]
pub close_to_tray: bool,
}
fn default_mcp_transport() -> String {
+14
View File
@@ -276,6 +276,10 @@ pub trait Store: Send + Sync {
) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
/// Overwrite a meeting's lifecycle `status` (e.g. mark a background import
/// `transcribing` while it runs, or `error` if it fails). `finalize_meeting`
/// is still the only path to `ready`.
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError>;
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
/// (Re)builds this meeting's FTS index row from the current title and
/// whatever's on disk/in the DB for transcript/notes/summary/tags (Phase
@@ -976,6 +980,16 @@ impl Store for SqliteStore {
Ok(())
}
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError> {
sqlx::query("UPDATE meetings SET status = ?, updated_at = ? WHERE id = ?")
.bind(status)
.bind(now_unix())
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> {
write_artifact(
&paths::meeting_dir(id).join("notes.md"),
+1 -5
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "WhispAssist",
"version": "0.7.0",
"version": "0.7.2",
"identifier": "bet.dou.whispassist",
"build": {
"frontendDist": "../dist",
@@ -22,10 +22,6 @@
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; media-src 'self' http://waaudio.localhost; style-src 'self' 'unsafe-inline'"
},
"trayIcon": {
"iconPath": "icons/tray.png",
"tooltip": "WhispAssist"
}
},
"bundle": {
+74 -24
View File
@@ -12,6 +12,7 @@
import { recording } from "./lib/stores/recording.svelte";
import { settings } from "./lib/stores/settings.svelte";
import { meetings } from "./lib/stores/meetings.svelte";
import { imports } from "./lib/stores/imports.svelte";
import { calendar } from "./lib/stores/calendar.svelte";
import { api, type NoteTemplate, type CalendarEvent } from "./lib/api";
import { onMount } from "svelte";
@@ -24,6 +25,8 @@
Square,
Trash2,
FilePlus,
Mic,
MicOff,
Settings as SettingsIcon,
AlertTriangle,
PanelLeftClose,
@@ -131,6 +134,7 @@
recording.init();
settings.load();
meetings.init();
imports.init(); // live background-import progress for the tracker
calendar.load(); // events power the auto-record timer above
checkVault();
api
@@ -207,6 +211,18 @@
} else if (e.ctrlKey && e.key === ",") {
e.preventDefault();
showSettings = !showSettings;
} else if (
// Press "M" to mute/unmute the mic mid-meeting (FR-CAP-7). Bare key (no
// modifiers) and only while recording with the mic on.
e.key.toLowerCase() === "m" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
recording.state !== "idle" &&
settings.settings.microphone_enabled
) {
e.preventDefault();
recording.toggleMute();
}
}
@@ -232,10 +248,16 @@
<div class="app" data-theme={resolvedTheme}>
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
<header class="bar">
<strong>WhispAssist</strong>
<span class="muted">{t("app.tagline")}</span>
<div class="spacer"></div>
{#if recording.state === "idle"}
<button
class="record-btn"
onclick={startRecording}
title={t("app.record_title")}
aria-keyshortcuts="Control+Shift+R"
>
<Circle size={11} fill="currentColor" aria-hidden="true" />
{t("app.record")}
</button>
<select
class="theme-select"
bind:value={selectedTemplateId}
@@ -247,23 +269,6 @@
<option value={tpl.id}>{tpl.name}</option>
{/each}
</select>
<button
class="import-btn"
onclick={() => (showImport = true)}
title={t("app.add_meeting_title")}
>
<FilePlus size={13} aria-hidden="true" />
{t("app.add_meeting")}
</button>
<button
class="record-btn"
onclick={startRecording}
title={t("app.record_title")}
aria-keyshortcuts="Control+Shift+R"
>
<Circle size={11} fill="currentColor" aria-hidden="true" />
{t("app.record")}
</button>
{:else}
<button
class="stop-btn"
@@ -278,6 +283,18 @@
<Trash2 size={12} aria-hidden="true" />
{t("app.cancel")}
</button>
{/if}
<div class="spacer"></div>
{#if recording.state === "idle"}
<button
class="import-btn"
onclick={() => (showImport = true)}
title={t("app.add_meeting_title")}
>
<FilePlus size={13} aria-hidden="true" />
{t("app.add_meeting")}
</button>
{:else}
<span class="rec">
<span class="rec-dot" aria-hidden="true"></span>
{t("app.recording")}
@@ -289,6 +306,23 @@
micPeak={recording.levelPeakMic}
showMic={settings.settings.microphone_enabled}
/>
{#if settings.settings.microphone_enabled}
<button
class="mute-btn"
class:muted={recording.micMuted}
onclick={() => recording.toggleMute()}
aria-pressed={recording.micMuted}
aria-keyshortcuts="M"
title={recording.micMuted ? t("app.unmute_title") : t("app.mute_title")}
>
{#if recording.micMuted}
<MicOff size={14} aria-hidden="true" />
{:else}
<Mic size={14} aria-hidden="true" />
{/if}
<span class="sr-only">{recording.micMuted ? t("app.unmute") : t("app.mute")}</span>
</button>
{/if}
{#if settings.hardware}
<span class="backend" title={t("app.backend_title")}>{settings.hardware.active}</span>
{/if}
@@ -588,10 +622,6 @@
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
.bar strong {
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.spacer {
flex: 1;
}
@@ -718,6 +748,26 @@
border-radius: var(--radius-full);
padding: 0.15rem 0.5rem;
}
.mute-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-full);
cursor: pointer;
}
.mute-btn:hover {
background: var(--bg-hover);
}
.mute-btn.muted {
color: var(--danger, #d33);
border-color: var(--danger, #d33);
background: color-mix(in srgb, var(--danger, #d33) 12%, transparent);
}
.retention {
display: flex;
align-items: center;
+63 -4
View File
@@ -56,6 +56,19 @@ export interface AudioDeviceInfo {
name: string;
}
// Quick hardware stress test (Settings ▸ Hardware): per-(backend, model)
// real-time factor, plus the recommended real-time-capable pairing.
export interface StressResult {
backend: string;
model: string;
rtf: number;
realtime: boolean;
}
export interface StressTestResult {
results: StressResult[];
recommended: { backend: string; model: string } | null;
}
export interface LlmStatus {
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
reachable: boolean;
@@ -83,6 +96,19 @@ export interface LanguageOption {
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
// The four ordered phases of a background media import (import://progress).
export type ImportPhase = "prepare" | "transcribe" | "diarize" | "finalize";
// One `import://progress` tick. `state` is active (running), done (finished,
// `elapsedMs` set) or error (`error` message set) for the given `phase`.
export interface ImportProgress {
meetingId: MeetingId;
phase: ImportPhase;
state: "active" | "done" | "error";
elapsedMs: number | null;
error: string | null;
}
export interface MeetingListItem {
id: MeetingId;
title: string;
@@ -339,6 +365,9 @@ export interface AppSettings {
/** Launch WhispAssist at login (opt-in, off by default; NFR-RES-4). Toggled
* via setAutoStart, which writes a per-user Run entry (no admin). */
auto_start: boolean;
/** Closing the window hides to the tray (keep running in background) instead
* of quitting; on by default. Tray "Quit" is the real exit. */
close_to_tray: boolean;
}
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
@@ -401,6 +430,10 @@ export const api = {
invoke<string>("recording_playback_path", { meetingId }),
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
// Toggle mic mute for the active recording (FR-CAP-7); returns the new muted
// state. Errors if the meeting was started with the mic off.
toggleMicrophoneMute: (meetingId: MeetingId) =>
invoke<boolean>("toggle_microphone_mute", { meetingId }),
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
invoke<void>("set_recording_retention", { meetingId, record }),
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
@@ -421,6 +454,10 @@ export const api = {
setPreferredBackend: (backend: BackendId | "auto") =>
invoke<void>("set_preferred_backend", { args: { backend } }),
setAutoStart: (enabled: boolean) => invoke<void>("set_auto_start", { enabled }),
// Test a device: stream device://level for a few seconds. Resolves when done.
monitorAudioLevel: (kind: "input" | "loopback", deviceId: string | null, durationMs = 6000) =>
invoke<void>("monitor_audio_level", { kind, deviceId, durationMs }),
stressTestHardware: () => invoke<StressTestResult>("stress_test_hardware"),
downloadNpuPackage: () => invoke<void>("download_npu_package"),
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
listModels: () => invoke<ModelInfo[]>("list_models"),
@@ -441,10 +478,12 @@ export const api = {
invoke<void>("reprocess_transcript", { meetingId, model, language }),
// Manually add a meeting from an existing recording — a local audio/video
// file path or a URL (YouTube/streaming page or direct media URL). Requires
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. Returns the new
// meeting's id once transcription + diarization have finished.
importMedia: (source: string, title?: string) =>
invoke<MeetingId>("import_media", { source, title }),
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. `model` overrides
// the Settings whisper model for this one import. Returns the new meeting's id
// *immediately*; transcode/transcribe/diarize run in the background and stream
// `import://progress` ticks, finishing with `transcript://finalized`.
importMedia: (source: string, title?: string, model?: string) =>
invoke<MeetingId>("import_media", { source, title, model }),
resumeTranscription: (meetingId: MeetingId) =>
invoke<void>("resume_transcription", { meetingId }),
listMeetings: (filter?: MeetingFilter) =>
@@ -464,6 +503,10 @@ export const api = {
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
updateNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("update_notes", { meetingId, markdown }),
// AI-enhance rough notes into structured Markdown grounded in the transcript
// (Granola-style). Returns the enhanced text; the caller decides to keep it.
enhanceNotes: (meetingId: MeetingId, notes: string) =>
invoke<string>("enhance_notes", { meetingId, notes }),
// dest is a file path for md/pdf/docx/obsidian, a folder for bundle.
// "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4.
exportMeeting: (
@@ -592,12 +635,19 @@ export const events = {
onDeviceChanged: (
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
): Promise<UnlistenFn> => listen("recording://device", (e) => cb(e.payload as never)),
// Mic mute toggled for the active recording (FR-CAP-7).
onMicMuted: (
cb: (p: { meetingId: string; muted: boolean }) => void,
): Promise<UnlistenFn> => listen("recording://mic", (e) => cb(e.payload as never)),
onSegment: (
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
onFinalized: (
cb: (p: { meetingId: string; segmentCount: number }) => void,
): Promise<UnlistenFn> => listen("transcript://finalized", (e) => cb(e.payload as never)),
// Per-phase progress of a background media import (feeds the import tracker).
onImportProgress: (cb: (p: ImportProgress) => void): Promise<UnlistenFn> =>
listen("import://progress", (e) => cb(e.payload as never)),
// Live diarization refined the speaker list mid-recording (FR-SPK): updated
// labels/display names, including the mic speaker resolved to "You".
onDiarizationUpdated: (
@@ -613,6 +663,15 @@ export const events = {
onHardwareChanged: (
cb: (p: { active: BackendId; reason: string }) => void,
): Promise<UnlistenFn> => listen("hardware://changed", (e) => cb(e.payload as never)),
// Live level meter for a device test (Settings ▸ Hardware). `done` marks the
// end of the monitor window.
onDeviceLevel: (
cb: (p: { kind: string; rms?: number; peak?: number; done?: boolean }) => void,
): Promise<UnlistenFn> => listen("device://level", (e) => cb(e.payload as never)),
// Per-(backend, model) progress ticks during the quick stress test.
onStressProgress: (
cb: (p: { backend: string; model: string }) => void,
): Promise<UnlistenFn> => listen("stress://progress", (e) => cb(e.payload as never)),
onNpuDownload: (
cb: (p: {
stage: "model" | "runtime" | "done";
+102 -21
View File
@@ -1,22 +1,43 @@
<script lang="ts">
// Manually add a meeting from an existing recording (feature: "add a meeting
// + upload a video URL or audio file"). Transcoding is done by the backend
// via ffmpeg (+ yt-dlp for URLs) — both external, not bundled — so this is
// just a small form: pick a local file or paste a URL, optional title, go.
import { api, errorMessage } from "../api";
// via ffmpeg (+ yt-dlp for URLs) — both external, not bundled — so this is a
// small form: pick a file or URL, choose the transcription model, go. Import
// runs in the background (import_media returns as soon as the meeting row
// exists), so this closes immediately and the meetings list shows progress.
import { api, errorMessage, type ModelInfo } from "../api";
import { open } from "@tauri-apps/plugin-dialog";
import { onMount } from "svelte";
import { trapFocus } from "../actions/trapFocus";
import { t } from "../i18n/index.svelte";
import { X, FileUp, Link as LinkIcon } from "@lucide/svelte";
import { X, FileUp, Download } from "@lucide/svelte";
let { onClose, onImported }: { onClose: () => void; onImported: (id: string) => void } = $props();
// External download pages for the two tools this feature shells out to.
const FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/latest";
const YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest";
// `source` is either a local file path (set via Browse) or a URL (typed).
let source = $state("");
let title = $state("");
let model = $state("");
let models = $state<ModelInfo[]>([]);
let busy = $state(false);
let error = $state<string | null>(null);
// Only installed whisper models are selectable; default to the active one so
// the pick matches the user's Settings default unless they change it here.
onMount(async () => {
try {
const all = await api.listModels();
models = all.filter((m) => m.installed);
model = models.find((m) => m.active)?.id ?? models[0]?.id ?? "";
} catch {
models = [];
}
});
async function browse() {
const path = await open({
multiple: false,
@@ -51,7 +72,7 @@
busy = true;
error = null;
try {
const id = await api.importMedia(source.trim(), title.trim() || undefined);
const id = await api.importMedia(source.trim(), title.trim() || undefined, model || undefined);
onImported(id);
onClose();
} catch (e) {
@@ -101,17 +122,32 @@
</div>
</label>
<label class="wide">
{t("import.title_label")} <em>({t("import.optional")})</em>
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
</label>
<div class="grid">
<label>
{t("import.model_label")}
<select bind:value={model} disabled={busy || models.length === 0}>
{#each models as m (m.id)}
<option value={m.id}>{m.label}</option>
{/each}
</select>
<span class="hint">{t("import.model_hint")}</span>
</label>
<p class="muted small">
<LinkIcon size={12} aria-hidden="true" />
{t("import.requires_1")} <code>ffmpeg</code>
{t("import.requires_2")} <code>yt-dlp</code>
{t("import.requires_3")}
</p>
<label>
{t("import.title_label")} <em>({t("import.optional")})</em>
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
</label>
</div>
<div class="tools">
<span class="muted small">{t("import.requires")}</span>
<button class="tool" type="button" onclick={() => api.openUrl(FFMPEG_URL)}>
<Download size={12} aria-hidden="true" /> ffmpeg
</button>
<button class="tool" type="button" onclick={() => api.openUrl(YTDLP_URL)}>
<Download size={12} aria-hidden="true" /> yt-dlp
</button>
</div>
{#if error}
<p class="error">{error}</p>
@@ -121,6 +157,7 @@
<button class="primary" onclick={doImport} disabled={!source.trim() || busy}>
{busy ? t("import.importing") : t("import.import")}
</button>
<span class="muted small note">{t("import.background_note")}</span>
<button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button>
</div>
</div>
@@ -143,7 +180,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
width: min(520px, 100%);
width: min(540px, 100%);
max-height: 90vh;
overflow: auto;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
@@ -179,7 +216,8 @@
font-weight: 400;
color: var(--muted);
}
input {
input,
select {
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.55rem;
@@ -189,10 +227,18 @@
color: var(--fg);
font: inherit;
}
input:focus-visible {
input:focus-visible,
select:focus-visible {
border-color: var(--accent);
outline: none;
}
.hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
font-weight: 400;
color: var(--muted);
}
.row {
display: flex;
gap: 0.4rem;
@@ -213,18 +259,49 @@
padding: 0.4rem 0.6rem;
cursor: pointer;
}
/* Model + title side by side on wide panels, stacked when cramped. */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 0.75rem;
}
@media (max-width: 460px) {
.grid {
grid-template-columns: 1fr;
}
}
.tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.9rem;
}
.tool {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--bg-hover, transparent);
color: var(--accent);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
cursor: pointer;
}
.tool:hover {
border-color: var(--accent);
}
.muted {
color: var(--muted);
}
.small {
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.error {
color: var(--danger, #d33);
font-size: 0.85rem;
margin-top: 0.75rem;
}
.actions {
display: flex;
@@ -232,6 +309,10 @@
gap: 0.6rem;
margin-top: 1rem;
}
.actions .note {
flex: 1;
line-height: 1.2;
}
.actions .primary {
background: var(--accent);
color: var(--accent-fg, #fff);
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
// Domino's-pizza-tracker-style progress for a background media import: four
// ordered steps, the running one pulses, finished ones show how long they
// took. Fed by the `imports` store (import://progress events). Renders nothing
// until the first tick arrives. Design per ui-ux-pro-max: color is never the
// only signal (icon + label + time), tabular figures for the timers, and the
// pulse is dropped under prefers-reduced-motion.
import { imports } from "../stores/imports.svelte";
import { t } from "../i18n/index.svelte";
import { AudioLines, Captions, Users, FileCheck2, Check, X } from "@lucide/svelte";
let { meetingId }: { meetingId: string } = $props();
const run = $derived(imports.get(meetingId));
const ICONS = {
prepare: AudioLines,
transcribe: Captions,
diarize: Users,
finalize: FileCheck2,
} as const;
// ms → compact, human duration for a finished step ("820 ms", "4.3s", "2m 05s").
function fmtDur(ms: number | null): string {
if (ms == null) return "";
if (ms < 1000) return `${ms} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
const m = Math.floor(s / 60);
const rem = Math.round(s % 60);
return `${m}m ${String(rem).padStart(2, "0")}s`;
}
</script>
{#if run}
<section class="tracker" aria-label={t("import.tracker.label")}>
<header>
{#if run.error}
<span class="head err">{t("import.tracker.failed")}</span>
{:else if run.done}
<span class="head ok">{t("import.tracker.done")}</span>
{:else}
<span class="head">{t("import.tracker.running")}</span>
{/if}
</header>
<ol class="steps" aria-live="polite">
{#each run.phases as p (p.phase)}
{@const Icon = ICONS[p.phase]}
<li class="step {p.state}">
<div class="node">
{#if p.state === "done"}
<Check size={18} aria-hidden="true" />
{:else if p.state === "error"}
<X size={18} aria-hidden="true" />
{:else}
<Icon size={18} aria-hidden="true" />
{/if}
</div>
<div class="meta">
<span class="name">{t(`import.phase.${p.phase}`)}</span>
<span class="time">
{#if p.state === "done"}{fmtDur(p.elapsedMs)}
{:else if p.state === "active"}{t("import.tracker.working")}
{:else if p.state === "error"}{t("import.tracker.stopped")}
{/if}
</span>
</div>
</li>
{/each}
</ol>
{#if run.error}
<p class="msg">{run.error}</p>
{/if}
</section>
{/if}
<style>
.tracker {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel, var(--bg-elevated));
padding: 0.85rem 1rem 1rem;
}
header {
margin-bottom: 0.9rem;
}
.head {
font-size: 0.85rem;
font-weight: 600;
color: var(--fg);
}
.head.ok {
color: var(--success);
}
.head.err {
color: var(--danger);
}
.steps {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.step {
flex: 1;
position: relative;
text-align: center;
min-width: 0;
}
/* Connector from the previous node's center to this one's (each step is the
same width, so -50%→+50% spans center to center), sitting behind the node. */
.step::before {
content: "";
position: absolute;
top: 17px;
left: -50%;
width: 100%;
height: 2px;
background: var(--border);
z-index: 0;
}
.step:first-child::before {
display: none;
}
.step.done::before,
.step.active::before,
.step.error::before {
background: var(--accent);
}
.node {
position: relative;
z-index: 1;
width: 36px;
height: 36px;
margin: 0 auto 0.45rem;
display: grid;
place-items: center;
border-radius: var(--radius-full);
border: 2px solid var(--border);
background: var(--bg);
color: var(--muted);
}
.step.active .node {
border-color: var(--accent);
background: var(--accent-soft, transparent);
color: var(--accent);
animation: pulse 1.4s ease-out infinite;
}
.step.done .node {
border-color: var(--success);
background: var(--success);
color: #fff;
}
.step.error .node {
border-color: var(--danger);
background: var(--danger);
color: #fff;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0 0.2rem;
}
.name {
font-size: 0.78rem;
font-weight: 500;
color: var(--muted);
line-height: 1.2;
}
.step.active .name,
.step.done .name {
color: var(--fg);
}
.time {
font-size: 0.72rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-height: 1em;
}
.step.active .time {
color: var(--accent);
}
.msg {
margin: 0.85rem 0 0;
font-size: 0.8rem;
color: var(--danger);
word-break: break-word;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
}
70% {
box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.step.active .node {
animation: none;
box-shadow: 0 0 0 3px var(--accent-soft, transparent);
}
}
</style>
+51 -5
View File
@@ -25,6 +25,24 @@
"settings.hardware.title": "Hardware",
"settings.hardware.refresh": "Refresh",
"settings.hardware.test_output": "Test",
"settings.hardware.test_mic": "Test",
"settings.hardware.testing": "Listening…",
"settings.hardware.play_tone": "Play tone",
"settings.hardware.stress_title": "Quick stress test",
"settings.hardware.stress_hint": "Benchmarks your installed models on each available backend and recommends the most accurate one that still keeps up with live speech. Takes a moment.",
"settings.hardware.stress_run": "Run stress test",
"settings.hardware.stress_running": "Running…",
"settings.hardware.stress_progress": "Benchmarking {pair}…",
"settings.hardware.stress_recommend": "Recommended: {backend} + {model}",
"settings.hardware.stress_apply": "Apply",
"settings.hardware.stress_none": "No installed model keeps up with live speech on this hardware — try a smaller model.",
"settings.hardware.stress_backend": "Backend",
"settings.hardware.stress_model": "Model",
"settings.hardware.stress_rtf": "Speed (×real-time)",
"settings.hardware.stress_realtime": "Live?",
"settings.hardware.stress_yes": "Yes",
"settings.hardware.stress_no": "No",
"settings.hardware.active_backend": "Active backend",
"settings.hardware.model_meta": "· model {size}",
"settings.hardware.preferred_backend": "Preferred backend",
@@ -279,6 +297,7 @@
"settings.privacy.locked_word": "locked",
"settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.",
"settings.privacy.password": "Password",
"settings.privacy.show_password": "Show password",
"settings.privacy.unlock": "Unlock",
"settings.privacy.vault_unlocked_1": "Vault is ",
"settings.privacy.unlocked_word": "unlocked",
@@ -321,12 +340,23 @@
"import.optional": "optional",
"import.title_placeholder": "Defaults to the file name",
"import.filter_av": "Audio / video",
"import.requires_1": "Requires",
"import.requires_2": "installed and on your PATH (plus",
"import.requires_3": "for URLs). WhispAssist doesn't bundle them.",
"import.importing": "Importing… this can take a while",
"import.model_label": "Transcription model",
"import.model_hint": "Recorded with the meeting so you can see how it was transcribed.",
"import.requires": "Needs these on your PATH (not bundled):",
"import.background_note": "Runs in the background — track it in the list.",
"import.importing": "Starting…",
"import.import": "Import",
"import.cancel": "Cancel",
"import.tracker.label": "Import progress",
"import.tracker.running": "Importing…",
"import.tracker.done": "Import complete",
"import.tracker.failed": "Import failed",
"import.tracker.working": "working…",
"import.tracker.stopped": "stopped",
"import.phase.prepare": "Transcode",
"import.phase.transcribe": "Transcribe",
"import.phase.diarize": "Identify speakers",
"import.phase.finalize": "Finalize",
"tagchip.filter": "Filter meetings tagged \"{tag}\"",
"tagchip.remove": "Remove tag {tag}",
@@ -346,6 +376,10 @@
"app.cancel": "Cancel",
"app.cancel_title": "Discard this recording and delete it",
"app.recording": "Recording…",
"app.mute": "Mute microphone",
"app.unmute": "Unmute microphone",
"app.mute_title": "Mute microphone (M)",
"app.unmute_title": "Unmute microphone (M)",
"app.backend_title": "Active transcription backend",
"app.retention_title": "Save audio as .wav for this meeting",
"app.saving": "saving",
@@ -398,6 +432,7 @@
"transcript.heading": "Transcript",
"transcript.title_aria": "Meeting title",
"transcript.transcribed_with": "Transcribed with",
"transcript.lang_title": "Transcription language",
"transcript.lang_auto": "auto-detecting…",
"transcript.show": "Show transcript",
@@ -429,9 +464,18 @@
"notes.italic": "Italic",
"notes.h1": "Heading 1",
"notes.h2": "Heading 2",
"notes.h3": "Heading 3",
"notes.bullet": "Bullet list",
"notes.numbered": "Numbered list",
"notes.quote": "Quote",
"notes.divider": "Divider",
"notes.checkbox_title": "Checkbox",
"notes.checkbox_aria": "Checkbox list item",
"notes.enhance": "Enhance",
"notes.enhancing": "Enhancing…",
"notes.enhance_title": "Expand these notes into structured Markdown using the transcript (AI)",
"notes.enhanced_note": "Notes enhanced from the transcript.",
"notes.undo_enhance": "Undo",
"notes.edit_raw": "Edit the raw markdown",
"notes.render": "Render the markdown",
"notes.editor": "Editor",
@@ -530,5 +574,7 @@
"settings.recording.auto_label": "Auto-start recording when a calendar event begins",
"settings.recording.auto_hint": "Only while WhispAssist is open. When an imported calendar event's start time arrives, a recording begins automatically (using your default retention setting above). Nothing runs in the background — the timer is armed only while the app is running. Import events under Settings → Calendar.",
"settings.recording.autostart_label": "Launch WhispAssist at login",
"settings.recording.autostart_hint": "Starts WhispAssist automatically when you sign in to Windows. Off by default; installs a per-user startup entry (no admin required) and does not begin recording on its own."
"settings.recording.autostart_hint": "Starts WhispAssist automatically when you sign in to Windows. Off by default; installs a per-user startup entry (no admin required) and does not begin recording on its own.",
"settings.recording.close_tray_label": "Close to system tray",
"settings.recording.close_tray_hint": "Closing the window keeps WhispAssist running in the background instead of quitting. Reopen it from the tray icon; use the tray's Quit to exit fully. On by default."
}
+63
View File
@@ -0,0 +1,63 @@
// Live per-meeting progress of background media imports (feeds ImportTracker).
// Fed entirely by `import://progress` events emitted by `import_media`; kept in
// memory only (the meeting's `status` badge is the persistent story after a
// restart). See commands.rs `run_import_pipeline`.
import { events, type ImportPhase, type ImportProgress, type MeetingId } from "../api";
export type PhaseState = "pending" | "active" | "done" | "error";
// The four phases in the order the backend runs (and the tracker renders) them.
export const IMPORT_PHASES: ImportPhase[] = ["prepare", "transcribe", "diarize", "finalize"];
export interface PhaseInfo {
phase: ImportPhase;
state: PhaseState;
elapsedMs: number | null;
}
export interface ImportRun {
meetingId: MeetingId;
phases: PhaseInfo[];
error: string | null;
done: boolean;
}
function freshRun(meetingId: MeetingId): ImportRun {
return {
meetingId,
phases: IMPORT_PHASES.map((phase) => ({ phase, state: "pending", elapsedMs: null })),
error: null,
done: false,
};
}
class ImportsStore {
runs = $state<Record<MeetingId, ImportRun>>({});
get(meetingId: MeetingId): ImportRun | undefined {
return this.runs[meetingId];
}
async init() {
await events.onImportProgress((p) => this.apply(p));
}
private apply(p: ImportProgress) {
// Re-read through the record after inserting so we mutate the $state proxy,
// not the raw object (Svelte 5 deep reactivity only tracks the proxy).
if (!this.runs[p.meetingId]) this.runs[p.meetingId] = freshRun(p.meetingId);
const run = this.runs[p.meetingId];
const info = run.phases.find((x) => x.phase === p.phase);
if (!info) return;
info.state = p.state;
if (p.state === "done") info.elapsedMs = p.elapsedMs;
if (p.state === "error") {
run.error = p.error;
run.done = true;
}
if (p.phase === "finalize" && p.state === "done") run.done = true;
}
}
export const imports = new ImportsStore();
+22
View File
@@ -22,6 +22,9 @@ class RecordingStore {
* (FR-CAP-7); stays 0 when the mic is disabled or not recording. */
levelRmsMic = $state(0);
levelPeakMic = $state(0);
/** Mic muted for the in-flight meeting (FR-CAP-7): mic channel goes silent
* while loopback keeps recording. Toggled by the "M" key / mute button. */
micMuted = $state(false);
/** Set while a capture-device reconnect is in progress; cleared on recovery (FR-CAP-6). */
deviceNotice = $state<string | null>(null);
/** Live notes redesign: freeform text typed in the Notes pane while recording. */
@@ -72,6 +75,22 @@ class RecordingStore {
await events.onDeviceChanged(({ recovered, message }) => {
this.deviceNotice = recovered ? null : message;
});
// Keep mute state in sync even if it was toggled elsewhere (e.g. a future
// tray control), not just from this store's toggleMute().
await events.onMicMuted(({ muted }) => {
this.micMuted = muted;
});
}
/** Toggle mic mute for the active recording (FR-CAP-7); no-op if not
* recording. Optimistically flips, then reconciles with the backend result. */
async toggleMute() {
if (!this.meetingId || this.state === "idle") return;
try {
this.micMuted = await api.toggleMicrophoneMute(this.meetingId);
} catch {
// Mic off for this meeting (or capture gone) — nothing to mute.
}
}
async start(title?: string, record = false, templateId?: string, calendarEventId?: string) {
@@ -79,6 +98,7 @@ class RecordingStore {
this.speakers = [];
this.retention = record;
this.deviceNotice = null;
this.micMuted = false;
this.notesText = "";
this.segmentNotes.clear();
// T8.7/FR-TRX-4: whatever language is currently configured in Settings
@@ -98,6 +118,7 @@ class RecordingStore {
this.levelPeak = 0;
this.levelRmsMic = 0;
this.levelPeakMic = 0;
this.micMuted = false;
this.deviceNotice = null;
}
@@ -116,6 +137,7 @@ class RecordingStore {
this.levelPeak = 0;
this.levelRmsMic = 0;
this.levelPeakMic = 0;
this.micMuted = false;
this.deviceNotice = null;
this.notesText = "";
this.segmentNotes.clear();
+1
View File
@@ -49,6 +49,7 @@ const DEFAULT_SETTINGS: AppSettings = {
microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7)
audio_input_device: null, // system default capture device
auto_start: false, // launch at login — opt-in, off by default (NFR-RES-4)
close_to_tray: true, // closing the window hides to tray; on by default
};
class SettingsStore {
+423 -67
View File
@@ -9,9 +9,16 @@
import { t, i18n, LOCALES } from "../i18n/index.svelte";
import ConsentNotice from "../components/ConsentNotice.svelte";
import HostedAiBanner from "../components/HostedAiBanner.svelte";
import LevelMeter from "../components/LevelMeter.svelte";
import { open } from "@tauri-apps/plugin-dialog";
import { api, errorMessage, events } from "../api";
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
import type {
BackendId,
StressTestResult,
SyncKind,
SyncTargetConfig,
SyncTargetInfo,
} from "../api";
import { trapFocus } from "../actions/trapFocus";
import {
X,
@@ -23,7 +30,11 @@
CalendarDays,
UploadCloud,
ShieldCheck,
Lock,
LockOpen,
Sparkles,
Volume2,
Zap,
RefreshCw,
ChevronRight,
RotateCcw,
@@ -381,11 +392,98 @@
let showConsent = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null);
// ---- Audio device test (live level meter) ----
let monitorKind = $state<"input" | "loopback" | null>(null);
let monitorRms = $state(0);
let monitorPeak = $state(0);
let monitorUnlisten: (() => void) | null = null;
function stopMonitor() {
monitorUnlisten?.();
monitorUnlisten = null;
monitorKind = null;
monitorRms = 0;
monitorPeak = 0;
}
async function testDevice(kind: "input" | "loopback") {
if (monitorKind) return;
monitorKind = kind;
monitorRms = 0;
monitorPeak = 0;
monitorUnlisten = await events.onDeviceLevel((p) => {
if (p.kind !== kind) return;
if (p.done) {
stopMonitor();
return;
}
monitorRms = p.rms ?? 0;
monitorPeak = p.peak ?? 0;
});
const deviceId =
kind === "input"
? (settings.settings.audio_input_device ?? null)
: (settings.settings.audio_output_device ?? null);
try {
await api.monitorAudioLevel(kind, deviceId, 6000);
} catch (e) {
testResult = { ok: false, message: errorMessage(e) };
} finally {
stopMonitor();
}
}
// A 440Hz beep to the default output so the user can confirm speakers work.
function playTone() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 440;
gain.gain.value = 0.15;
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.5);
osc.onended = () => ctx.close();
} catch {
/* no Web Audio available */
}
}
// ---- Quick hardware stress test ----
let stressRunning = $state(false);
let stressProgress = $state<string | null>(null);
let stressResult = $state<StressTestResult | null>(null);
let stressError = $state<string | null>(null);
async function runStressTest() {
if (stressRunning) return;
stressRunning = true;
stressError = null;
stressResult = null;
const un = await events.onStressProgress((p) => {
stressProgress = `${p.backend} · ${p.model}`;
});
try {
stressResult = await api.stressTestHardware();
} catch (e) {
stressError = errorMessage(e);
} finally {
un();
stressProgress = null;
stressRunning = false;
}
}
async function applyRecommendation() {
const r = stressResult?.recommended;
if (!r) return;
await settings.setPreferredBackend(r.backend as BackendId | "auto");
await settings.patch({ whisper_model: r.model });
}
// ---- At-rest encryption vault (T8.8, FR-SEC-3) ----
let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null);
let vaultPw = $state("");
let vaultPw2 = $state("");
let vaultMsg = $state<string | null>(null);
let vaultMsgError = $state(false);
let showVaultPw = $state(false);
async function loadVault() {
try {
vault = await api.vaultStatus();
@@ -394,42 +492,46 @@
}
}
onMount(loadVault);
function setVaultMsg(msg: string | null, isError = false) {
vaultMsg = msg;
vaultMsgError = isError;
}
async function enableVault() {
vaultMsg = null;
setVaultMsg(null);
try {
await api.enableVault(vaultPw);
vaultPw = "";
vaultMsg = "Vault enabled and unlocked.";
setVaultMsg("Vault enabled and unlocked.");
await loadVault();
} catch (e) {
vaultMsg = errorMessage(e);
setVaultMsg(errorMessage(e), true);
}
}
async function unlockVault() {
vaultMsg = null;
setVaultMsg(null);
try {
await api.unlockVault(vaultPw);
vaultPw = "";
vaultMsg = "Unlocked.";
setVaultMsg("Unlocked.");
await loadVault();
} catch (e) {
vaultMsg = errorMessage(e);
setVaultMsg(errorMessage(e), true);
}
}
async function lockVault() {
await api.lockVault();
vaultMsg = "Locked.";
setVaultMsg("Locked.");
await loadVault();
}
async function changeVaultPassword() {
vaultMsg = null;
setVaultMsg(null);
try {
await api.changeVaultPassword(vaultPw, vaultPw2);
vaultPw = "";
vaultPw2 = "";
vaultMsg = "Password changed.";
setVaultMsg("Password changed.");
} catch (e) {
vaultMsg = errorMessage(e);
setVaultMsg(errorMessage(e), true);
}
}
@@ -764,6 +866,17 @@
</label>
<p class="muted">{t("settings.recording.autostart_hint")}</p>
<label class="row">
<input
type="checkbox"
checked={settings.settings.close_to_tray}
onchange={(e) =>
settings.patch({ close_to_tray: (e.target as HTMLInputElement).checked })}
/>
<span>{t("settings.recording.close_tray_label")}</span>
</label>
<p class="muted">{t("settings.recording.close_tray_hint")}</p>
{#if showConsent}
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
{/if}
@@ -820,6 +933,20 @@
</select>
</label>
<p class="muted">{t("settings.hardware.recording_device_hint")}</p>
<div class="device-test">
<button onclick={() => testDevice("loopback")} disabled={monitorKind !== null}>
<Volume2 size={13} aria-hidden="true" />
{monitorKind === "loopback"
? t("settings.hardware.testing")
: t("settings.hardware.test_output")}
</button>
<button onclick={playTone} disabled={monitorKind !== null}>
{t("settings.hardware.play_tone")}
</button>
{#if monitorKind === "loopback"}
<LevelMeter rms={monitorRms} peak={monitorPeak} />
{/if}
</div>
<label
>{t("settings.hardware.microphone")}
@@ -841,6 +968,75 @@
</select>
</label>
<p class="muted">{t("settings.hardware.mic_hint")}</p>
<div class="device-test">
<button
onclick={() => testDevice("input")}
disabled={monitorKind !== null || !settings.settings.microphone_enabled}
>
<Mic size={13} aria-hidden="true" />
{monitorKind === "input"
? t("settings.hardware.testing")
: t("settings.hardware.test_mic")}
</button>
{#if monitorKind === "input"}
<LevelMeter rms={monitorRms} peak={monitorPeak} />
{/if}
</div>
<h4>{t("settings.hardware.stress_title")}</h4>
<p class="muted">{t("settings.hardware.stress_hint")}</p>
<button onclick={runStressTest} disabled={stressRunning}>
<Zap size={13} aria-hidden="true" />
{stressRunning ? t("settings.hardware.stress_running") : t("settings.hardware.stress_run")}
</button>
{#if stressProgress}
<p class="muted">{t("settings.hardware.stress_progress", { pair: stressProgress })}</p>
{/if}
{#if stressError}<p class="muted err">{stressError}</p>{/if}
{#if stressResult}
{#if stressResult.recommended}
<div class="stress-rec">
<ShieldCheck size={14} aria-hidden="true" />
<span
>{t("settings.hardware.stress_recommend", {
backend: stressResult.recommended.backend,
model: stressResult.recommended.model,
})}</span
>
<button class="primary" onclick={applyRecommendation}
>{t("settings.hardware.stress_apply")}</button
>
</div>
{:else}
<p class="muted">{t("settings.hardware.stress_none")}</p>
{/if}
<table class="stress-table">
<thead>
<tr>
<th>{t("settings.hardware.stress_backend")}</th>
<th>{t("settings.hardware.stress_model")}</th>
<th>{t("settings.hardware.stress_rtf")}</th>
<th>{t("settings.hardware.stress_realtime")}</th>
</tr>
</thead>
<tbody>
{#each stressResult.results as r (r.backend + r.model)}
<tr>
<td>{r.backend}</td>
<td>{r.model}</td>
<td class="num">{r.rtf.toFixed(2)}×</td>
<td>
{#if r.realtime}
<Check size={13} aria-hidden="true" /> {t("settings.hardware.stress_yes")}
{:else}
{t("settings.hardware.stress_no")}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if settings.hardware.npu?.present}
{@const npu = settings.hardware.npu}
@@ -1934,67 +2130,118 @@
{/if}
{#if vault}
<h4>{t("settings.privacy.vault_title")}</h4>
{#if !vault.enabled}
<p class="muted">{t("settings.privacy.vault_intro")}</p>
<div class="grid">
<label class="wide"
>{t("settings.privacy.vault_password")}<input
type="password"
bind:value={vaultPw}
/></label
>
<div
class="vault-card"
class:locked={vault.enabled && !vault.unlocked}
class:unlocked={vault.enabled && vault.unlocked}
>
<div class="vault-head">
{#if !vault.enabled}
<ShieldCheck size={18} aria-hidden="true" />
{:else if !vault.unlocked}
<Lock size={18} aria-hidden="true" />
{:else}
<LockOpen size={18} aria-hidden="true" />
{/if}
<h4>{t("settings.privacy.vault_title")}</h4>
{#if vault.enabled}
<span class="badge" class:busy={!vault.unlocked}>
{vault.unlocked
? t("settings.privacy.unlocked_word")
: t("settings.privacy.locked_word")}
</span>
{/if}
</div>
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
>{t("settings.privacy.enable_vault")}</button
>
<p class="muted">{t("settings.privacy.vault_pw_hint")}</p>
{:else if !vault.unlocked}
<p class="muted">
{t("settings.privacy.vault_locked_1")}<strong
>{t("settings.privacy.locked_word")}</strong
>{t("settings.privacy.vault_locked_2")}
</p>
<div class="grid">
<label class="wide"
>{t("settings.privacy.password")}<input
type="password"
{#if !vault.enabled}
<p class="muted">{t("settings.privacy.vault_intro")}</p>
<div class="pw-row">
<input
type={showVaultPw ? "text" : "password"}
bind:value={vaultPw}
/></label
>
</div>
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
>{t("settings.privacy.unlock")}</button
>
{:else}
<p class="muted">
{t("settings.privacy.vault_unlocked_1")}<strong
>{t("settings.privacy.unlocked_word")}</strong
>{t("settings.privacy.vault_unlocked_2")}
</p>
<button onclick={lockVault}>{t("settings.privacy.lock_now")}</button>
<details>
<summary>{t("settings.privacy.change_password")}</summary>
<div class="grid">
<label class="wide"
>{t("settings.privacy.current_password")}<input
type="password"
bind:value={vaultPw}
/></label
>
<label class="wide"
>{t("settings.privacy.new_password")}<input
type="password"
bind:value={vaultPw2}
/></label
placeholder={t("settings.privacy.vault_password")}
aria-label={t("settings.privacy.vault_password")}
/>
<button
type="button"
class="icon pw-toggle"
onclick={() => (showVaultPw = !showVaultPw)}
aria-label={t("settings.privacy.show_password")}
title={t("settings.privacy.show_password")}
>
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
size={14}
aria-hidden="true"
/>{/if}
</button>
</div>
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
>{t("settings.privacy.change_password")}</button
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
>{t("settings.privacy.enable_vault")}</button
>
</details>
{/if}
{#if vaultMsg}<p class="muted">{vaultMsg}</p>{/if}
<p class="muted hint">{t("settings.privacy.vault_pw_hint")}</p>
{:else if !vault.unlocked}
<p class="muted">
{t("settings.privacy.vault_locked_1")}<strong
>{t("settings.privacy.locked_word")}</strong
>{t("settings.privacy.vault_locked_2")}
</p>
<div class="pw-row">
<input
type={showVaultPw ? "text" : "password"}
bind:value={vaultPw}
placeholder={t("settings.privacy.password")}
aria-label={t("settings.privacy.password")}
onkeydown={(e) => e.key === "Enter" && vaultPw && unlockVault()}
/>
<button
type="button"
class="icon pw-toggle"
onclick={() => (showVaultPw = !showVaultPw)}
aria-label={t("settings.privacy.show_password")}
title={t("settings.privacy.show_password")}
>
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
size={14}
aria-hidden="true"
/>{/if}
</button>
</div>
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
>{t("settings.privacy.unlock")}</button
>
{:else}
<p class="muted">
{t("settings.privacy.vault_unlocked_1")}<strong
>{t("settings.privacy.unlocked_word")}</strong
>{t("settings.privacy.vault_unlocked_2")}
</p>
<button onclick={lockVault}>
<Lock size={14} aria-hidden="true" />
{t("settings.privacy.lock_now")}
</button>
<details>
<summary>{t("settings.privacy.change_password")}</summary>
<div class="grid">
<label class="wide"
>{t("settings.privacy.current_password")}<input
type="password"
bind:value={vaultPw}
/></label
>
<label class="wide"
>{t("settings.privacy.new_password")}<input
type="password"
bind:value={vaultPw2}
/></label
>
</div>
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
>{t("settings.privacy.change_password")}</button
>
</details>
{/if}
{#if vaultMsg}<p class="vault-msg" class:error={vaultMsgError}>{vaultMsg}</p>{/if}
</div>
{/if}
</section>
{:else if section === "language"}
@@ -2445,6 +2692,115 @@
color: var(--accent, #2563eb);
border-color: currentColor;
}
.vault-card {
margin-top: 0.6rem;
padding: 0.85rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md, 8px);
background: var(--bg-elevated);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.vault-card.locked {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.vault-card.unlocked {
border-color: color-mix(in srgb, var(--success, #16a34a) 45%, var(--border));
}
.vault-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.vault-head h4 {
margin: 0;
}
.vault-head :global(svg) {
color: var(--muted);
}
.vault-card.locked .vault-head :global(svg) {
color: var(--accent);
}
.vault-card.unlocked .vault-head :global(svg) {
color: var(--success, #16a34a);
}
.vault-head .badge {
margin-left: auto;
}
.pw-row {
display: flex;
align-items: center;
gap: 0.4rem;
max-width: 22rem;
}
.pw-row input {
flex: 1;
}
.pw-toggle {
flex: none;
}
.vault-card .hint {
margin: 0;
}
.vault-msg {
margin: 0;
font-size: 0.85rem;
color: var(--success, #16a34a);
}
.vault-msg.error {
color: var(--danger);
}
.device-test {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0.35rem 0 0.6rem;
}
.device-test :global(.meter) {
flex: 1;
min-width: 8rem;
}
.stress-rec {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0;
padding: 0.55rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
border-radius: var(--radius-md, 8px);
background: color-mix(in srgb, var(--accent) 10%, var(--bg));
}
.stress-rec :global(svg) {
color: var(--accent);
}
.stress-rec span {
flex: 1;
}
.stress-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
font-size: 0.82rem;
}
.stress-table th,
.stress-table td {
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--border);
}
.stress-table th {
color: var(--muted);
font-weight: 600;
}
.stress-table .num {
font-variant-numeric: tabular-nums;
text-align: right;
}
.err {
color: var(--danger);
}
.npu-package {
margin-top: 0.6rem;
padding: 0.6rem 0.75rem;
+65 -44
View File
@@ -64,7 +64,7 @@
// nonce so re-clicking the same segment still jumps). Reading seekNonce is
// what makes this effect re-run.
$effect(() => {
player.seekNonce;
void player.seekNonce;
const ms = player.seekMs;
if (ms == null || !audioEl) return;
audioEl.currentTime = ms / 1000;
@@ -770,47 +770,54 @@
{#if editableItems.length === 0}
<p class="muted">{t("summary.ai_empty")}</p>
{:else}
<!-- Two-row card per item: the action text owns the full first row (it
was unreadable when six controls shared one row in this narrow
pane); owner/due/reminder are a secondary meta row beneath it. -->
<ul class="action-items">
{#each editableItems as item, i (i)}
<li>
<input
type="checkbox"
bind:checked={item.confirmed}
aria-label={t("summary.confirmed")}
title={t("summary.confirmed")}
/>
<input
class="ai-text"
bind:value={item.text}
placeholder={t("summary.ai_text_placeholder")}
aria-label={t("summary.ai_text_aria")}
/>
<input
class="ai-owner"
value={item.owner ?? ""}
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
placeholder={t("summary.owner")}
aria-label={t("summary.owner")}
/>
<input
type="date"
class="due-date"
aria-label={t("summary.due_date")}
value={dueDateInput(item.due_at)}
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
/>
<label class="remind" title={t("summary.reminder_title")}>
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
<Bell size={14} aria-hidden="true" />
</label>
<button
class="ai-del"
onclick={() => removeActionItem(i)}
title={t("summary.ai_delete_title")}
aria-label={t("summary.ai_delete_aria")}
>
<X size={14} aria-hidden="true" />
</button>
<div class="ai-main">
<input
type="checkbox"
bind:checked={item.confirmed}
aria-label={t("summary.confirmed")}
title={t("summary.confirmed")}
/>
<input
class="ai-text"
bind:value={item.text}
placeholder={t("summary.ai_text_placeholder")}
aria-label={t("summary.ai_text_aria")}
/>
<button
class="ai-del"
onclick={() => removeActionItem(i)}
title={t("summary.ai_delete_title")}
aria-label={t("summary.ai_delete_aria")}
>
<X size={14} aria-hidden="true" />
</button>
</div>
<div class="ai-meta">
<input
class="ai-owner"
value={item.owner ?? ""}
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
placeholder={t("summary.owner")}
aria-label={t("summary.owner")}
/>
<input
type="date"
class="due-date"
aria-label={t("summary.due_date")}
value={dueDateInput(item.due_at)}
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
/>
<label class="remind" title={t("summary.reminder_title")}>
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
<Bell size={14} aria-hidden="true" />
</label>
</div>
</li>
{/each}
</ul>
@@ -1080,9 +1087,9 @@
}
ul.action-items li {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.35rem;
padding: 0.25rem 0;
padding: 0.45rem 0;
border-bottom: 1px solid var(--border);
}
ul.action-items label {
@@ -1090,14 +1097,28 @@
align-items: center;
gap: 0.4rem;
}
.ai-main {
display: flex;
align-items: center;
gap: 0.35rem;
}
.ai-text {
flex: 1;
min-width: 0;
font-size: 0.82rem;
font-size: 0.85rem;
}
/* Meta row indented under the text (past the confirm checkbox), wrapping
rather than crushing its inputs when the pane is narrow. */
.ai-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
padding-left: 1.4rem;
}
.ai-owner {
flex: none;
width: 5rem;
flex: 1;
min-width: 5rem;
font-size: 0.75rem;
}
.ai-del {
+206 -5
View File
@@ -6,19 +6,27 @@
import { meetings } from "../stores/meetings.svelte";
import { settings } from "../stores/settings.svelte";
import { player } from "../stores/player.svelte";
import { api, type SpeakerInfo } from "../api";
import { api, errorMessage, type SpeakerInfo } from "../api";
import { t } from "../i18n/index.svelte";
import { renderMarkdown } from "../markdown";
import { save, open } from "@tauri-apps/plugin-dialog";
import { layout, clamp } from "../stores/layout.svelte";
import { imports } from "../stores/imports.svelte";
import Splitter from "../components/Splitter.svelte";
import ImportTracker from "../components/ImportTracker.svelte";
import {
Bold,
Italic,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListChecks,
Quote,
Minus,
Sparkles,
Undo2,
FileDown,
FileText,
FolderOutput,
@@ -66,6 +74,10 @@
let editorEl: HTMLTextAreaElement | undefined = $state();
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let loadedForId: string | null = null;
// The server copy the buffer was last synced against — lets the effect below
// tell a server-side notes change (speaker rename, reprocess) apart from the
// user's own unsaved edits.
let lastServerNotes: string | null = null;
// Transcript/notes split (FR-UX-1): resizable (drag the Splitter) and
// each side independently hideable, shared across the finalized-meeting
@@ -103,14 +115,23 @@
selectedSegmentMs = null;
});
// Sync the editor buffer whenever a different meeting is selected.
// Sync the editor buffer whenever a different meeting is selected — and when
// the *server* copy of the same meeting's notes changes underneath us (a
// speaker rename rewrites notes.md's dialogue tags, reprocess regenerates it).
// A buffer with unsaved local edits is never clobbered: it only adopts the
// server copy when it still equals the last-synced one.
$effect(() => {
const m = meetings.selected;
if (m && m.id !== loadedForId) {
notesText = m.notes_markdown;
lastServerNotes = m.notes_markdown;
loadedForId = m.id;
} else if (m && m.id === loadedForId && m.notes_markdown !== lastServerNotes) {
if (notesText === lastServerNotes) notesText = m.notes_markdown;
lastServerNotes = m.notes_markdown;
} else if (!m) {
loadedForId = null;
lastServerNotes = null;
}
});
@@ -158,6 +179,70 @@
scheduleSave();
}
// Slash commands: typing "/todo" (etc.) at the start of a line and pressing
// Space/Enter swaps it for the matching Markdown prefix. Reuses the same
// line-prefix model as the toolbar buttons — no rich inline menu.
// ponytail: line-prefix slash only; add a picker popover if users ask.
const SLASH_COMMANDS: Record<string, string> = {
h1: "# ",
h2: "## ",
h3: "### ",
todo: "- [ ] ",
bullet: "- ",
num: "1. ",
quote: "> ",
divider: "---\n",
};
function handleNotesKeydown(e: KeyboardEvent) {
if (e.key !== "Enter" && e.key !== " ") return;
const el = editorEl;
if (!el) return;
const { selectionStart: s, value } = el;
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
const match = /^\/(\w+)$/.exec(value.slice(lineStart, s));
if (!match) return;
const prefix = SLASH_COMMANDS[match[1].toLowerCase()];
if (prefix === undefined) return;
e.preventDefault();
const head = value.slice(0, lineStart) + prefix;
notesText = head + value.slice(s);
queueMicrotask(() => {
el.focus();
el.selectionStart = el.selectionEnd = head.length;
});
scheduleSave();
}
// AI-enhance (Granola-style): expand the user's rough notes into structured
// Markdown grounded in the transcript, via the configured LlmProvider (local
// by default, no new egress). Keeps a one-step Undo so we never silently lose
// what the user typed.
let enhancing = $state(false);
let enhanceError = $state<string | null>(null);
let notesBeforeEnhance = $state<string | null>(null);
async function enhanceNotes() {
const m = meetings.selected;
if (!m || enhancing) return;
enhancing = true;
enhanceError = null;
try {
const enhanced = await api.enhanceNotes(m.id, notesText);
notesBeforeEnhance = notesText;
notesText = enhanced;
scheduleSave();
} catch (e) {
enhanceError = errorMessage(e);
} finally {
enhancing = false;
}
}
function undoEnhance() {
if (notesBeforeEnhance === null) return;
notesText = notesBeforeEnhance;
notesBeforeEnhance = null;
scheduleSave();
}
async function exportMd() {
const m = meetings.selected;
if (!m) return;
@@ -247,6 +332,15 @@
reprocessing = true;
try {
await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined);
// Re-transcribe rebuilds notes.md server-side (merging saved manual notes),
// but the meeting stays selected (same id), so the buffer-sync $effect —
// which only fires on an id change — won't pick it up. Resync explicitly so
// the notes pane updates in place instead of only after a restart.
const updated = meetings.selected;
if (updated && updated.id === m.id) {
notesText = updated.notes_markdown;
loadedForId = updated.id;
}
} finally {
reprocessing = false;
}
@@ -262,14 +356,23 @@
onchange={onTitleChange}
aria-label={t("transcript.title_aria")}
/>
{#if m.status === "transcribing" || imports.get(m.id)}
<div class="import-strip"><ImportTracker meetingId={m.id} /></div>
{:else if m.model_used}
<p class="engine-meta" title={t("transcript.transcribed_with")}>
{t("transcript.transcribed_with")}
<strong>{m.model_used}</strong>{#if m.backend_used}
· {m.backend_used}{/if}
</p>
{/if}
<div
class="split"
style="grid-template-columns: {splitColumns()};"
bind:clientWidth={splitWidth}
>
<!-- svelte-ignore a11y_no_static_element_interactions -- wheel/touchmove
here only note "the user scrolled by hand" to pause playback
auto-scroll; the pane isn't an interactive control. -->
<!-- wheel/touchmove here only note "the user scrolled by hand" to pause
playback auto-scroll; the pane isn't an interactive control. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pane transcript"
class:collapsed={layout.transcriptCollapsed}
@@ -423,6 +526,43 @@
>
<ListChecks size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("### ")}
title={t("notes.h3")}
aria-label={t("notes.h3")}
>
<Heading3 size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("1. ")}
title={t("notes.numbered")}
aria-label={t("notes.numbered")}
>
<ListOrdered size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("> ")}
title={t("notes.quote")}
aria-label={t("notes.quote")}
>
<Quote size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("---\n")}
title={t("notes.divider")}
aria-label={t("notes.divider")}
>
<Minus size={14} aria-hidden="true" />
</button>
<button
class="enhance"
onclick={enhanceNotes}
disabled={enhancing}
title={t("notes.enhance_title")}
>
<Sparkles size={14} aria-hidden="true" class={enhancing ? "spin" : ""} />
{enhancing ? t("notes.enhancing") : t("notes.enhance")}
</button>
<button
class="toggle"
onclick={() => (notesPreview = !notesPreview)}
@@ -459,6 +599,17 @@
Obsidian
</button>
</div>
{#if enhanceError}
<p class="enhance-bar error" role="alert">{enhanceError}</p>
{:else if notesBeforeEnhance !== null}
<div class="enhance-bar">
<span>{t("notes.enhanced_note")}</span>
<button class="link" onclick={undoEnhance}>
<Undo2 size={13} aria-hidden="true" />
{t("notes.undo_enhance")}
</button>
</div>
{/if}
<div class="editor-preview">
{#if notesPreview}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
@@ -468,6 +619,7 @@
bind:this={editorEl}
bind:value={notesText}
oninput={scheduleSave}
onkeydown={handleNotesKeydown}
placeholder={t("notes.placeholder")}
></textarea>
{/if}
@@ -610,6 +762,21 @@
background: var(--border);
outline: none;
}
.import-strip {
flex: none;
padding: 0 1rem 0.5rem;
}
.engine-meta {
flex: none;
margin: 0;
padding: 0 1rem 0.4rem;
font-size: 0.75rem;
color: var(--muted);
}
.engine-meta strong {
font-weight: 600;
color: var(--fg);
}
.pad {
padding: 1rem;
max-width: 760px;
@@ -842,6 +1009,40 @@
.toolbar .spacer {
flex: 1;
}
.toolbar .enhance {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
font-weight: 600;
}
.toolbar .enhance:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 12%, var(--bg));
}
.toolbar .enhance:disabled {
opacity: 0.6;
cursor: default;
}
.enhance-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.enhance-bar.error {
color: var(--danger);
}
.enhance-bar .link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
.editor-preview {
height: calc(100% - 2.5rem);