Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f300ee177 | ||
|
|
52d3fcbf3b | ||
|
|
2181aa5b0e | ||
|
|
4e87cab3ad | ||
|
|
84ab88b565 | ||
|
|
ae13944563 | ||
|
|
8fc5d72576 | ||
|
|
681933c8cf | ||
|
|
cfe73e7d80 | ||
|
|
505387ba49 | ||
|
|
fa8ce62f63 | ||
|
|
7527da92c5 | ||
|
|
ef933c3786 | ||
|
|
f6f336d79f | ||
|
|
1f25833e74 | ||
|
|
0d8bcf3b17 | ||
|
|
27a1f98051 | ||
|
|
0ecd6db12e | ||
|
|
d2f35c64b1 | ||
|
|
5967fd6997 | ||
|
|
a0ad1564a8 | ||
|
|
377a91d1b8 | ||
|
|
7796c5b62d | ||
|
|
698fb26b0a | ||
|
|
802e30e9c6 | ||
|
|
f1683af01c | ||
|
|
12388b8bd9 | ||
|
|
b295e1ee83 | ||
|
|
c98c14bbc7 | ||
|
|
c0b7587298 | ||
|
|
d5f0d028ac | ||
|
|
2f925de9a5 | ||
|
|
486767587e | ||
|
|
88969019b3 | ||
|
|
2b13de4ef2 | ||
|
|
a112b81d6e | ||
|
|
1da127a61c | ||
|
|
57d3aca386 | ||
|
|
aac88bfbc5 | ||
|
|
e5fdb2baea | ||
|
|
fc89f76ac8 | ||
|
|
7b8a651730 | ||
|
|
e1e2808d9c | ||
|
|
77234e697d | ||
|
|
f5c521df0d | ||
|
|
3f04ec6167 | ||
|
|
030b06329a | ||
|
|
a7af623753 | ||
|
|
b98c258c13 | ||
|
|
8ca6cf4f89 | ||
|
|
dbe845e923 | ||
|
|
d5aef2966f | ||
|
|
2389fb05a5 | ||
|
|
96d02cc220 | ||
|
|
c3da6cf0f3 | ||
|
|
5564c9c9a6 | ||
|
|
d25c297257 | ||
|
|
23cfca7ea2 | ||
|
|
6129866ac5 | ||
|
|
2e57ccfab5 | ||
|
|
4dff7a58b5 | ||
|
|
25204c0235 | ||
|
|
2b2e120f12 | ||
|
|
70eb182eb4 | ||
|
|
836547596b | ||
|
|
58215076cb | ||
|
|
862ab86860 | ||
|
|
a0faaa94b4 | ||
|
|
9c74f84c5b | ||
|
|
57cd46be46 | ||
|
|
490a960676 | ||
|
|
379bdf532f | ||
|
|
95f07e1dba | ||
|
|
931a2b78b4 | ||
|
|
89254bc070 | ||
|
|
22a37c5f4e | ||
|
|
cecfe20eec | ||
|
|
6fbf967fdb | ||
|
|
9f3c1b65d0 | ||
|
|
a4b2b9a292 | ||
|
|
12e23143f6 | ||
|
|
fd556c57a1 | ||
|
|
1ea8011dc8 | ||
|
|
002f8caa2b | ||
|
|
d72509df5f | ||
|
|
7ef45bb315 | ||
|
|
bd251b991a | ||
|
|
dfaeb7c873 | ||
|
|
6b164cd02a | ||
|
|
21101054a4 | ||
|
|
16200a130e | ||
|
|
86f115642c | ||
|
|
1760d4ae24 | ||
|
|
2681a5d8c8 | ||
|
|
233d641041 | ||
|
|
29589663b2 | ||
|
|
28058e205d | ||
|
|
235129d0d6 | ||
|
|
59c2d70643 | ||
|
|
e082f99f32 | ||
|
|
5260a4240b | ||
|
|
bf644ea0be | ||
|
|
13fb9984da | ||
|
|
99d50583cf | ||
|
|
0027becd10 | ||
|
|
e3c80b565c | ||
|
|
2a637e78ad | ||
|
|
80ad2a5f76 | ||
|
|
6622ae81ff | ||
|
|
90b2d51d08 | ||
|
|
7081040475 | ||
|
|
7dfcbdf247 | ||
|
|
864cd33a5f | ||
|
|
94eafd4fae | ||
|
|
45ddc2bd3e | ||
|
|
bb226dcb5e | ||
|
|
98b183d2b0 | ||
|
|
8828fca805 | ||
|
|
9057b25e92 | ||
|
|
41b5553836 | ||
|
|
538db4372f | ||
|
|
8906f8c8ed | ||
|
|
2f62e4b13a | ||
|
|
3214bd2740 | ||
|
|
e7b951cb90 | ||
|
|
a341afc52f | ||
|
|
83babc99cd | ||
|
|
4015b5132e | ||
|
|
b5f5ef172c | ||
|
|
69eedc9354 | ||
|
|
c9b5b91473 | ||
|
|
dca797aaa3 | ||
|
|
b532acbb3c | ||
|
|
469df7524f | ||
|
|
ec864c24f0 |
@@ -21,6 +21,13 @@ Every memory operation in this session goes through MEMANTO. There is no excepti
|
||||
|
||||
These are not suggestions. Follow each one on every turn.
|
||||
|
||||
0. **Activate the `whispassist` agent at the start of every session, before any memory op.** Run
|
||||
`memanto agent activate whispassist` first thing. This machine hosts multiple projects and the
|
||||
session-start sync may activate a *different* project's agent (e.g. `whispassist`), so the
|
||||
auto-synced `MEMORY.md` can belong to the wrong project — do not trust it as LastERP context
|
||||
until you've activated `whispassist` and re-synced. Confirm with `memanto agent list` (the
|
||||
active one is marked). All `recall`/`remember`/`answer` calls read and write the *active*
|
||||
agent's store, so getting this wrong silently pollutes or mis-reads another project's memory.
|
||||
1. **Read `MEMORY.md` before doing anything.** It is auto-synced at session start and holds
|
||||
the user's preferences, facts, goals, instructions, decisions, and commitments from every
|
||||
prior session. You MUST honor what is written there. If you act against it, you are
|
||||
|
||||
@@ -7,13 +7,14 @@ on-device acceleration (**NPU → GPU → CPU**), labels speakers, structures th
|
||||
Markdown notes, and optionally augments them with a locally hosted LLM (Ollama). Audio and
|
||||
transcripts **never leave the machine** unless you explicitly configure a destination.
|
||||
|
||||
> **Status: working application (v0.2.0).** Capture (system audio **+ your microphone**),
|
||||
> transcription (CPU / Intel NPU / Vulkan GPU), speaker diarization, storage + crash recovery,
|
||||
> local-LLM summaries, AI tags, opt-in recording with **in-app playback**, at-rest encryption,
|
||||
> and self-hosted sync are implemented and ship as a single signed **MSI + NSIS** universal
|
||||
> installer. Outlook `.pst`/calendar context (recurring-event import + filtering) is landing;
|
||||
> the coding-agent (MCP) handoff is in progress. Build order and remaining tasks are in
|
||||
> [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
> **Status: working application (v0.4.0).** Capture (system audio **+ your microphone**, with a
|
||||
> live **dual level meter**), transcription (CPU / Intel NPU / Vulkan GPU) with a **fluid live
|
||||
> transcript**, speaker diarization, storage + crash recovery, local-LLM summaries, AI tags,
|
||||
> opt-in recording with **in-app playback**, at-rest encryption, self-hosted sync, **Outlook
|
||||
> `.pst`/calendar import with optional auto-record**, **importing an existing recording from a
|
||||
> file or URL**, and a loopback **MCP server** for coding-agent handoff are all implemented and
|
||||
> ship as a single signed **MSI + NSIS** universal installer. Build order and remaining tasks
|
||||
> are in [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
|
||||
## Why WhispAssist — Granola vs Meetily vs WhispAssist
|
||||
|
||||
@@ -27,17 +28,21 @@ transcripts **never leave the machine** unless you explicitly configure a destin
|
||||
| **Speaker diarization** | ✅ cloud | ⚠️ limited | ✅ offline (sherpa-onnx) |
|
||||
| **Summaries / notes AI** | ☁️ cloud LLM | ✅ local (Ollama) / BYO | ✅ local (Ollama, localhost **or LAN**) + optional hosted |
|
||||
| **Default data egress** | ☁️ audio + notes to cloud | 🔒 local (cloud optional) | 🔒 **none** — everything off by default, allowlist-enforced |
|
||||
| **Calendar / Outlook `.pst` context** | ✅ cloud calendar | ❌ | ⚙️ local `.pst` — in progress |
|
||||
| **Calendar / Outlook `.pst` context** | ✅ cloud calendar | ❌ | ✅ local `.pst` import + **auto-record on events** |
|
||||
| **Import an existing recording (file/URL)** | ❌ live capture only | ❌ | ✅ any file or URL (ffmpeg + yt-dlp) |
|
||||
| **Opt-in recording + consent notice** | ⚠️ partial | ❌ | ✅ off by default, one-time consent |
|
||||
| **At-rest encryption** | ☁️ server-side | ❌ | ✅ vault: Argon2id + XChaCha20-Poly1305 |
|
||||
| **Self-hosted sync w/ client-side encryption** | ❌ | ⚠️ | ✅ WebDAV + OAuth, **encrypt-before-upload** |
|
||||
| **Coding-agent (MCP) handoff** | ❌ | ❌ | ⚙️ local MCP server — in progress |
|
||||
| **Coding-agent (MCP) server** | ✅ **cloud** MCP (notes via their servers) | ❌ | ✅ **local, loopback-only**, token-gated, adds no egress |
|
||||
| **License** | Proprietary | Open source (MIT) | Open source (MIT / Apache-2.0) |
|
||||
| **Cost** | Subscription | Free | Free |
|
||||
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola and Meetily
|
||||
are independent products and their capabilities evolve — verify current details before relying
|
||||
on any row.</sub>
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola now ships an
|
||||
MCP server too, but it is **cloud-hosted** — an agent reaching it pulls your notes through
|
||||
Granola's servers; WhispAssist's MCP server is **loopback-only and adds no egress of its own**
|
||||
(data leaves only via the connected agent's own provider, which WA discloses). Granola and
|
||||
Meetily are independent products and their capabilities evolve — verify current details before
|
||||
relying on any row.</sub>
|
||||
|
||||
**The short version:** Granola is the polished cloud option (your audio and notes are processed
|
||||
on their servers). Meetily is the closest peer — open-source and self-hosted — but is
|
||||
@@ -46,19 +51,29 @@ accelerated, zero-egress-by-default** option: it exploits the NPU/GPU in modern
|
||||
everything on the device unless you opt in, and adds Windows-specific context (Outlook) and a
|
||||
coding-agent handoff.
|
||||
|
||||
## What's built (v0.2.0)
|
||||
## What's built (v0.4.0)
|
||||
|
||||
- **Bot-free capture — now both sides** — WASAPI loopback records the system mix (all
|
||||
participants), and an optional **microphone** path captures your own voice, mixed into both the
|
||||
live transcript and the saved recording. Pick a specific output/input or turn the mic off in
|
||||
**Settings ▸ Hardware**. No meeting bot, no per-app plumbing.
|
||||
- **Local transcription with a hardware ladder** — whisper.cpp via `whisper-rs` on CPU; the
|
||||
**Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single binary that runs on
|
||||
NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**), streams partial transcripts live, and shows the active
|
||||
backend in the UI.
|
||||
- **Speaker diarization** — `sherpa-onnx` (pyannote segmentation + speaker-embedding
|
||||
clustering), fully offline.
|
||||
- **Bot-free capture — now both sides, with a live dual meter** — WASAPI loopback records the
|
||||
system mix (all participants), and an optional **microphone** path captures your own voice,
|
||||
mixed into both the live transcript and the saved recording. While recording, a **level meter
|
||||
overlays the system and microphone signals in two colours** so you can see both sides are being
|
||||
picked up. Pick a specific output/input or turn the mic off in **Settings ▸ Hardware**. No
|
||||
meeting bot, no per-app plumbing.
|
||||
- **Local transcription with a hardware ladder — and a fluid live transcript** — whisper.cpp via
|
||||
`whisper-rs` on CPU; the **Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single
|
||||
binary that runs on NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**), and shows it in the UI. The **live transcript streams a
|
||||
growing line that refreshes ~once a second and commits at natural pauses** — words appear as
|
||||
they're spoken instead of in fixed multi-second blocks, so sentences aren't chopped across
|
||||
lines.
|
||||
- **Speaker diarization** — `sherpa-onnx` (pyannote segmentation + speaker-embedding clustering),
|
||||
fully offline. Install the two diarization models in **Settings ▸ Hardware** and finished
|
||||
recordings are split by speaker (Speaker 1, Speaker 2, …); the microphone speaker is
|
||||
auto-labelled from a short voiceprint.
|
||||
- **Import an existing recording** — add a meeting from a **local audio/video file or a URL**
|
||||
(YouTube, a streaming page, or a direct media link). WhispAssist transcribes and diarizes it
|
||||
just like a live recording. Uses **`ffmpeg`** (and **`yt-dlp`** for URLs), which you install
|
||||
yourself — neither is bundled.
|
||||
- **Notes & summaries** — Markdown notes; local-LLM summaries via **Ollama** on `localhost`
|
||||
**or a private LAN endpoint** (RFC-1918), with a full advanced-parameter panel (system prompt,
|
||||
`think`, `keep_alive`, `num_ctx`, sampling/repetition/mirostat, etc.).
|
||||
@@ -80,12 +95,20 @@ coding-agent handoff.
|
||||
destination holds only ciphertext. Credentials live only in the OS credential store.
|
||||
- **Optional hosted AI** — Anthropic and OpenAI-compatible providers behind the same
|
||||
`LlmProvider` interface, off by default (third-party egress, keys in the OS credential store).
|
||||
- **Outlook `.pst` / calendar context, with auto-record** — import events and attendees from a
|
||||
local Outlook `.pst` backup (read-only, range-limited, de-duplicated, with cleanup), attach
|
||||
meetings to events, and — opt-in — **auto-start recording when a calendar event begins** while
|
||||
the app is open (a one-shot timer, no background polling).
|
||||
- **Local MCP server for coding-agent handoff** — hand meeting context to your own coding agents
|
||||
(Claude, Codex, Copilot, OpenCode) over a **loopback-only, token-gated** MCP server that is off
|
||||
by default, scope-limited, audited, and **adds no egress** — data leaves only via the agent's
|
||||
own provider, which WA discloses.
|
||||
- **One universal installer** — a single signed MSI and NSIS `-setup.exe` that covers every
|
||||
machine: Vulkan for all GPUs, the Intel NPU path, and CPU fallback. The Vulkan loader is bundled
|
||||
so it launches even on machines without a GPU driver.
|
||||
machine: Vulkan for all GPUs, the Intel NPU path (+ DirectML fallback), and CPU. The Vulkan
|
||||
loader is bundled so it launches even on machines without a GPU driver.
|
||||
|
||||
**In progress:** Outlook `.pst` + calendar context, the local **MCP server** that hands meeting
|
||||
context to your coding agents (Claude, Codex, Copilot, OpenCode), and MS Graph calendar.
|
||||
**In progress:** Microsoft Graph calendar (cloud calendar via OAuth) and an optional CUDA
|
||||
(NVIDIA-only) build variant.
|
||||
|
||||
## Quick start (install)
|
||||
|
||||
@@ -107,6 +130,14 @@ data under `%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
Prefer the NSIS installer? Grab **`WhispAssist_<version>_x64-setup.exe`** from the same page.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
If you do not have these installed, WhispAssist will still work, but some features will be unavailable.
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases)
|
||||
- [ffmpeg](https://www.ffmpeg.org/download.html#build-windows)
|
||||
- [libpst](https://sourceforge.net/projects/ezwinports/files/libpst-0.6.63-w32-bin.zip/)
|
||||
|
||||
## Technology
|
||||
|
||||
WhispAssist is a **Tauri 2** application: a small Rust core with a compiled **Svelte + TypeScript**
|
||||
@@ -121,7 +152,7 @@ and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0011).
|
||||
- **Storage:** SQLite + on-disk audio/transcript files
|
||||
- **Local LLM:** Ollama HTTP API (localhost or a private LAN endpoint)
|
||||
- **Encryption:** Argon2id + XChaCha20-Poly1305 envelope vault; secrets in the OS credential store
|
||||
- **Calendar / Outlook:** `outlook-pst` for `.pst`, OS notifications for reminders *(in progress)*
|
||||
- **Calendar / Outlook:** `readpst` for `.pst` import, OS scheduled toasts for action-item reminders
|
||||
- **Sync:** WebDAV primary set + OneDrive/Dropbox/Box via OAuth — off by default (ADR-0010)
|
||||
- **External AI / agents:** hosted providers behind `LlmProvider`; a loopback-only **MCP server**
|
||||
for coding-agent handoff — off by default (ADR-0011)
|
||||
|
||||
+45
-4
@@ -16,17 +16,58 @@ Default root: `%LOCALAPPDATA%\WhispAssist\` (user-configurable, FR-STORE-2).
|
||||
└── <meeting_id>\ # one folder per meeting (uuid)
|
||||
├── audio.wav # canonical recording — present ONLY if "Record" was on (ADR-0009)
|
||||
├── transcript.json # canonical transcript (segments+speakers+timings)
|
||||
├── notes.md # user-editable Markdown notes
|
||||
├── manual_notes.json # raw user-authored notes captured live during recording
|
||||
├── notes.md # the final notes document: manual notes + transcript, merged at finalize
|
||||
├── summary.json # LLM summary, decisions, action items (if generated)
|
||||
└── briefs/ # feature briefs distilled from this meeting (ADR-0011), if any
|
||||
└── <brief_id>.json # agent-ready spec served via the MCP `get_feature_brief` tool
|
||||
```
|
||||
|
||||
A **bundle export** (`export_meeting` / `bulk_export_meetings` with `format: "bundle"`, FR-STORE-4)
|
||||
copies a meeting's `audio.wav`, `transcript.json`, `notes.md`, and `summary.json` (all decrypted)
|
||||
into a destination folder plus a `meeting.json` manifest (the `MeetingBundle`: title, timestamps,
|
||||
duration, language/backend/model, tags, speakers, and confirmed action items). `import_meeting_bundle`
|
||||
reconstructs each such folder under a fresh meeting id — the portable format for moving recordings
|
||||
between computers.
|
||||
|
||||
Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On
|
||||
finalize, it is **kept** as `audio.wav` if "Record this meeting" was on, or **deleted** if not
|
||||
(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json`,
|
||||
`notes.md`, and `summary.json` are **derived** and regenerable (regenerable only while the audio
|
||||
still exists — i.e. for recorded meetings).
|
||||
(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json` and
|
||||
`summary.json` are **derived** and regenerable (regenerable only while the audio still exists —
|
||||
i.e. for recorded meetings).
|
||||
|
||||
`notes.md` is **generated once, at finalize**, by merging `manual_notes.json` (freeform notes
|
||||
typed live during the recording, plus any per-moment annotations — see below) with the rendered,
|
||||
speaker-tagged transcript (`notes::MarkdownNotes::merge`). After that it is the user's own
|
||||
document, freely editable via `update_notes` exactly like before this changed — nothing
|
||||
re-renders or overwrites it afterward. In particular, renaming or merging a speaker after finalize
|
||||
updates the `speakers` table and the live UI display, but does **not** retroactively rewrite text
|
||||
already baked into `notes.md` (same as any other manual edit isn't retroactively touched either —
|
||||
this was a pre-existing clobber bug this redesign also fixes: renaming a speaker used to silently
|
||||
overwrite the whole file). A crash-recovery finalize (T2.8) and a post-finalize batch
|
||||
re-transcription (T3.8) both re-render `notes.md` from scratch and so both re-read
|
||||
`manual_notes.json` from disk to fold the same manual notes back in.
|
||||
|
||||
### `manual_notes.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": 1,
|
||||
"freeform_md": "string — the user's running notes, typed live in the Notes pane while recording",
|
||||
"segment_notes": [
|
||||
{ "anchor_ms": 12345, "text": "string", "created_at": 1735000000, "updated_at": 1735000010 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`segment_notes[].anchor_ms` is a timestamp into the recording (a clicked transcript segment's
|
||||
`start_ms`), not a segment id — a later batch re-transcription can renumber/regenerate segment
|
||||
ids, but never moves the moment in time a note was attached to. At merge time, each note is placed
|
||||
right after whichever transcript paragraph's time span contains its `anchor_ms`; a note whose
|
||||
anchor doesn't land inside any paragraph surfaces under an "Other notes" section instead of being
|
||||
silently dropped. Written to disk on every edit via `update_live_notes`/`set_segment_note`
|
||||
(`04-api-contracts.md`) — live-session only, same write-through-for-crash-safety spirit as
|
||||
`transcript.json` accumulating during recording.
|
||||
|
||||
## SQLite schema (`wa.db`)
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ resume_recording(input: { meetingId: MeetingId }): void
|
||||
set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1)
|
||||
acknowledge_recording_consent(): void // one-time (FR-REC-2)
|
||||
|
||||
// ---- Live notes (Granola-style redesign, `03-data-model.md`'s manual_notes.json) ----
|
||||
// Both are live-session only (err "no matching active recording" once finalized — post-finalize,
|
||||
// notes.md is the single editable document and `update_notes` is the command for it).
|
||||
update_live_notes(input: { meetingId: MeetingId; markdown: string }): void // freeform notes typed while recording
|
||||
// anchorMs: the clicked transcript segment's start_ms, not its id (survives re-transcription).
|
||||
// text: "" clears that moment's note. Merged into notes.md right after the transcript paragraph
|
||||
// covering anchorMs when the meeting finalizes (notes::MarkdownNotes::merge).
|
||||
set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void
|
||||
|
||||
// ---- Hardware ----
|
||||
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number }
|
||||
set_preferred_backend(input: { backend: BackendId | "auto" }): void
|
||||
@@ -54,9 +63,21 @@ map_speaker_to_participant(input: { meetingId: MeetingId; label: string; partici
|
||||
// yet at local-desktop meeting counts) and gained from/to date filters, per
|
||||
// FR-SEARCH-2's "filter by date, tag, or participant".
|
||||
list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[]
|
||||
// Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source
|
||||
// of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment
|
||||
// keeps start_ms/end_ms; the UI renders these as per-line timestamps.
|
||||
get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated)
|
||||
delete_meeting(input: { meetingId: MeetingId }): void
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string
|
||||
// format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json
|
||||
// (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine.
|
||||
// format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio):
|
||||
// YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions
|
||||
// + action items + timestamped transcript. For dropping a meeting into an Obsidian vault.
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): string
|
||||
// Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary,
|
||||
// reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5):
|
||||
// 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
|
||||
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
|
||||
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
|
||||
@@ -68,6 +89,10 @@ list_note_templates(): NoteTemplate[]
|
||||
// Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle
|
||||
// folder) per meeting under destDir. Returns the count actually exported.
|
||||
bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number
|
||||
// Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent
|
||||
// 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
|
||||
|
||||
// ---- LLM / AI provider (ADR-0007/0011) ----
|
||||
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
|
||||
@@ -83,9 +108,19 @@ llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggest
|
||||
pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7)
|
||||
|
||||
// ---- Calendar / .pst ----
|
||||
import_pst(input: { path: string; password?: string }): number // eventsImported; emits pst://progress (FR-CAL-1)
|
||||
// rangeDays: only import events starting within the last N days; omitted imports the full mailbox
|
||||
// history. Bug fix: a long-lived .pst has no natural upper bound on history (every recurring
|
||||
// series expands to its cap, T4.1's RECURRENCE_MAX_OCCURRENCES, plus every one-off entry the file
|
||||
// ever held, e.g. a decade of Outlook's auto-generated yearly holidays) -- unbounded import could
|
||||
// produce tens of thousands of rows. Settings.pst_import_range_days persists the last choice and
|
||||
// applies it to pst_auto_sync's startup re-import too.
|
||||
import_pst(input: { path: string; password?: string; rangeDays?: number }): number // eventsImported; emits pst://progress (FR-CAL-1)
|
||||
list_calendar_events(input: { from?: number; to?: number }): CalendarEvent[]
|
||||
get_calendar_event(input: { eventId: string }): { event: CalendarEvent; participants: Participant[] } // pre-meeting panel + naming dropdown (FR-CAL-3, FR-SPK-4)
|
||||
// olderThanDays omitted deletes every unlinked event ("Delete all"); Some(n) only those starting
|
||||
// more than n days ago. An event attached to a recorded meeting (meetings.calendar_event_id) is
|
||||
// always kept regardless of the choice -- protected reports how many were skipped for that reason.
|
||||
cleanup_calendar_events(input: { olderThanDays?: number }): { deleted: number; protected: number }
|
||||
attach_meeting_to_event(input: { meetingId: MeetingId; eventId: string }): void
|
||||
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6): opt-in, explicit consent (OAuth PKCE
|
||||
// + Microsoft's own consent screen), metadata-only (subject/organizer/start/end/attendees, never
|
||||
@@ -240,6 +275,9 @@ pub trait NotesRenderer: Send + Sync {
|
||||
fn to_markdown(&self, t: &Transcript, speakers: &[SpeakerInfo], s: Option<&Summary>) -> String;
|
||||
fn export(&self, md: &str, dest: &Path, fmt: ExportFormat) -> Result<PathBuf, NotesError>;
|
||||
}
|
||||
// MarkdownNotes::merge(segments, speakers, manual: &ManualNotes, summary, template) -> String is an
|
||||
// inherent method (not part of the trait — only one renderer needs it): what `stop_recording` calls
|
||||
// instead of `to_markdown` to fold manual_notes.json into the generated notes.md (see 03-data-model.md).
|
||||
|
||||
// sync/mod.rs
|
||||
// One impl per provider; `WebDavTarget` covers Nextcloud/ownCloud/Cloudreve/Seafile/Synology.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# i18n migration tracking
|
||||
|
||||
Working doc for the incremental UI-string translation effort. The i18n **mechanism**
|
||||
is done; this tracks moving the app's remaining hardcoded strings into the translation
|
||||
files, one batch at a time. Tick boxes as views are converted.
|
||||
|
||||
> **Status (2026-07-12): migration complete.** All views and components (Batches A–F) are
|
||||
> converted; `en.json` holds ~506 keys. Every user-facing English string flows through
|
||||
> `t()`. What remains is intentionally-untranslated data (see the "Not translated" section)
|
||||
> — plus the actual work of adding a second language, which is now just translating
|
||||
> `en.json` into a new `<code>.json`.
|
||||
|
||||
- **Engine:** hand-rolled, zero-dependency. `src/lib/i18n/index.svelte.ts`
|
||||
- **Baseline dictionary:** `src/lib/i18n/en.json` (English is the source-of-truth key set
|
||||
**and** the fallback for any missing key)
|
||||
- **Selector:** Settings → Language (`section === "language"`)
|
||||
- **Scope decision:** display language is separate from the transcription `whisper_language`
|
||||
setting — don't conflate them.
|
||||
|
||||
## What we translate (and what we never do)
|
||||
|
||||
**Translate: UI chrome only** — fixed labels, buttons, headings, placeholders,
|
||||
empty/loading/status states, tooltips, and app-generated default *labels*.
|
||||
|
||||
**Never translate: user-authored content.** Meeting titles, tag names, notes, transcript
|
||||
text, search snippets, dates — these are bound straight from data (`item.title`, tag
|
||||
values, `s.text`, …) and stay exactly as the user wrote them. If a string comes from the
|
||||
user or the recording, it does not get a key.
|
||||
|
||||
So "convert view X" always means "key its fixed labels", never "touch its content". Most
|
||||
views are mostly content with a thin shell of labels — MeetingsList, for example, is ~10
|
||||
fixed labels (search placeholder, empty states, filter/bulk-export labels) wrapped around
|
||||
a list whose rows are pure user data.
|
||||
|
||||
> Caveat — the `"Untitled meeting"` **default title is generated in the Rust backend**, not
|
||||
> the frontend, so it never reaches `t()`. Localizing app-generated defaults is a separate
|
||||
> backend decision, out of scope for this frontend effort.
|
||||
|
||||
## How to convert a string (the pattern)
|
||||
|
||||
1. Add a key to `en.json`. Naming: `<area>.<subarea>.<name>`, dotted, grouped by view —
|
||||
e.g. `nav.recording`, `settings.transcription.title`, `meetings.empty`.
|
||||
2. Replace the literal in markup with `{t("key")}` (import `t` from `../i18n/index.svelte`).
|
||||
3. Dynamic bits use placeholders: `t("meetings.count", { n })` against
|
||||
`"meetings.count": "{n} meetings"`. Handle plurals with a caller-side ternary for now
|
||||
(`n === 1 ? t("...one") : t("...many")`) — the engine is intentionally simple.
|
||||
4. Attributes translate the same way: `title={t("...")}`, `aria-label={t("...")}`,
|
||||
`placeholder={t("...")}`.
|
||||
5. `npm run check` must stay at 0 errors/warnings.
|
||||
|
||||
> When adding a **new language** (not covered by this doc's batches): copy `en.json` to
|
||||
> `<code>.json`, translate it, and register it in `DICTS` + `LOCALES` in
|
||||
> `index.svelte.ts`. That one file is the whole job.
|
||||
|
||||
## Done
|
||||
|
||||
- [x] i18n engine + `en.json` baseline + Settings language selector (branch
|
||||
`feature_chore_bug_007`)
|
||||
- [x] `Settings.svelte` — nav labels (`nav.*`), the Language section
|
||||
(`settings.language.*`), and the Transcription Language section
|
||||
(`settings.transcription.*`)
|
||||
|
||||
- [x] `src/App.svelte` — app shell chrome (`app.*`): header (tagline, template
|
||||
picker, record/stop/cancel/add-meeting, recording status, retention, backend,
|
||||
device-notice), Settings button, consent + vault-unlock dialogs, pane toggles,
|
||||
splitter labels, and JS strings (discard confirm, SR announcements, vault error).
|
||||
- [x] `src/lib/views/MeetingsList.svelte` — labels only (`meetings.*`): search/filter/
|
||||
bulk-export controls, empty/loading/status states, status badges (via
|
||||
`statusLabel`), resume + delete, delete-confirm, and the pluralized export result.
|
||||
List rows (titles/tags/dates/snippets) left as user data.
|
||||
- [x] `src/lib/views/TranscriptNotes.svelte` — transcript + notes chrome (`transcript.*`,
|
||||
`notes.*`): pane headings/toggles, reprocess controls, notes toolbar (bold/heading/
|
||||
list/preview), export button tooltips + dialog filter names, empty states, segment
|
||||
tooltips + note placeholders. Transcript/notes/speaker text left as user data.
|
||||
- [x] `src/lib/views/SummaryPanel.svelte` — all panel chrome (`summary.*`): section
|
||||
headings (Recording/Sync/Tags/Summary/Briefs/Action items/Calendar/Participants/
|
||||
Speakers), buttons, placeholders, empty states, provider labels (via `providerLabel`;
|
||||
brand names kept literal), action-item + brief + speaker controls. Summary text,
|
||||
tags, brief content, participant/speaker names left as user data.
|
||||
- [x] `src/lib/views/Settings.svelte` — **all sections** done (`settings.*`): the modal
|
||||
shell (dialog title, Close), plus recording, hardware (incl. models/diarization),
|
||||
storage (+ export/import status), calendar (.pst import/cleanup/events), sync
|
||||
(targets + WebDAV/OAuth forms), AI (provider + Ollama advanced params), MCP
|
||||
(transport/scope/token/access log), privacy (egress self-check + vault), about.
|
||||
OLLAMA_OPTIONS param catalog labels/help left as data (config catalog, like model
|
||||
ids); example URLs/model-id placeholders left literal.
|
||||
|
||||
- [x] Components (Batch F): `ConsentNotice` + `HostedAiBanner` (legal/consent copy,
|
||||
`consent.*` / `hosted.*`), `ThemeToggle` (`theme.*`), `ImportMeeting` (`import.*`),
|
||||
`TagChip` (`tagchip.*`), `LevelMeter` (`levelmeter.*`). `Splitter`'s `label` is
|
||||
caller-supplied and already translated by the parent; no strings of its own.
|
||||
|
||||
## Outstanding — suggested batches
|
||||
|
||||
_None — all batches complete._ The section below is kept as a record of the plan.
|
||||
|
||||
Ordered roughly by user-visibility ÷ effort. Sizes are rough (line count / labeled
|
||||
attributes) to help portion the work, not exact string counts. A file isn't "done" until
|
||||
its visible text **and** its `title`/`aria-label`/`placeholder` attributes are keyed.
|
||||
|
||||
### ~~Batch A — app shell~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch B — meetings list~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch C — transcript & notes~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch D — summary panel~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch E — Settings, all sections~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
All nine sections + the modal shell converted, one commit per section. Moved to the Done
|
||||
list above.
|
||||
|
||||
### ~~Batch F — components~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
## Not translated (intentional)
|
||||
|
||||
- **User-authored content** — meeting titles, tag names, notes, transcript text, search
|
||||
snippets. Bound from data; stays as the user wrote it (see "What we translate" above).
|
||||
- App-generated defaults created in the backend (e.g. `"Untitled meeting"`) — a separate
|
||||
backend concern; the frontend `t()` never sees them.
|
||||
- Backend / Rust error messages surfaced via `WaError` — out of scope for the frontend
|
||||
`t()`; revisit only if we localize command errors.
|
||||
- Provider/proper names (Ollama, Anthropic, OpenAI, Obsidian, WebDAV, WhispAssist),
|
||||
model ids, ISO language codes.
|
||||
- Console/`tracing` logs.
|
||||
|
||||
## Batch log
|
||||
|
||||
Record each landed batch here (date / branch / commit) so progress is auditable.
|
||||
|
||||
| Date | Batch | Branch / commit | Notes |
|
||||
|------|-------|-----------------|-------|
|
||||
| 2026-07-12 | Infra + Settings nav/language/transcription | `feature_chore_bug_007` | engine + en.json + selector |
|
||||
| 2026-07-12 | Batch A (App shell) + Batch B (MeetingsList) | `feature_chore_bug_007` | +~60 keys; `app.*`, `meetings.*` |
|
||||
| 2026-07-12 | Batch C (TranscriptNotes) + Batch D (SummaryPanel) | `feature_chore_bug_007` | +~120 keys; `transcript.*`, `notes.*`, `summary.*`; en.json now 198 keys |
|
||||
| 2026-07-12 | Batch E (Settings, all 9 sections + shell) | `feature_chore_bug_007` | +~270 keys; `settings.*`; en.json now 470 keys; one commit per section |
|
||||
| 2026-07-12 | Batch F (components) | `feature_chore_bug_007` | +~36 keys; `consent.*`/`hosted.*`/`theme.*`/`import.*`/`tagchip.*`/`levelmeter.*`; en.json now 506 keys — migration complete |
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "whispassist",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "Privacy-first, fully local Windows meeting assistant.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
Generated
+1
-1
@@ -6043,7 +6043,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whispassist"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whispassist"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Privacy-first, fully local Windows meeting assistant"
|
||||
authors = ["WhispAssist contributors"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Bug fix: search (FR-SEARCH-1) never covered summary or tags -- meeting_fts
|
||||
-- only ever had title/transcript_text/notes_text columns. `meeting_fts` is a
|
||||
-- derived index (rebuilt from meetings/transcript.json/notes.md/summary.json/
|
||||
-- tags, never a source of truth), so dropping and recreating it is safe: no
|
||||
-- data loss, and `backfill_fts` repopulates every meeting on next startup
|
||||
-- since a freshly created table has no rows for anything yet.
|
||||
DROP TABLE meeting_fts;
|
||||
|
||||
CREATE VIRTUAL TABLE meeting_fts USING fts5(
|
||||
meeting_id UNINDEXED,
|
||||
title,
|
||||
transcript_text,
|
||||
notes_text,
|
||||
summary_text,
|
||||
tags_text,
|
||||
tokenize = 'porter unicode61'
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Bug fix: prior imports could accumulate many duplicate rows for the same
|
||||
-- real calendar event on every re-import -- either because the (source,
|
||||
-- raw_uid) unique index (added in 0005) never retroactively deduped rows
|
||||
-- that existed before it, or because an event with no UID in its source data
|
||||
-- got `raw_uid = NULL`, which that index's `WHERE raw_uid IS NOT NULL` clause
|
||||
-- explicitly exempts from uniqueness, so it duplicated on every single
|
||||
-- re-import forever. This collapses whatever's already in the table, then
|
||||
-- backfills a stable content-based key for anything still missing a UID so
|
||||
-- future re-imports resolve to the same row instead of minting a new one
|
||||
-- (see calendar::content_uid in src/calendar/mod.rs, which produces the
|
||||
-- identical 'content:subject|organizer|starts_at|ends_at' format used here).
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Drop the index first: the backfill below can momentarily produce rows that
|
||||
-- share a (source, raw_uid) pair before they're deduped a few statements
|
||||
-- later, which the index would reject mid-UPDATE.
|
||||
DROP INDEX IF EXISTS idx_calendar_events_source_uid;
|
||||
|
||||
UPDATE calendar_events
|
||||
SET raw_uid = 'content:' || COALESCE(subject, '') || '|' || COALESCE(organizer, '')
|
||||
|| '|' || COALESCE(starts_at, 0) || '|' || COALESCE(ends_at, 0)
|
||||
WHERE raw_uid IS NULL;
|
||||
|
||||
-- One survivor per (source, raw_uid) group: whichever row a meeting is
|
||||
-- already attached to (so `attach_meeting_to_event` links don't break), else
|
||||
-- the lexicographically-first id (arbitrary but deterministic).
|
||||
CREATE TEMP TABLE calendar_event_survivors AS
|
||||
SELECT source, raw_uid, MIN(id) AS keep_id
|
||||
FROM calendar_events
|
||||
GROUP BY source, raw_uid;
|
||||
|
||||
UPDATE calendar_event_survivors
|
||||
SET keep_id = (
|
||||
SELECT m.calendar_event_id FROM meetings m
|
||||
JOIN calendar_events ce ON ce.id = m.calendar_event_id
|
||||
WHERE ce.source = calendar_event_survivors.source
|
||||
AND ce.raw_uid = calendar_event_survivors.raw_uid
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM meetings m
|
||||
JOIN calendar_events ce ON ce.id = m.calendar_event_id
|
||||
WHERE ce.source = calendar_event_survivors.source
|
||||
AND ce.raw_uid = calendar_event_survivors.raw_uid
|
||||
);
|
||||
|
||||
-- Repoint any meeting attached to a duplicate that's about to be deleted
|
||||
-- onto the group's survivor instead.
|
||||
UPDATE meetings
|
||||
SET calendar_event_id = (
|
||||
SELECT s.keep_id FROM calendar_event_survivors s
|
||||
JOIN calendar_events ce ON ce.source = s.source AND ce.raw_uid = s.raw_uid
|
||||
WHERE ce.id = meetings.calendar_event_id
|
||||
)
|
||||
WHERE calendar_event_id IN (
|
||||
SELECT ce.id FROM calendar_events ce
|
||||
JOIN calendar_event_survivors s ON ce.source = s.source AND ce.raw_uid = s.raw_uid
|
||||
WHERE ce.id != s.keep_id
|
||||
);
|
||||
|
||||
-- Drop the duplicates (cascades to calendar_event_participants).
|
||||
DELETE FROM calendar_events
|
||||
WHERE id NOT IN (SELECT keep_id FROM calendar_event_survivors);
|
||||
|
||||
DROP TABLE calendar_event_survivors;
|
||||
|
||||
CREATE UNIQUE INDEX idx_calendar_events_source_uid ON calendar_events(source, raw_uid)
|
||||
WHERE raw_uid IS NOT NULL;
|
||||
+108
-25
@@ -58,6 +58,9 @@ pub type FrameSink = SyncSender<Vec<f32>>;
|
||||
pub struct AudioLevel {
|
||||
pub rms: f32,
|
||||
pub peak: f32,
|
||||
/// `true` for the microphone stream, `false` for system/loopback audio —
|
||||
/// lets the UI overlay the two meters in different colours (FR-CAP-5/7).
|
||||
pub mic: bool,
|
||||
}
|
||||
|
||||
/// Out-of-band capture notices, lower-volume than `FrameSink`/level updates.
|
||||
@@ -200,10 +203,48 @@ impl MicBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-shot, bounded capture of raw mic-only audio (16kHz mono, same format
|
||||
/// the transcriber and diarizer both use) taken early in a recording — enough
|
||||
/// to compute a voiceprint that identifies which diarized speaker cluster is
|
||||
/// the mic (so it can be labeled "You" instead of a clustered "S1"/"S2"; see
|
||||
/// `diarization::voiceprint`). Unlike `MicBridge`, this is filled once and
|
||||
/// never drained: the first `cap` samples are kept and everything after is
|
||||
/// dropped, since a voiceprint only needs a few seconds of real speech, not
|
||||
/// the whole meeting.
|
||||
pub struct VoiceSample {
|
||||
cap: usize,
|
||||
buf: Mutex<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl VoiceSample {
|
||||
pub fn new(cap_samples: usize) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
cap: cap_samples,
|
||||
buf: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn push(&self, samples: &[f32]) {
|
||||
if let Ok(mut buf) = self.buf.lock() {
|
||||
if buf.len() < self.cap {
|
||||
buf.extend_from_slice(samples);
|
||||
buf.truncate(self.cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples captured so far (may be shorter than `cap` on a short
|
||||
/// recording, or empty if the mic never produced usable audio).
|
||||
pub fn samples(&self) -> Vec<f32> {
|
||||
self.buf.lock().map(|b| b.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of audio frames in a raw WASAPI byte buffer of the given format.
|
||||
#[cfg(feature = "audio")]
|
||||
fn frame_count(bytes: &[u8], format: &WaveFormat) -> usize {
|
||||
let bytes_per_frame = (format.get_bitspersample() as usize / 8) * format.get_nchannels() as usize;
|
||||
let bytes_per_frame =
|
||||
(format.get_bitspersample() as usize / 8) * format.get_nchannels() as usize;
|
||||
if bytes_per_frame == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -231,6 +272,8 @@ impl WasapiCapture {
|
||||
frame_sink: FrameSink,
|
||||
event_sink: EventSink,
|
||||
bridge: Option<Arc<MicBridge>>,
|
||||
voice_sample: Option<Arc<VoiceSample>>,
|
||||
emit_level: bool,
|
||||
) -> Result<CaptureHandle, AudioError> {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
@@ -251,6 +294,8 @@ impl WasapiCapture {
|
||||
&running_th,
|
||||
&paused_th,
|
||||
bridge.as_ref(),
|
||||
voice_sample.as_ref(),
|
||||
emit_level,
|
||||
)
|
||||
})
|
||||
.map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?;
|
||||
@@ -281,17 +326,22 @@ impl WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// Microphone capture that both feeds the live-transcript mixer (`frame_sink`)
|
||||
/// and pushes its audio into `bridge` for the loopback thread to record.
|
||||
/// `voice_sample`, when given, also collects the first few seconds of raw
|
||||
/// mic audio for a post-recording voiceprint match (mic-speaker labeling).
|
||||
pub fn start_microphone_recording(
|
||||
&self,
|
||||
device_id: Option<&str>,
|
||||
frame_sink: FrameSink,
|
||||
event_sink: EventSink,
|
||||
bridge: Arc<MicBridge>,
|
||||
voice_sample: Option<Arc<VoiceSample>>,
|
||||
) -> Result<CaptureHandle, AudioError> {
|
||||
self.start_capture(
|
||||
"wa-mic-capture",
|
||||
@@ -301,6 +351,8 @@ impl WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
voice_sample,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -322,6 +374,8 @@ impl AudioCapture for WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -339,6 +393,8 @@ impl AudioCapture for WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -376,7 +432,10 @@ struct CaptureSession {
|
||||
/// was picked) — same "degrade gracefully rather than fail the recording" spirit
|
||||
/// as the device-recovery reconnect below.
|
||||
#[cfg(feature = "audio")]
|
||||
fn find_device(direction: &Direction, device_id: Option<&str>) -> Result<wasapi::Device, AudioError> {
|
||||
fn find_device(
|
||||
direction: &Direction,
|
||||
device_id: Option<&str>,
|
||||
) -> Result<wasapi::Device, AudioError> {
|
||||
let Some(id) = device_id else {
|
||||
return wasapi::get_default_device(direction)
|
||||
.map_err(|e| AudioError::Device(format!("no default audio device: {e}")));
|
||||
@@ -458,12 +517,14 @@ fn format_compatible(a: &WaveFormat, b: &WaveFormat) -> bool {
|
||||
}
|
||||
|
||||
/// Amplitude of one chunk of mono samples, for the live level meter (FR-CAP-5).
|
||||
/// `mic` tags which stream this level came from so the UI can overlay both.
|
||||
#[cfg(feature = "audio")]
|
||||
fn audio_level(mono: &[f32]) -> AudioLevel {
|
||||
fn audio_level(mono: &[f32], mic: bool) -> AudioLevel {
|
||||
if mono.is_empty() {
|
||||
return AudioLevel {
|
||||
rms: 0.0,
|
||||
peak: 0.0,
|
||||
mic,
|
||||
};
|
||||
}
|
||||
let sum_sq: f32 = mono.iter().map(|s| s * s).sum();
|
||||
@@ -471,6 +532,7 @@ fn audio_level(mono: &[f32]) -> AudioLevel {
|
||||
AudioLevel {
|
||||
rms: (sum_sq / mono.len() as f32).sqrt(),
|
||||
peak,
|
||||
mic,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,15 +554,16 @@ fn capture_loop(
|
||||
running: &AtomicBool,
|
||||
paused: &AtomicBool,
|
||||
bridge: Option<&Arc<MicBridge>>,
|
||||
voice_sample: Option<&Arc<VoiceSample>>,
|
||||
emit_level: bool,
|
||||
) -> Result<CaptureSummary, AudioError> {
|
||||
wasapi::initialize_mta()
|
||||
.ok()
|
||||
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
|
||||
|
||||
let is_loopback = matches!(direction, Direction::Render);
|
||||
// Only the loopback stream emits level updates (FR-CAP-5) and writes a WAV;
|
||||
// the mic stream just contributes frames to the transcript mixer (FR-CAP-7).
|
||||
let emit_level = wav_path.is_some();
|
||||
// Both streams emit level updates (FR-CAP-5/7) — tagged by `mic` below so the
|
||||
// UI can overlay them — but only the loopback stream writes a WAV.
|
||||
let mut session = open_capture_session(&direction, device_id)?;
|
||||
// Loopback publishes its rate so the mic knows what to resample to before
|
||||
// pushing into the shared bridge (mic-into-recording, FR-CAP-7).
|
||||
@@ -612,12 +675,20 @@ fn capture_loop(
|
||||
}
|
||||
|
||||
if emit_level && last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL {
|
||||
let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono)));
|
||||
let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono, !is_loopback)));
|
||||
last_level_emit = Instant::now();
|
||||
}
|
||||
|
||||
let resampled = resampler.process(&mono);
|
||||
if !resampled.is_empty() {
|
||||
// Mic-only, best-effort: a few seconds of raw mic audio for the
|
||||
// post-recording voiceprint match (see `VoiceSample`). No-ops
|
||||
// itself once its cap is reached.
|
||||
if !is_loopback {
|
||||
if let Some(vs) = voice_sample {
|
||||
vs.push(&resampled);
|
||||
}
|
||||
}
|
||||
let _ = frame_sink.try_send(resampled); // drop on backpressure; disk write is unaffected
|
||||
}
|
||||
}
|
||||
@@ -688,7 +759,8 @@ fn write_wav_bytes(
|
||||
for frame in bytes.chunks_exact(2 * channels) {
|
||||
let add = mic.get(frames as usize).copied().unwrap_or(0.0);
|
||||
for c in frame.chunks_exact(2) {
|
||||
let v = i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32 + add;
|
||||
let v =
|
||||
i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32 + add;
|
||||
writer
|
||||
.write_sample(f32_to_i16(v))
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
@@ -1013,6 +1085,16 @@ mod tests {
|
||||
assert!(m.loopback.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_sample_caps_and_stops_accepting_once_full() {
|
||||
let vs = VoiceSample::new(4);
|
||||
vs.push(&[1.0, 2.0, 3.0]);
|
||||
vs.push(&[4.0, 5.0]); // would overflow the cap of 4
|
||||
assert_eq!(vs.samples(), vec![1.0, 2.0, 3.0, 4.0]);
|
||||
vs.push(&[9.0]); // already full — ignored
|
||||
assert_eq!(vs.samples(), vec![1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_writer_writes_16bit_from_a_float_mix_and_keeps_duration() {
|
||||
// A 32-bit-float mix is captured but written as 16-bit PCM at the same
|
||||
@@ -1063,10 +1145,7 @@ mod tests {
|
||||
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||
let mut writer = WavWriter::create(&path, wav_spec_for(&format).unwrap()).unwrap();
|
||||
// One stereo frame of silence (L=0.0, R=0.0).
|
||||
let bytes: Vec<u8> = [0.0f32, 0.0]
|
||||
.iter()
|
||||
.flat_map(|s| s.to_le_bytes())
|
||||
.collect();
|
||||
let bytes: Vec<u8> = [0.0f32, 0.0].iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
let frames = write_wav_bytes(&mut writer, &bytes, &format, &[0.5]).unwrap();
|
||||
writer.finalize().unwrap();
|
||||
assert_eq!(frames, 1);
|
||||
@@ -1328,17 +1407,21 @@ mod tests {
|
||||
let (ev_tx, _ev) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
||||
let (ev_tx2, _ev2) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
||||
|
||||
let loop_h =
|
||||
match WasapiCapture.start_loopback_recording(&path, None, loop_tx, ev_tx, bridge.clone())
|
||||
{
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("[mixrec] no loopback device: {e} (skipping)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let loop_h = match WasapiCapture.start_loopback_recording(
|
||||
&path,
|
||||
None,
|
||||
loop_tx,
|
||||
ev_tx,
|
||||
bridge.clone(),
|
||||
) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("[mixrec] no loopback device: {e} (skipping)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mic_h = WasapiCapture
|
||||
.start_microphone_recording(None, mic_tx, ev_tx2, bridge)
|
||||
.start_microphone_recording(None, mic_tx, ev_tx2, bridge, None)
|
||||
.ok();
|
||||
|
||||
let _ = std::process::Command::new("powershell")
|
||||
@@ -1401,7 +1484,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn audio_level_of_empty_chunk_is_silence() {
|
||||
let level = audio_level(&[]);
|
||||
let level = audio_level(&[], false);
|
||||
assert_eq!(level.rms, 0.0);
|
||||
assert_eq!(level.peak, 0.0);
|
||||
}
|
||||
@@ -1409,14 +1492,14 @@ mod tests {
|
||||
#[test]
|
||||
fn audio_level_computes_rms_and_peak() {
|
||||
// Two samples of equal magnitude: RMS equals that magnitude, peak too.
|
||||
let level = audio_level(&[0.5, -0.5]);
|
||||
let level = audio_level(&[0.5, -0.5], false);
|
||||
assert!((level.rms - 0.5).abs() < 1e-6);
|
||||
assert!((level.peak - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_level_peak_tracks_the_largest_magnitude_sample() {
|
||||
let level = audio_level(&[0.1, -0.9, 0.3]);
|
||||
let level = audio_level(&[0.1, -0.9, 0.3], false);
|
||||
assert!((level.peak - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
|
||||
+178
-14
@@ -62,10 +62,41 @@ impl CalendarSource for PstSource {
|
||||
|
||||
let result = run_readpst(&input.path, &out_dir).and_then(|_| collect_events(&out_dir));
|
||||
let _ = std::fs::remove_dir_all(&out_dir); // best-effort cleanup either way
|
||||
result
|
||||
|
||||
// Bug fix: `from`/`to` used to be silently ignored for PST (only
|
||||
// Graph honored a range) — a long-lived mailbox has no natural
|
||||
// upper bound on history, so "import everything" meant every
|
||||
// recurring series expanded across its full lifetime (up to 500
|
||||
// occurrences each, T4.1's RECURRENCE_MAX_OCCURRENCES) plus every
|
||||
// one-off entry (e.g. a decade of Outlook's auto-generated yearly
|
||||
// holidays) the file has ever held. Filtering here, after parsing,
|
||||
// is the simplest correct place: it doesn't need to change how
|
||||
// readpst is invoked or how recurrence expansion works.
|
||||
result.map(|events| filter_by_range(events, input.from, input.to))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pst")]
|
||||
fn filter_by_range(
|
||||
events: Vec<ImportedEvent>,
|
||||
from: Option<i64>,
|
||||
to: Option<i64>,
|
||||
) -> Vec<ImportedEvent> {
|
||||
if from.is_none() && to.is_none() {
|
||||
return events;
|
||||
}
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| match e.event.starts_at {
|
||||
// An event with no known start time can't be range-tested —
|
||||
// keep it rather than silently drop something the user might
|
||||
// still want (this is rare; most PST appointments have a start).
|
||||
None => true,
|
||||
Some(start) => from.map_or(true, |f| start >= f) && to.map_or(true, |t| start <= t),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "pst")]
|
||||
fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
|
||||
// -S: one file per item. -e: extension matches item type (.ics for
|
||||
@@ -78,15 +109,25 @@ fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
|
||||
// still handled safely (collect_ics_files skips a file that doesn't
|
||||
// read as valid UTF-8 rather than erroring), just silently dropped
|
||||
// instead of correctly decoded. Revisit if that's observed in practice.
|
||||
let output = Command::new("readpst")
|
||||
.args(["-S", "-e", "-t", "a", "-o"])
|
||||
let mut cmd = Command::new("readpst");
|
||||
cmd.args(["-S", "-e", "-t", "a", "-o"])
|
||||
.arg(out_dir)
|
||||
.arg(pst_path)
|
||||
.output()
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => CalError::ToolMissing,
|
||||
_ => CalError::Open(e.to_string()),
|
||||
})?;
|
||||
.arg(pst_path);
|
||||
// Bug fix: readpst.exe is a console-subsystem binary, and WhispAssist is
|
||||
// a GUI app with no console of its own — Windows was popping a brand
|
||||
// new console window for it on every import. CREATE_NO_WINDOW spawns it
|
||||
// fully headless instead; readpst's own stdout/stderr are still
|
||||
// captured normally via `.output()` below.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => CalError::ToolMissing,
|
||||
_ => CalError::Open(e.to_string()),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
// readpst writes some errors to stdout rather than stderr; show
|
||||
// whichever stream actually has text, stderr first.
|
||||
@@ -460,6 +501,17 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Bug fix: a VEVENT with no UID line used to import
|
||||
// with `raw_uid: None`, which the dedup unique index
|
||||
// (source, raw_uid) explicitly exempts (`WHERE
|
||||
// raw_uid IS NOT NULL`) — so every re-import created
|
||||
// a brand-new duplicate row for it forever. Fall back
|
||||
// to a deterministic hash of the event's own content
|
||||
// so re-imports of the same source still resolve to
|
||||
// the same key and upsert instead of duplicating.
|
||||
let raw_uid = uid.take().or_else(|| {
|
||||
Some(content_uid(&summary, &organizer, starts_at, ends_at))
|
||||
});
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -469,7 +521,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid: uid.take(),
|
||||
raw_uid,
|
||||
},
|
||||
attendees,
|
||||
});
|
||||
@@ -615,12 +667,37 @@ fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic stand-in identity for a VEVENT that has no `UID` of its own
|
||||
/// (see the `_` arm of `parse_vevents` above), so re-running the same import
|
||||
/// resolves to the same `raw_uid` and upserts in place rather than mints a
|
||||
/// fresh duplicate row every time. Format matches migration
|
||||
/// `0008_calendar_dedup_cleanup.sql`'s SQL-side backfill exactly, so a
|
||||
/// re-import after that cleanup migration converges onto the same row it
|
||||
/// already collapsed duplicates into rather than minting one more.
|
||||
pub(crate) fn content_uid(
|
||||
subject: &Option<String>,
|
||||
organizer: &Option<String>,
|
||||
starts_at: Option<i64>,
|
||||
ends_at: Option<i64>,
|
||||
) -> String {
|
||||
format!(
|
||||
"content:{}|{}|{}|{}",
|
||||
subject.as_deref().unwrap_or(""),
|
||||
organizer.as_deref().unwrap_or(""),
|
||||
starts_at.unwrap_or(0),
|
||||
ends_at.unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bug fix: this used to convert to the machine's *local* timezone before
|
||||
/// formatting, so an occurrence's date (and therefore its dedup key, see
|
||||
/// `raw_uid` below) could come out differently on two imports run either
|
||||
/// side of a DST transition or a timezone change — silently producing a
|
||||
/// second row for the same occurrence on re-import. UTC is stable no matter
|
||||
/// when/where the import runs.
|
||||
fn ymd_digits(unix_secs: i64) -> String {
|
||||
match Utc.timestamp_opt(unix_secs, 0).single() {
|
||||
Some(dt) => {
|
||||
let d = dt.with_timezone(&Local).date_naive();
|
||||
format!("{:04}{:02}{:02}", d.year(), d.month(), d.day())
|
||||
}
|
||||
Some(dt) => format!("{:04}{:02}{:02}", dt.year(), dt.month(), dt.day()),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -1082,4 +1159,91 @@ END:VEVENT\r\n";
|
||||
assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression for the duplicate-import bug: a VEVENT with no `UID` line
|
||||
/// used to get `raw_uid: None`, which the storage layer's unique index
|
||||
/// exempts from dedup entirely -- so re-importing the same source
|
||||
/// duplicated it on every single run. It must now get a stable,
|
||||
/// content-derived `raw_uid` so re-parsing the identical source resolves
|
||||
/// to the same key.
|
||||
#[test]
|
||||
fn parse_vevents_gives_a_uid_less_event_a_stable_content_based_raw_uid() {
|
||||
let ics = "BEGIN:VEVENT\r\n\
|
||||
SUMMARY:No UID here\r\n\
|
||||
DTSTART:20260112T163000Z\r\n\
|
||||
DTEND:20260112T170000Z\r\n\
|
||||
END:VEVENT\r\n";
|
||||
let first = parse_vevents(ics, "pst");
|
||||
let second = parse_vevents(ics, "pst");
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert!(first[0].event.raw_uid.is_some());
|
||||
assert_eq!(first[0].event.raw_uid, second[0].event.raw_uid);
|
||||
}
|
||||
|
||||
/// Distinct UID-less events (different subjects) must not collide onto
|
||||
/// the same content-based key.
|
||||
#[test]
|
||||
fn content_based_raw_uid_differs_for_distinct_uid_less_events() {
|
||||
let ics_a = "BEGIN:VEVENT\r\nSUMMARY:Event A\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
|
||||
let ics_b = "BEGIN:VEVENT\r\nSUMMARY:Event B\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
|
||||
let a = parse_vevents(ics_a, "pst");
|
||||
let b = parse_vevents(ics_b, "pst");
|
||||
assert_ne!(a[0].event.raw_uid, b[0].event.raw_uid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ymd_digits_is_timezone_independent() {
|
||||
// A UTC midnight timestamp must format to the same UTC calendar date
|
||||
// regardless of the machine's local timezone (the original bug:
|
||||
// this used to convert to `Local` first, so the same recurring
|
||||
// occurrence could compute a different dedup-key suffix on a machine
|
||||
// in a different timezone, or after a DST transition).
|
||||
let utc_new_year = Utc
|
||||
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp();
|
||||
assert_eq!(ymd_digits(utc_new_year), "20260101");
|
||||
}
|
||||
|
||||
fn dated_event(subject: &str, starts_at: Option<i64>) -> ImportedEvent {
|
||||
ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: "pst".to_string(),
|
||||
subject: Some(subject.to_string()),
|
||||
organizer: None,
|
||||
starts_at,
|
||||
ends_at: starts_at.map(|s| s + 1800),
|
||||
description: None,
|
||||
raw_uid: Some(subject.to_string()),
|
||||
},
|
||||
attendees: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_keeps_everything_when_unbounded() {
|
||||
let events = vec![dated_event("a", Some(0)), dated_event("b", Some(1_000_000))];
|
||||
assert_eq!(filter_by_range(events.clone(), None, None).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_excludes_events_outside_the_window() {
|
||||
let events = vec![
|
||||
dated_event("too old", Some(100)),
|
||||
dated_event("in range", Some(500)),
|
||||
dated_event("too new", Some(900)),
|
||||
];
|
||||
let kept = filter_by_range(events, Some(200), Some(800));
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].event.subject.as_deref(), Some("in range"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_keeps_undated_events_rather_than_guessing() {
|
||||
let events = vec![dated_event("no date", None)];
|
||||
let kept = filter_by_range(events, Some(200), Some(800));
|
||||
assert_eq!(kept.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+849
-34
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ use crate::models::{SpeakerSpan, TranscriptSegment};
|
||||
use std::path::Path;
|
||||
|
||||
pub mod models;
|
||||
pub mod voiceprint;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DiarError {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Voiceprint matching: identifies which diarized speaker cluster is the
|
||||
//! meeting's own microphone, so it can be auto-labeled "You" instead of a
|
||||
//! clustered "S1"/"S2" (bug: the mic speaker wasn't reliably first/labeled).
|
||||
//! Mic and system audio are already summed into one mono stream before
|
||||
//! diarization ever runs, so the only way to tell them apart afterwards is a
|
||||
//! voiceprint: a short mic-only sample, captured live, compared by embedding
|
||||
//! similarity against each cluster's own audio from the finished recording.
|
||||
//! Runs once per meeting, entirely offline via the same sherpa-onnx
|
||||
//! speaker-embedding model diarization already uses (ADR-0005).
|
||||
|
||||
use crate::models::SpeakerSpan;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// At least this much clean audio (mic sample or candidate cluster) before an
|
||||
/// embedding computed from it is trusted at all — a fragment of a word gives
|
||||
/// an unstable embedding that's as likely to mismatch as match.
|
||||
const MIN_VOICEPRINT_SAMPLES: usize = 16_000; // 1s @ 16kHz
|
||||
|
||||
/// Per-candidate audio is capped so one very long-talking speaker doesn't
|
||||
/// blow up embedding compute time; a few seconds is already stable.
|
||||
const MAX_CANDIDATE_SAMPLES: usize = 16_000 * 10;
|
||||
|
||||
/// sherpa's own default "is this a match" similarity threshold
|
||||
/// (`speaker_id::DEFAULT_SIMILARITY_THRESHOLD`) — kept as a local constant so
|
||||
/// this module doesn't need the `diarization` feature just to state its
|
||||
/// policy (used by both the real and no-op builds' doc comments/tests).
|
||||
const SIMILARITY_THRESHOLD: f32 = 0.5;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VoiceprintError {
|
||||
#[error("model load failed: {0}")]
|
||||
Load(String),
|
||||
#[error("embedding failed: {0}")]
|
||||
Embed(String),
|
||||
#[error("failed to read the recording: {0}")]
|
||||
Read(String),
|
||||
}
|
||||
|
||||
/// The label -> display-name map to auto-apply after diarization: whichever
|
||||
/// speaker's audio matches `mic_samples` best -> `"You"`; every other label,
|
||||
/// in first-appearance order, -> `"Speaker 2"`, `"Speaker 3"`, … An empty map
|
||||
/// means "couldn't tell" (too little mic audio, no cluster cleared the
|
||||
/// similarity threshold, embedding model unavailable) — callers leave the
|
||||
/// existing "S1"/"S2" labels alone rather than guess (FR-SPK-5).
|
||||
#[cfg(feature = "diarization")]
|
||||
pub fn match_mic_speaker(
|
||||
embedding_model: &Path,
|
||||
mic_samples: &[f32],
|
||||
wav_path: &Path,
|
||||
spans: &[SpeakerSpan],
|
||||
) -> Result<HashMap<String, String>, VoiceprintError> {
|
||||
if mic_samples.len() < MIN_VOICEPRINT_SAMPLES || spans.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let labels_in_order = first_appearance_order(spans);
|
||||
|
||||
let wav_samples = crate::audio::read_wav_mono_16k(wav_path)
|
||||
.map_err(|e| VoiceprintError::Read(e.to_string()))?;
|
||||
|
||||
let mut extractor =
|
||||
sherpa_rs::speaker_id::EmbeddingExtractor::new(sherpa_rs::speaker_id::ExtractorConfig {
|
||||
model: embedding_model.to_string_lossy().to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.map_err(|e| VoiceprintError::Load(e.to_string()))?;
|
||||
|
||||
let mic_embedding = extractor
|
||||
.compute_speaker_embedding(mic_samples.to_vec(), 16_000)
|
||||
.map_err(|e| VoiceprintError::Embed(e.to_string()))?;
|
||||
|
||||
let mut best: Option<(&str, f32)> = None;
|
||||
for label in &labels_in_order {
|
||||
let candidate_samples = candidate_audio(&wav_samples, spans, label);
|
||||
if candidate_samples.len() < MIN_VOICEPRINT_SAMPLES {
|
||||
continue;
|
||||
}
|
||||
let embedding = extractor
|
||||
.compute_speaker_embedding(candidate_samples, 16_000)
|
||||
.map_err(|e| VoiceprintError::Embed(e.to_string()))?;
|
||||
let score = cosine_similarity(&mic_embedding, &embedding);
|
||||
let is_better = match best {
|
||||
Some((_, best_score)) => score > best_score,
|
||||
None => true,
|
||||
};
|
||||
if is_better {
|
||||
best = Some((label, score));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((mic_label, score)) = best else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
if score < SIMILARITY_THRESHOLD {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
Ok(build_name_map(&labels_in_order, mic_label))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "diarization"))]
|
||||
pub fn match_mic_speaker(
|
||||
_embedding_model: &Path,
|
||||
_mic_samples: &[f32],
|
||||
_wav_path: &Path,
|
||||
_spans: &[SpeakerSpan],
|
||||
) -> Result<HashMap<String, String>, VoiceprintError> {
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
|
||||
/// Distinct speaker labels in first-appearance order — spans come back from
|
||||
/// the diarizer already sorted by start time.
|
||||
fn first_appearance_order(spans: &[SpeakerSpan]) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
spans
|
||||
.iter()
|
||||
.filter(|s| seen.insert(s.speaker.clone()))
|
||||
.map(|s| s.speaker.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenates up to `MAX_CANDIDATE_SAMPLES` of `label`'s audio out of the
|
||||
/// full 16kHz-mono recording, using each span's millisecond range.
|
||||
fn candidate_audio(wav_samples: &[f32], spans: &[SpeakerSpan], label: &str) -> Vec<f32> {
|
||||
const SAMPLES_PER_MS: u64 = 16; // 16_000 Hz / 1000
|
||||
let mut out = Vec::new();
|
||||
for span in spans.iter().filter(|s| s.speaker == label) {
|
||||
if out.len() >= MAX_CANDIDATE_SAMPLES {
|
||||
break;
|
||||
}
|
||||
let start = (span.start_ms * SAMPLES_PER_MS) as usize;
|
||||
let end = ((span.end_ms * SAMPLES_PER_MS) as usize).min(wav_samples.len());
|
||||
if start < end {
|
||||
out.extend_from_slice(&wav_samples[start..end]);
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_CANDIDATE_SAMPLES);
|
||||
out
|
||||
}
|
||||
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let norm_a = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm_a == 0.0 || norm_b == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
dot / (norm_a * norm_b)
|
||||
}
|
||||
}
|
||||
|
||||
/// `mic_label` -> "You"; every other label, in first-appearance order ->
|
||||
/// "Speaker 2", "Speaker 3", … (numbering starts at 2 — "You" stands in for
|
||||
/// "Speaker 1" without ever being called that).
|
||||
fn build_name_map(labels_in_order: &[String], mic_label: &str) -> HashMap<String, String> {
|
||||
let mut names = HashMap::new();
|
||||
let mut next_speaker_number = 2;
|
||||
for label in labels_in_order {
|
||||
if label == mic_label {
|
||||
names.insert(label.clone(), "You".to_string());
|
||||
} else {
|
||||
names.insert(label.clone(), format!("Speaker {next_speaker_number}"));
|
||||
next_speaker_number += 1;
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn span(start_ms: u64, end_ms: u64, speaker: &str) -> SpeakerSpan {
|
||||
SpeakerSpan {
|
||||
start_ms,
|
||||
end_ms,
|
||||
speaker: speaker.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_appearance_order_dedupes_in_encounter_order() {
|
||||
let spans = vec![
|
||||
span(0, 1000, "S2"),
|
||||
span(1000, 2000, "S1"),
|
||||
span(2000, 3000, "S2"),
|
||||
];
|
||||
assert_eq!(first_appearance_order(&spans), vec!["S2", "S1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_audio_concatenates_only_that_speakers_spans() {
|
||||
let wav: Vec<f32> = (0..32_000).map(|i| i as f32).collect(); // 2s @16kHz
|
||||
let spans = vec![
|
||||
span(0, 500, "S1"),
|
||||
span(500, 1000, "S2"),
|
||||
span(1000, 1500, "S1"),
|
||||
];
|
||||
let s1 = candidate_audio(&wav, &spans, "S1");
|
||||
// 500ms + 500ms of S1 = 1s = 16_000 samples, taken from [0,8000) and [16000,24000).
|
||||
assert_eq!(s1.len(), 16_000);
|
||||
assert_eq!(s1[0], 0.0);
|
||||
assert_eq!(s1[8000], 16_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_audio_caps_at_the_maximum() {
|
||||
let wav: Vec<f32> = vec![0.0; MAX_CANDIDATE_SAMPLES + 10_000];
|
||||
let spans = vec![span(0, (MAX_CANDIDATE_SAMPLES as u64 + 10_000) / 16, "S1")];
|
||||
assert_eq!(
|
||||
candidate_audio(&wav, &spans, "S1").len(),
|
||||
MAX_CANDIDATE_SAMPLES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_identical_vectors_is_one() {
|
||||
let v = [1.0, 2.0, 3.0];
|
||||
assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_opposite_vectors_is_negative_one() {
|
||||
let a = [1.0, 0.0];
|
||||
let b = [-1.0, 0.0];
|
||||
assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_handles_a_zero_vector_without_dividing_by_zero() {
|
||||
let a = [0.0, 0.0];
|
||||
let b = [1.0, 1.0];
|
||||
assert_eq!(cosine_similarity(&a, &b), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_name_map_labels_the_mic_you_and_numbers_the_rest_from_two() {
|
||||
let labels = vec!["S2".to_string(), "S1".to_string(), "S3".to_string()];
|
||||
let names = build_name_map(&labels, "S1");
|
||||
assert_eq!(names.get("S1"), Some(&"You".to_string()));
|
||||
assert_eq!(names.get("S2"), Some(&"Speaker 2".to_string()));
|
||||
assert_eq!(names.get("S3"), Some(&"Speaker 3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_mic_speaker_returns_empty_when_mic_sample_is_too_short() {
|
||||
let spans = vec![span(0, 1000, "S1")];
|
||||
let names = match_mic_speaker(
|
||||
Path::new("model.onnx"),
|
||||
&[0.0; 100],
|
||||
Path::new("audio.wav"),
|
||||
&spans,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(names.is_empty());
|
||||
}
|
||||
}
|
||||
+27
-3
@@ -14,6 +14,7 @@ pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod llm;
|
||||
pub mod mcp;
|
||||
pub mod media;
|
||||
pub mod models;
|
||||
pub mod notes;
|
||||
pub mod paths;
|
||||
@@ -78,6 +79,19 @@ pub struct RecordingSession {
|
||||
/// (T4.4, FR-SPK-2). Never rewritten onto segments (FR-SPK-5); resolved
|
||||
/// at render/finalize time instead.
|
||||
pub speaker_names: Arc<StdMutex<std::collections::HashMap<String, String>>>,
|
||||
/// A few seconds of raw mic-only audio, captured once early in the
|
||||
/// recording — used at `stop_recording` to voiceprint-match the mic
|
||||
/// against the diarized speaker clusters so the mic speaker can be
|
||||
/// auto-labeled "You" instead of a clustered "S1"/"S2". `None` when the
|
||||
/// mic is disabled (same conditions as `mic_capture`).
|
||||
pub mic_voice_sample: Option<Arc<audio::VoiceSample>>,
|
||||
/// Live notes redesign: raw user-authored notes accumulated *during* the
|
||||
/// recording (freeform text + per-moment annotations) — see
|
||||
/// `models::ManualNotes`. Mutated by `update_live_notes`/`set_segment_note`
|
||||
/// and write-through persisted to `manual_notes.json` on every edit (crash
|
||||
/// safety, same spirit as T2.8 recovery); folded into the final `notes.md`
|
||||
/// at `stop_recording` via `notes::MarkdownNotes::merge`.
|
||||
pub manual_notes: Arc<StdMutex<models::ManualNotes>>,
|
||||
}
|
||||
|
||||
/// Wraps the tray icon so it can be looked up from commands to update its
|
||||
@@ -190,9 +204,14 @@ pub fn run() {
|
||||
let pst_settings = commands::load_settings();
|
||||
if pst_settings.pst_auto_sync {
|
||||
if let Some(path) = pst_settings.pst_last_path {
|
||||
if let Err(e) =
|
||||
commands::import_pst_core(&startup_app, store.as_ref(), path, None)
|
||||
.await
|
||||
if let Err(e) = commands::import_pst_core(
|
||||
&startup_app,
|
||||
store.as_ref(),
|
||||
path,
|
||||
None,
|
||||
pst_settings.pst_import_range_days,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("startup PST auto-sync failed: {e:?}");
|
||||
}
|
||||
@@ -210,6 +229,8 @@ pub fn run() {
|
||||
commands::resume_recording,
|
||||
commands::set_recording_retention,
|
||||
commands::acknowledge_recording_consent,
|
||||
commands::update_live_notes,
|
||||
commands::set_segment_note,
|
||||
commands::resume_transcription,
|
||||
commands::app_info,
|
||||
commands::open_url,
|
||||
@@ -225,6 +246,7 @@ pub fn run() {
|
||||
commands::download_model,
|
||||
commands::remove_model,
|
||||
commands::reprocess_transcript,
|
||||
commands::import_media,
|
||||
commands::list_meetings,
|
||||
commands::search,
|
||||
commands::set_tags,
|
||||
@@ -235,6 +257,7 @@ pub fn run() {
|
||||
commands::update_notes,
|
||||
commands::export_meeting,
|
||||
commands::bulk_export_meetings,
|
||||
commands::import_meeting_bundle,
|
||||
commands::rename_speaker,
|
||||
commands::merge_speakers,
|
||||
commands::map_speaker_to_participant,
|
||||
@@ -246,6 +269,7 @@ pub fn run() {
|
||||
commands::llm_setup_suggestions,
|
||||
commands::pull_ollama_model,
|
||||
commands::import_pst,
|
||||
commands::cleanup_calendar_events,
|
||||
commands::list_calendar_events,
|
||||
commands::get_calendar_event,
|
||||
commands::attach_meeting_to_event,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Media import: turn an arbitrary local audio/video file, a direct media URL,
|
||||
//! or a streaming/YouTube page URL into WhispAssist's canonical 16kHz-mono
|
||||
//! 16-bit WAV, so it can go through the same transcription/diarization path as a
|
||||
//! live recording (manual "add a meeting from a file/URL" feature).
|
||||
//!
|
||||
//! Two external tools do the work and are deliberately **not bundled** (same
|
||||
//! call as `readpst` for .pst, ADR-0008): they must be installed and on PATH.
|
||||
//! - `ffmpeg` transcodes whatever we have to the target WAV.
|
||||
//! - `yt-dlp` resolves URLs (YouTube and other sites via its extractors, and
|
||||
//! direct media URLs via its generic extractor) down to an audio file that
|
||||
//! ffmpeg can then convert.
|
||||
//!
|
||||
//! A missing tool surfaces as a clear, named error rather than a generic
|
||||
//! "program not found".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MediaError {
|
||||
#[error("{0} isn't installed or on PATH — install it and try again")]
|
||||
ToolMissing(&'static str),
|
||||
#[error("{tool} failed: {message}")]
|
||||
Failed {
|
||||
tool: &'static str,
|
||||
message: String,
|
||||
},
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Whether `source` should be resolved as a URL (via yt-dlp) rather than opened
|
||||
/// as a local file path.
|
||||
pub fn is_url(source: &str) -> bool {
|
||||
let s = source.trim_start();
|
||||
s.starts_with("http://") || s.starts_with("https://")
|
||||
}
|
||||
|
||||
/// A `Command` for `program` that, on Windows, never pops a console window —
|
||||
/// WhispAssist is a GUI app with no console of its own (same fix as `readpst`).
|
||||
fn command(program: &str) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run `cmd`, mapping a missing binary to `ToolMissing(program)` and a non-zero
|
||||
/// exit to `Failed` carrying the tail of the tool's output (where the real
|
||||
/// error message from ffmpeg/yt-dlp lives — both are verbose).
|
||||
fn run(program: &'static str, cmd: &mut Command) -> Result<(), MediaError> {
|
||||
let output = cmd.output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => MediaError::ToolMissing(program),
|
||||
_ => MediaError::Io(e),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let text = if stderr.trim().is_empty() { stdout } else { stderr };
|
||||
let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let start = tail.len().saturating_sub(6);
|
||||
return Err(MediaError::Failed {
|
||||
tool: program,
|
||||
message: tail[start..].join("\n"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transcode `source` (a local file path or a URL) into a 16kHz-mono 16-bit WAV
|
||||
/// at `dest_wav`. URLs are first fetched with yt-dlp into `work_dir` (which the
|
||||
/// caller creates and cleans up), then ffmpeg converts whatever landed. Blocks;
|
||||
/// run it off the async runtime.
|
||||
pub fn import_to_wav(source: &str, dest_wav: &Path, work_dir: &Path) -> Result<(), MediaError> {
|
||||
if is_url(source) {
|
||||
// bestaudio keeps the download small; ffmpeg does the actual 16kHz-mono
|
||||
// conversion in one predictable pass. `--no-part` avoids a leftover
|
||||
// `.part` file so `first_file_in` finds the finished download.
|
||||
let template = work_dir.join("download.%(ext)s");
|
||||
let mut cmd = command("yt-dlp");
|
||||
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-part", "-o"])
|
||||
.arg(&template)
|
||||
.arg(source);
|
||||
run("yt-dlp", &mut cmd)?;
|
||||
let downloaded = first_file_in(work_dir)?.ok_or(MediaError::Failed {
|
||||
tool: "yt-dlp",
|
||||
message: "no media file was produced".into(),
|
||||
})?;
|
||||
ffmpeg_to_wav(&downloaded, dest_wav)
|
||||
} else {
|
||||
let input = Path::new(source);
|
||||
if !input.exists() {
|
||||
return Err(MediaError::Failed {
|
||||
tool: "import",
|
||||
message: format!("file not found: {source}"),
|
||||
});
|
||||
}
|
||||
ffmpeg_to_wav(input, dest_wav)
|
||||
}
|
||||
}
|
||||
|
||||
/// ffmpeg: any input → 16kHz mono 16-bit PCM WAV (drops video, matches the
|
||||
/// format `audio::read_wav_mono_16k` and the diarizer both expect).
|
||||
fn ffmpeg_to_wav(input: &Path, dest_wav: &Path) -> Result<(), MediaError> {
|
||||
let mut cmd = command("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(input)
|
||||
.args(["-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le"])
|
||||
.arg(dest_wav);
|
||||
run("ffmpeg", &mut cmd)
|
||||
}
|
||||
|
||||
/// The first regular file in `dir` (the single yt-dlp download; the caller uses
|
||||
/// a fresh temp dir per import so there's nothing else there).
|
||||
fn first_file_in(dir: &Path) -> Result<Option<PathBuf>, MediaError> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_file() {
|
||||
return Ok(Some(entry.path()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_url_distinguishes_urls_from_paths() {
|
||||
assert!(is_url("https://youtube.com/watch?v=abc"));
|
||||
assert!(is_url("http://example.com/a.mp4"));
|
||||
assert!(is_url(" https://leading-space.example/x")); // trimmed
|
||||
assert!(!is_url(r"C:\Users\me\meeting.mp4"));
|
||||
assert!(!is_url("/home/me/meeting.m4a"));
|
||||
assert!(!is_url("meeting.wav"));
|
||||
assert!(!is_url("ftp://not-http.example/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_reports_a_missing_local_file_without_touching_a_tool() {
|
||||
let dir = std::env::temp_dir().join(format!("wa-media-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let err = import_to_wav(
|
||||
"no-such-file.mp4",
|
||||
&dir.join("out.wav"),
|
||||
&dir,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, MediaError::Failed { tool: "import", .. }));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,41 @@ pub struct SpeakerInfo {
|
||||
pub participant_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A user-typed note attached to a moment in the recording, anchored by
|
||||
/// timestamp rather than segment id — a segment id can be invalidated by a
|
||||
/// later batch re-transcription (T3.8), but the moment in time it pointed at
|
||||
/// never changes. `text: ""` marks a cleared note (kept rather than removed
|
||||
/// so `updated_at` still reflects the clear).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SegmentNote {
|
||||
pub anchor_ms: u64,
|
||||
pub text: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// On-disk shape of `manual_notes.json` (`docs/03-data-model.md`) — the raw
|
||||
/// user-authored input a live recording accumulates (freeform notes typed
|
||||
/// while recording, plus any per-moment annotations), kept distinct from the
|
||||
/// transcript-derived `notes.md` so a re-render never has to guess which
|
||||
/// parts of `notes.md` were hand-written.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManualNotes {
|
||||
pub schema: u32,
|
||||
pub freeform_md: String,
|
||||
pub segment_notes: Vec<SegmentNote>,
|
||||
}
|
||||
|
||||
impl Default for ManualNotes {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: 1,
|
||||
freeform_md: String::new(),
|
||||
segment_notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A diarization result span before alignment to transcript segments.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpeakerSpan {
|
||||
@@ -158,6 +193,29 @@ pub struct ActionItem {
|
||||
pub reminder_set: bool,
|
||||
}
|
||||
|
||||
/// Portable meeting export manifest — the `meeting.json` inside an export
|
||||
/// bundle folder (FR-STORE-4). Carries everything needed to reconstruct a
|
||||
/// meeting on another machine alongside the bundle's files (`audio.wav`,
|
||||
/// `transcript.json`, `notes.md`, `summary.json`). Deliberately excludes the
|
||||
/// meeting id (a fresh one is minted on import to avoid collisions) and the
|
||||
/// calendar-event link (event ids are machine-local).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingBundle {
|
||||
pub schema: u32,
|
||||
pub title: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub duration_secs: Option<i64>,
|
||||
pub language: Option<String>,
|
||||
pub backend_used: Option<String>,
|
||||
pub model_used: Option<String>,
|
||||
pub recorded: bool,
|
||||
pub template_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub speakers: Vec<SpeakerInfo>,
|
||||
pub action_items: Vec<ActionItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalendarEvent {
|
||||
pub id: String,
|
||||
@@ -242,6 +300,20 @@ pub struct Settings {
|
||||
pub pst_last_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pst_auto_sync: bool,
|
||||
/// How far back to import (days before "now"); `None` = full mailbox
|
||||
/// history (the original, unbounded behavior). Applied to both a manual
|
||||
/// Import click and the `pst_auto_sync` startup re-import — a long-lived
|
||||
/// mailbox otherwise re-imports its entire multi-year history (every
|
||||
/// recurring series expanded to its cap, every one-off holiday entry
|
||||
/// Outlook ever generated) on every launch.
|
||||
#[serde(default)]
|
||||
pub pst_import_range_days: Option<u32>,
|
||||
/// Auto-start recording when a calendar event begins while the app is open
|
||||
/// (FR-CAL, opt-in). OFF by default. No background timer runs for this: the
|
||||
/// UI arms a single one-shot timer to the next event while the app is open
|
||||
/// and disarms it on close, so idle resource use stays at zero (NFR-RES-1).
|
||||
#[serde(default)]
|
||||
pub auto_record_calendar: bool,
|
||||
// Microsoft Graph calendar source (M4.4, T8.9, ADR-0008, FR-CAL-6). Opt-in,
|
||||
// explicit consent via OAuth PKCE — off by default. The credential ref
|
||||
// points into the OS credential store; the token itself never lives here.
|
||||
|
||||
+220
-44
@@ -3,7 +3,7 @@
|
||||
//! Renders speaker-tagged Markdown from transcript + speaker names (+ optional
|
||||
//! summary). Names are resolved here from the mapping; segments keep internal IDs.
|
||||
|
||||
use crate::models::{SpeakerInfo, TranscriptSegment};
|
||||
use crate::models::{ManualNotes, SegmentNote, SpeakerInfo, TranscriptSegment};
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -101,6 +101,156 @@ pub trait NotesRenderer: Send + Sync {
|
||||
|
||||
pub struct MarkdownNotes;
|
||||
|
||||
impl MarkdownNotes {
|
||||
/// Bug fix / redesign: `notes.md` used to be generated *only* from the
|
||||
/// transcript (`to_markdown`), then blindly overwritten on every
|
||||
/// finalize/speaker-rename, discarding anything the user typed. `merge`
|
||||
/// is what `stop_recording` now calls instead — it folds in whatever was
|
||||
/// captured live in `manual` (freeform notes typed during the meeting,
|
||||
/// plus any per-moment annotations) alongside the transcript, so the
|
||||
/// generated document isn't transcript-only and isn't a single
|
||||
/// same-speaker-collapsed blob (`## Notes` / `## Transcript` sections,
|
||||
/// with each annotation placed right after the paragraph it points at).
|
||||
pub fn merge(
|
||||
&self,
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
manual: &ManualNotes,
|
||||
summary_md: Option<&str>,
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
let freeform = manual.freeform_md.trim();
|
||||
if !freeform.is_empty() {
|
||||
out.push_str("## Notes\n\n");
|
||||
out.push_str(freeform);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let transcript = transcript_with_notes(segments, speakers, &manual.segment_notes);
|
||||
if !transcript.is_empty() {
|
||||
out.push_str("## Transcript\n\n");
|
||||
out.push_str(&transcript);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Template section scaffold + summary block shared by `to_markdown` and `merge`.
|
||||
fn push_prelude(out: &mut String, summary_md: Option<&str>, template: Option<&NoteTemplate>) {
|
||||
if let Some(template) = template {
|
||||
for section in &template.sections {
|
||||
out.push_str(&format!("## {section}\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(summary) = summary_md {
|
||||
let summary = summary.trim();
|
||||
if !summary.is_empty() {
|
||||
out.push_str(summary);
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups consecutive same-speaker segments into `(start_ms, end_ms,
|
||||
/// "**Name:** text")` paragraphs — the ms range is each paragraph's span in
|
||||
/// the original recording, used by `transcript_with_notes` to place a
|
||||
/// per-moment note right after the paragraph it was anchored to.
|
||||
fn dialogue_paragraphs(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
) -> Vec<(u64, u64, String)> {
|
||||
let name_for = |label: &str| -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut current_speaker: Option<&str> = None;
|
||||
let mut buffer = String::new();
|
||||
let mut range: (u64, u64) = (0, 0);
|
||||
for seg in segments {
|
||||
let text = seg.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if current_speaker != Some(seg.speaker.as_str()) {
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
current_speaker = Some(seg.speaker.as_str());
|
||||
buffer.clear();
|
||||
range = (seg.start_ms, seg.end_ms);
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
buffer.push(' ');
|
||||
}
|
||||
buffer.push_str(text);
|
||||
range.1 = seg.end_ms;
|
||||
}
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The dialogue paragraphs, each followed by a `> 📝` blockquote for any
|
||||
/// `segment_notes` whose `anchor_ms` falls inside that paragraph's span.
|
||||
/// A note whose anchor doesn't land inside any paragraph (its segment fell
|
||||
/// in a gap, or vanished in a later re-transcription) still isn't dropped —
|
||||
/// it surfaces under "Other notes" at the end instead of silently
|
||||
/// disappearing.
|
||||
fn transcript_with_notes(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
segment_notes: &[SegmentNote],
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let mut matched = vec![false; segment_notes.len()];
|
||||
for (start, end, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
for (i, note) in segment_notes.iter().enumerate() {
|
||||
let text = note.text.trim();
|
||||
if text.is_empty() || note.anchor_ms < start || note.anchor_ms > end {
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!("> 📝 {text}\n\n"));
|
||||
matched[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
let orphans: Vec<&str> = segment_notes
|
||||
.iter()
|
||||
.zip(matched.iter())
|
||||
.filter(|(n, was_matched)| !**was_matched && !n.text.trim().is_empty())
|
||||
.map(|(n, _)| n.text.trim())
|
||||
.collect();
|
||||
if !orphans.is_empty() {
|
||||
out.push_str("### Other notes\n\n");
|
||||
for text in orphans {
|
||||
out.push_str(&format!("> 📝 {text}\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
impl NotesRenderer for MarkdownNotes {
|
||||
fn to_markdown(
|
||||
&self,
|
||||
@@ -110,50 +260,11 @@ impl NotesRenderer for MarkdownNotes {
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(template) = template {
|
||||
for section in &template.sections {
|
||||
out.push_str(&format!("## {section}\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(summary) = summary_md {
|
||||
let summary = summary.trim();
|
||||
if !summary.is_empty() {
|
||||
out.push_str(summary);
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
}
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
let name_for = |label: &str| -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
// Group consecutive segments from the same speaker into one paragraph
|
||||
// (matters once Phase 4 diarization produces more than one speaker).
|
||||
let mut current_speaker: Option<&str> = None;
|
||||
let mut buffer = String::new();
|
||||
for seg in segments {
|
||||
let text = seg.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if current_speaker != Some(seg.speaker.as_str()) {
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
}
|
||||
current_speaker = Some(seg.speaker.as_str());
|
||||
buffer.clear();
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
buffer.push(' ');
|
||||
}
|
||||
buffer.push_str(text);
|
||||
}
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
for (_, _, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
@@ -350,6 +461,71 @@ mod tests {
|
||||
assert_eq!(md, "## Discussion\n\n## Action Items\n\n**S1:** Hi");
|
||||
}
|
||||
|
||||
fn note(anchor_ms: u64, text: &str) -> SegmentNote {
|
||||
SegmentNote {
|
||||
anchor_ms,
|
||||
text: text.to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_no_manual_notes_matches_to_markdown() {
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_adds_a_notes_section_for_freeform_text() {
|
||||
let segments = vec![seg(0, "S1", "Hello")];
|
||||
let manual = ManualNotes {
|
||||
freeform_md: "Remember to follow up.".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Notes\n\nRemember to follow up.\n\n## Transcript\n\n**S1:** Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_places_a_segment_note_right_after_its_paragraph() {
|
||||
// seg 0 spans 0-900ms ("S1"); anchor 500 falls inside it.
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(500, "circle back on this")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n> \u{1f4dd} circle back on this\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_surfaces_an_unmatched_anchor_under_other_notes() {
|
||||
let segments = vec![seg(0, "S1", "Hello")]; // spans 0-900ms
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(50_000, "way outside any paragraph")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n### Other notes\n\n> \u{1f4dd} way outside any paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_omits_empty_sections_for_an_empty_meeting() {
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(MarkdownNotes.merge(&[], &[], &manual, None, None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() {
|
||||
let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing";
|
||||
|
||||
@@ -29,6 +29,13 @@ pub fn meeting_dir(id: &MeetingId) -> PathBuf {
|
||||
meetings_dir().join(id)
|
||||
}
|
||||
|
||||
/// Raw user-authored notes (freeform + per-moment annotations) accumulated
|
||||
/// live during a recording — see `models::ManualNotes`. Distinct from the
|
||||
/// derived `notes.md` the same directory holds after finalize.
|
||||
pub fn manual_notes_file(id: &MeetingId) -> PathBuf {
|
||||
meeting_dir(id).join("manual_notes.json")
|
||||
}
|
||||
|
||||
pub fn models_dir() -> PathBuf {
|
||||
wa_root().join("models")
|
||||
}
|
||||
|
||||
+458
-45
@@ -104,6 +104,12 @@ pub struct Meeting {
|
||||
/// Note-template id (Phase 8, T8.1, FR-NOTE-5), if one was picked at
|
||||
/// creation — resolved against `notes::templates::catalog()`.
|
||||
pub template_id: Option<String>,
|
||||
/// Confirmed/edited action items from the `action_items` table — the
|
||||
/// source of truth the user manages directly (add/edit/delete via
|
||||
/// `confirm_action_items`, FR-LLM-3). Falls back to the drafts parsed
|
||||
/// into `summary.json` when the table has none yet, so a freshly
|
||||
/// generated summary still shows its suggestions.
|
||||
pub action_items: Vec<ActionItem>,
|
||||
}
|
||||
|
||||
/// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the
|
||||
@@ -115,6 +121,15 @@ pub struct CalendarEventDetail {
|
||||
pub participants: Vec<Participant>,
|
||||
}
|
||||
|
||||
/// Result of `cleanup_calendar_events` — `protected` is always reported
|
||||
/// alongside `deleted` so the UI can show the user their meeting-linked
|
||||
/// events weren't touched, not just a silent lower-than-expected count.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalendarCleanupResult {
|
||||
pub deleted: u32,
|
||||
pub protected: u32,
|
||||
}
|
||||
|
||||
/// On-disk shape of `summary.json` (`docs/03-data-model.md`). Drafted action
|
||||
/// items here are NOT yet rows in the `action_items` table — the user
|
||||
/// reviews/edits them first; `confirm_action_items` is what persists them
|
||||
@@ -254,6 +269,13 @@ pub trait Store: Send + Sync {
|
||||
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
|
||||
async fn delete_meeting(&self, id: &MeetingId) -> 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
|
||||
/// 8, FR-SEARCH-1). `finalize_meeting`/`update_notes`/`set_tags` already
|
||||
/// call this themselves; callers that write searchable content some
|
||||
/// other way (e.g. `generate_summary` sealing `summary.json` straight to
|
||||
/// disk) must call it afterward so search doesn't silently miss it.
|
||||
async fn reindex_fts(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||
/// Set (or create) a speaker's display name; works whether or not the
|
||||
/// meeting has finalized yet (T4.4, FR-SPK-2/5).
|
||||
async fn rename_speaker(
|
||||
@@ -298,6 +320,16 @@ pub trait Store: Send + Sync {
|
||||
/// A single event with its attendees (T6.3/T6.4, FR-CAL-1/3) — backs the
|
||||
/// pre-meeting context panel and the speaker-naming attendee dropdown.
|
||||
async fn get_calendar_event(&self, id: &str) -> Result<CalendarEventDetail, StoreError>;
|
||||
/// Deletes imported calendar events not linked to any meeting —
|
||||
/// `older_than_unix: None` deletes every unlinked event ("Delete all"),
|
||||
/// `Some(cutoff)` only those with `starts_at < cutoff`. An event
|
||||
/// referenced by `meetings.calendar_event_id` is always kept regardless
|
||||
/// of age (FR-CAL-2 pre-meeting context / FR-CAL-4 continuity would
|
||||
/// break otherwise). Returns `(deleted, protected)`.
|
||||
async fn cleanup_calendar_events(
|
||||
&self,
|
||||
older_than_unix: Option<i64>,
|
||||
) -> Result<(u32, u32), StoreError>;
|
||||
/// Link a meeting (current or historical) to a calendar event (T6.3/T6.6,
|
||||
/// FR-CAL-2/4). Errs if either id doesn't exist. Also mirrors the event's
|
||||
/// subject onto the meeting's title when it has one — linking is meant to
|
||||
@@ -311,6 +343,16 @@ pub trait Store: Send + Sync {
|
||||
/// Manually rename a meeting — recordings otherwise default to "Untitled
|
||||
/// meeting" with no other way to change that.
|
||||
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError>;
|
||||
/// Overwrite a meeting's start/end timestamps — used by bundle import
|
||||
/// (FR-STORE-4) so a moved recording keeps its original date rather than
|
||||
/// showing the import time (`create_meeting`/`finalize_meeting` both stamp
|
||||
/// "now").
|
||||
async fn set_meeting_times(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
) -> Result<(), StoreError>;
|
||||
/// Names a speaker AND links them to a known `Participant` (T6.5/T6.6,
|
||||
/// FR-SPK-4): the display name comes from the participant record, and
|
||||
/// the shared `participant_id` is what gives naming "continuity" across
|
||||
@@ -576,49 +618,6 @@ impl SqliteStore {
|
||||
.fetch_all(&self.pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// (Re)builds this meeting's `meeting_fts` row from the current title and
|
||||
/// whatever's on disk for transcript/notes (Phase 8, FR-SEARCH-1).
|
||||
/// `meeting_fts` is a plain (non-`content=`) FTS5 table, so nothing keeps
|
||||
/// it in sync automatically — called from `finalize_meeting`/
|
||||
/// `update_notes` so the index can't drift from what's actually stored.
|
||||
/// Deletes-then-inserts rather than `INSERT OR REPLACE`: FTS5 has no
|
||||
/// unique constraint to conflict on.
|
||||
async fn reindex_fts(&self, id: &MeetingId) -> Result<(), StoreError> {
|
||||
let title: String = sqlx::query_scalar("SELECT title FROM meetings WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
let transcript_text = read_artifact(&paths::meeting_dir(id).join("transcript.json"))
|
||||
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
|
||||
.map(|t| {
|
||||
t.segments
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let notes_text =
|
||||
read_artifact(&paths::meeting_dir(id).join("notes.md")).unwrap_or_default();
|
||||
|
||||
sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO meeting_fts (meeting_id, title, transcript_text, notes_text) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&title)
|
||||
.bind(&transcript_text)
|
||||
.bind(¬es_text)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows a `label -> merged_into` chain to its canonical label. Capped at 8
|
||||
@@ -909,6 +908,14 @@ impl Store for SqliteStore {
|
||||
let summary = read_artifact(&folder.join("summary.json"))
|
||||
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok());
|
||||
let tags = self.tags_for_meeting(id).await?;
|
||||
// Table is the source of truth for confirmed/edited items; fall back
|
||||
// to the summary's parsed drafts only when nothing's been saved yet.
|
||||
let mut action_items = self.list_action_items(id).await?;
|
||||
if action_items.is_empty() {
|
||||
if let Some(s) = &summary {
|
||||
action_items = s.action_items.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Meeting {
|
||||
id: row.get("id"),
|
||||
@@ -928,6 +935,7 @@ impl Store for SqliteStore {
|
||||
calendar_event_id: row.get("calendar_event_id"),
|
||||
tags,
|
||||
template_id: row.get("template_id"),
|
||||
action_items,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -963,6 +971,71 @@ impl Store for SqliteStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `meeting_fts` is a plain (non-`content=`) FTS5 table, so nothing keeps
|
||||
/// it in sync automatically — every write path that changes title,
|
||||
/// transcript, notes, summary, or tags calls this afterward so search
|
||||
/// (FR-SEARCH-1) can't drift from what's actually stored. Deletes-then-
|
||||
/// inserts rather than `INSERT OR REPLACE`: FTS5 has no unique
|
||||
/// constraint to conflict on.
|
||||
async fn reindex_fts(&self, id: &MeetingId) -> Result<(), StoreError> {
|
||||
let title: String = sqlx::query_scalar("SELECT title FROM meetings WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
let transcript_text = read_artifact(&paths::meeting_dir(id).join("transcript.json"))
|
||||
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
|
||||
.map(|t| {
|
||||
t.segments
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let notes_text =
|
||||
read_artifact(&paths::meeting_dir(id).join("notes.md")).unwrap_or_default();
|
||||
|
||||
// `read_artifact` already unseals (T8.8's vault, passthrough when
|
||||
// locked/plaintext), same as the transcript/notes reads above.
|
||||
let summary_text = read_artifact(&paths::meeting_dir(id).join("summary.json"))
|
||||
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok())
|
||||
.map(|s| {
|
||||
let mut text = s.summary_md;
|
||||
for decision in &s.decisions {
|
||||
text.push(' ');
|
||||
text.push_str(decision);
|
||||
}
|
||||
for item in &s.action_items {
|
||||
text.push(' ');
|
||||
text.push_str(&item.text);
|
||||
}
|
||||
text
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let tags_text = self.tags_for_meeting(id).await?.join(" ");
|
||||
|
||||
sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO meeting_fts (meeting_id, title, transcript_text, notes_text, summary_text, tags_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&title)
|
||||
.bind(&transcript_text)
|
||||
.bind(¬es_text)
|
||||
.bind(&summary_text)
|
||||
.bind(&tags_text)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename_speaker(
|
||||
&self,
|
||||
id: &MeetingId,
|
||||
@@ -1040,6 +1113,60 @@ impl Store for SqliteStore {
|
||||
})
|
||||
}
|
||||
|
||||
async fn cleanup_calendar_events(
|
||||
&self,
|
||||
older_than_unix: Option<i64>,
|
||||
) -> Result<(u32, u32), StoreError> {
|
||||
// Protected = referenced by any meeting's calendar_event_id, full
|
||||
// stop — never deleted regardless of age or the "delete all" choice.
|
||||
let protected: i64 = match older_than_unix {
|
||||
Some(cutoff) => {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM calendar_events
|
||||
WHERE starts_at < ?
|
||||
AND id IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
|
||||
)
|
||||
.bind(cutoff)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM calendar_events
|
||||
WHERE id IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
// calendar_event_participants cascades via its ON DELETE CASCADE FK.
|
||||
let deleted = match older_than_unix {
|
||||
Some(cutoff) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM calendar_events
|
||||
WHERE starts_at < ?
|
||||
AND id NOT IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
|
||||
)
|
||||
.bind(cutoff)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected()
|
||||
}
|
||||
None => {
|
||||
sqlx::query(
|
||||
"DELETE FROM calendar_events
|
||||
WHERE id NOT IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected()
|
||||
}
|
||||
};
|
||||
|
||||
Ok((deleted as u32, protected as u32))
|
||||
}
|
||||
|
||||
async fn attach_meeting_to_event(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
@@ -1094,6 +1221,22 @@ impl Store for SqliteStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_meeting_times(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
) -> Result<(), StoreError> {
|
||||
sqlx::query("UPDATE meetings SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(started_at)
|
||||
.bind(ended_at)
|
||||
.bind(now_unix())
|
||||
.bind(meeting_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn map_speaker_to_participant(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
@@ -1152,6 +1295,26 @@ impl Store for SqliteStore {
|
||||
items: &[ActionItem],
|
||||
) -> Result<Vec<ActionItem>, StoreError> {
|
||||
let now = now_unix();
|
||||
// Reconcile deletes: any row previously saved for this meeting that the
|
||||
// caller no longer includes was removed in the UI (FR-LLM-3). Action
|
||||
// items per meeting number in the low tens, so a per-row delete loop is
|
||||
// fine. ponytail: O(n) delete scan, batch it only if n ever gets large.
|
||||
let keep: std::collections::HashSet<&str> =
|
||||
items.iter().filter_map(|i| i.id.as_deref()).collect();
|
||||
let existing_ids: Vec<String> =
|
||||
sqlx::query_scalar("SELECT id FROM action_items WHERE meeting_id = ?")
|
||||
.bind(id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for eid in &existing_ids {
|
||||
if !keep.contains(eid.as_str()) {
|
||||
sqlx::query("DELETE FROM action_items WHERE id = ? AND meeting_id = ?")
|
||||
.bind(eid)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let mut saved = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let item_id = match &item.id {
|
||||
@@ -1201,6 +1364,19 @@ impl Store for SqliteStore {
|
||||
async fn import_calendar_events(&self, events: Vec<ImportedEvent>) -> Result<u32, StoreError> {
|
||||
let mut imported = 0u32;
|
||||
for ImportedEvent { event, attendees } in events {
|
||||
// Bug fix: `raw_uid = NULL` is explicitly exempt from the
|
||||
// (source, raw_uid) unique index below, so any caller that
|
||||
// passes one through duplicated that event on every re-import
|
||||
// forever. `parse_vevents` no longer produces one, but default
|
||||
// here too — defense in depth for any other/future import path.
|
||||
let raw_uid = event.raw_uid.clone().or_else(|| {
|
||||
Some(crate::calendar::content_uid(
|
||||
&event.subject,
|
||||
&event.organizer,
|
||||
event.starts_at,
|
||||
event.ends_at,
|
||||
))
|
||||
});
|
||||
sqlx::query(
|
||||
"INSERT INTO calendar_events (id, source, subject, organizer, starts_at, ends_at, description, raw_uid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -1218,14 +1394,14 @@ impl Store for SqliteStore {
|
||||
.bind(event.starts_at)
|
||||
.bind(event.ends_at)
|
||||
.bind(&event.description)
|
||||
.bind(&event.raw_uid)
|
||||
.bind(&raw_uid)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// A re-import may have updated an *existing* row rather than
|
||||
// inserting `event.id` — resolve the row that's actually there
|
||||
// before linking attendees to it.
|
||||
let row_id: String = match &event.raw_uid {
|
||||
let row_id: String = match &raw_uid {
|
||||
Some(raw_uid) => {
|
||||
sqlx::query("SELECT id FROM calendar_events WHERE source = ? AND raw_uid = ?")
|
||||
.bind(&event.source)
|
||||
@@ -1308,6 +1484,7 @@ impl Store for SqliteStore {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
self.reindex_fts(meeting_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1846,6 +2023,84 @@ mod tests {
|
||||
assert_eq!(meeting.language.as_deref(), Some("fr"));
|
||||
}
|
||||
|
||||
/// End-to-end regression for the search bug: `meeting_fts` originally had
|
||||
/// no summary/tags columns at all and nothing reindexed on those writes,
|
||||
/// so search silently missed anything that wasn't in the title,
|
||||
/// transcript, or notes (FR-SEARCH-1).
|
||||
#[tokio::test]
|
||||
async fn search_finds_hits_via_transcript_notes_summary_and_tags() {
|
||||
let store = SqliteStore::connect_in_memory().await.unwrap();
|
||||
let id = store
|
||||
.create_meeting(NewMeeting {
|
||||
title: "Weekly sync".to_string(),
|
||||
calendar_event_id: None,
|
||||
template_id: None,
|
||||
language: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.finalize_meeting(
|
||||
&id,
|
||||
FinalizeMeeting {
|
||||
segments: vec![TranscriptSegment {
|
||||
id: 0,
|
||||
start_ms: 0,
|
||||
end_ms: 1000,
|
||||
speaker: "S1".to_string(),
|
||||
text: "let's discuss the transcriptword rollout".to_string(),
|
||||
confidence: None,
|
||||
interim: false,
|
||||
}],
|
||||
speakers: Vec::new(),
|
||||
duration_secs: 60,
|
||||
recorded: false,
|
||||
language: Some("en".to_string()),
|
||||
backend_used: Some("cpu".to_string()),
|
||||
model_used: Some("small-q5_1".to_string()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.update_notes(&id, "action: follow up on notesword")
|
||||
.await
|
||||
.unwrap();
|
||||
store.set_tags(&id, &["tagword".to_string()]).await.unwrap();
|
||||
|
||||
// summary.json isn't written through the store (generate_summary
|
||||
// seals it straight to disk in commands.rs), so reindex_fts must
|
||||
// pick it up when explicitly told to, same as that command does.
|
||||
let summary = SummaryFile {
|
||||
schema: 1,
|
||||
generated_at: 0,
|
||||
provider: "test".to_string(),
|
||||
model: "test".to_string(),
|
||||
summary_md: "summaryword recap".to_string(),
|
||||
decisions: Vec::new(),
|
||||
action_items: Vec::new(),
|
||||
};
|
||||
write_artifact(
|
||||
&paths::meeting_dir(&id).join("summary.json"),
|
||||
serde_json::to_string(&summary).unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
store.reindex_fts(&id).await.unwrap();
|
||||
|
||||
for (query, source) in [
|
||||
("transcriptword", "transcript"),
|
||||
("notesword", "notes"),
|
||||
("summaryword", "summary"),
|
||||
("tagword", "tags"),
|
||||
] {
|
||||
let hits = store.search(query).await.unwrap();
|
||||
assert!(
|
||||
hits.iter().any(|h| h.id == id),
|
||||
"expected a hit from {source} for query {query:?}, got {hits:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_meeting_updates_the_title() {
|
||||
let store = SqliteStore::connect_in_memory().await.unwrap();
|
||||
@@ -1960,6 +2215,164 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the calendar duplicate-import bug: re-importing the
|
||||
/// exact same events (same `(source, raw_uid)`) must update the existing
|
||||
/// rows in place, not add new ones -- whether or not the source
|
||||
/// supplied a `raw_uid` at all.
|
||||
#[tokio::test]
|
||||
async fn reimporting_the_same_events_does_not_duplicate_rows() {
|
||||
let store = SqliteStore::connect_in_memory().await.unwrap();
|
||||
let events = || {
|
||||
vec![
|
||||
ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: "ev1".to_string(),
|
||||
source: "pst".to_string(),
|
||||
subject: Some("Weekly 1:1".to_string()),
|
||||
organizer: None,
|
||||
starts_at: Some(1_000),
|
||||
ends_at: Some(2_000),
|
||||
description: None,
|
||||
raw_uid: Some("uid-1".to_string()),
|
||||
},
|
||||
attendees: vec![],
|
||||
},
|
||||
// No raw_uid at all -- the case that used to duplicate on
|
||||
// every re-import (exempt from the unique index).
|
||||
ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: "ev2".to_string(),
|
||||
source: "pst".to_string(),
|
||||
subject: Some("No UID event".to_string()),
|
||||
organizer: None,
|
||||
starts_at: Some(3_000),
|
||||
ends_at: Some(4_000),
|
||||
description: None,
|
||||
raw_uid: None,
|
||||
},
|
||||
attendees: vec![],
|
||||
},
|
||||
]
|
||||
};
|
||||
store.import_calendar_events(events()).await.unwrap();
|
||||
assert_eq!(
|
||||
store.list_calendar_events(None, None).await.unwrap().len(),
|
||||
2
|
||||
);
|
||||
|
||||
// Re-import the identical batch a few times, as a user retrying an
|
||||
// import would.
|
||||
for _ in 0..3 {
|
||||
store.import_calendar_events(events()).await.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
store.list_calendar_events(None, None).await.unwrap().len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the "14,686 calendar events" bloat report: cleanup
|
||||
/// must delete unlinked old events while never touching one attached to
|
||||
/// a recorded meeting, whether age-bounded or "delete all".
|
||||
#[tokio::test]
|
||||
async fn cleanup_calendar_events_protects_meeting_linked_rows() {
|
||||
let store = SqliteStore::connect_in_memory().await.unwrap();
|
||||
let event = |id: &str, starts_at: i64| ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: id.to_string(),
|
||||
source: "pst".to_string(),
|
||||
subject: Some(id.to_string()),
|
||||
organizer: None,
|
||||
starts_at: Some(starts_at),
|
||||
ends_at: Some(starts_at + 1_800),
|
||||
description: None,
|
||||
raw_uid: Some(id.to_string()),
|
||||
},
|
||||
attendees: vec![],
|
||||
};
|
||||
store
|
||||
.import_calendar_events(vec![
|
||||
event("old-unlinked", 1_000),
|
||||
event("old-linked", 1_000),
|
||||
event("recent-unlinked", 1_000_000_000),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let meeting_id = store
|
||||
.create_meeting(NewMeeting {
|
||||
title: "Untitled meeting".to_string(),
|
||||
calendar_event_id: None,
|
||||
template_id: None,
|
||||
language: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.attach_meeting_to_event(&meeting_id, "old-linked")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Cutoff between the two "old" timestamps and the "recent" one.
|
||||
let (deleted, protected) = store.cleanup_calendar_events(Some(500_000)).await.unwrap();
|
||||
assert_eq!(deleted, 1, "only old-unlinked should go");
|
||||
assert_eq!(protected, 1, "old-linked is attached to a meeting");
|
||||
|
||||
let remaining: Vec<String> = store
|
||||
.list_calendar_events(None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
assert!(!remaining.contains(&"old-unlinked".to_string()));
|
||||
assert!(remaining.contains(&"old-linked".to_string()));
|
||||
assert!(remaining.contains(&"recent-unlinked".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_calendar_events_delete_all_still_protects_linked_rows() {
|
||||
let store = SqliteStore::connect_in_memory().await.unwrap();
|
||||
let event = |id: &str| ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: id.to_string(),
|
||||
source: "pst".to_string(),
|
||||
subject: Some(id.to_string()),
|
||||
organizer: None,
|
||||
starts_at: Some(1_000),
|
||||
ends_at: Some(2_800),
|
||||
description: None,
|
||||
raw_uid: Some(id.to_string()),
|
||||
},
|
||||
attendees: vec![],
|
||||
};
|
||||
store
|
||||
.import_calendar_events(vec![event("unlinked"), event("linked")])
|
||||
.await
|
||||
.unwrap();
|
||||
let meeting_id = store
|
||||
.create_meeting(NewMeeting {
|
||||
title: "Untitled meeting".to_string(),
|
||||
calendar_event_id: None,
|
||||
template_id: None,
|
||||
language: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.attach_meeting_to_event(&meeting_id, "linked")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (deleted, protected) = store.cleanup_calendar_events(None).await.unwrap();
|
||||
assert_eq!(deleted, 1);
|
||||
assert_eq!(protected, 1);
|
||||
assert_eq!(
|
||||
store.list_calendar_events(None, None).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_target_crud_round_trips() {
|
||||
let store = SqliteStore::connect_in_memory().await.unwrap();
|
||||
|
||||
@@ -87,6 +87,17 @@ fn resolve_language(requested: Option<&str>, multilingual: bool) -> Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments whisper.cpp itself flags as more likely silence than speech are
|
||||
/// dropped rather than emitted (see `run_full`) — whisper.cpp's CLI ships
|
||||
/// this same 0.6 default for `--no-speech-thold`.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
const NO_SPEECH_THRESHOLD: f32 = 0.6;
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
fn is_likely_speech(no_speech_probability: f32) -> bool {
|
||||
no_speech_probability <= NO_SPEECH_THRESHOLD
|
||||
}
|
||||
|
||||
/// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features).
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub struct WhisperTranscriber {
|
||||
@@ -185,6 +196,15 @@ impl WhisperTranscriber {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// whisper.cpp's own `no_speech_thold` gate is a no-op (unimplemented
|
||||
// upstream as of whisper-rs 0.16 / whisper.cpp v1.3.0+), so silent/
|
||||
// near-silent windows still decode — and greedy short-window decode
|
||||
// reliably hallucinates a short filler word ("you", "Thank you.")
|
||||
// instead of emitting nothing. Drop those ourselves: matches
|
||||
// whisper.cpp's own CLI default threshold for "this was silence".
|
||||
if !is_likely_speech(seg.no_speech_probability()) {
|
||||
continue;
|
||||
}
|
||||
// Whisper timestamps are centiseconds (10ms units).
|
||||
let start_ms = offset_ms + seg.start_timestamp().max(0) as u64 * 10;
|
||||
let end_ms = if single_segment {
|
||||
@@ -297,63 +317,215 @@ pub mod onnx_models;
|
||||
#[cfg(feature = "npu")]
|
||||
pub use npu::OnnxTranscriber;
|
||||
|
||||
/// Streaming window worker (Phase 1, T1.5/T1.6): accumulates raw 16kHz-mono
|
||||
/// chunks from the `audio` service into fixed-size, **non-overlapping** windows
|
||||
/// and runs one `transcribe_stream` pass per window as it fills, forwarding
|
||||
/// each produced segment to `on_segment` (e.g. a Tauri event emit).
|
||||
const STREAM_SAMPLE_RATE: usize = 16_000;
|
||||
|
||||
/// Cadence/length knobs for the live streaming worker (`Streamer`). Tuned for a
|
||||
/// fluid transcript that shows words ~1s after they're spoken and breaks lines
|
||||
/// at natural pauses rather than on a fixed clock.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StreamTuning {
|
||||
/// How often the growing window is re-decoded (and the interim line
|
||||
/// refreshed). Lower = more responsive, but more CPU (each decode re-runs
|
||||
/// whisper over the whole in-progress window).
|
||||
pub step_ms: u64,
|
||||
/// Hard cap on an uncommitted window: once the current line reaches this
|
||||
/// without a natural pause, it's force-committed so the window (and its
|
||||
/// per-step decode cost) can't grow without bound.
|
||||
pub max_window_ms: u64,
|
||||
/// Don't commit a line shorter than this on a detected pause — avoids
|
||||
/// chopping a brief hesitation into its own one-word line.
|
||||
pub min_commit_ms: u64,
|
||||
}
|
||||
|
||||
impl StreamTuning {
|
||||
/// `low_overhead` doubles the decode step (halving CPU) at the cost of a
|
||||
/// slightly less immediate transcript — matches the Settings "low overhead"
|
||||
/// preset (CPU + smallest model, for battery/background use).
|
||||
pub fn new(low_overhead: bool) -> Self {
|
||||
Self {
|
||||
step_ms: if low_overhead { 2000 } else { 1000 },
|
||||
max_window_ms: 12_000,
|
||||
min_commit_ms: if low_overhead { 2000 } else { 1500 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamTuning {
|
||||
fn default() -> Self {
|
||||
Self::new(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental live-transcript state machine, independent of any transcription
|
||||
/// engine (a `decode: &[f32] -> String` closure is injected) so its
|
||||
/// commit/interim logic is unit-testable without whisper.
|
||||
///
|
||||
/// `transcribe_stream` takes a `SegmentSink` per the `Transcriber` trait (so a
|
||||
/// future async/threaded engine can push mid-inference), but whisper.cpp's
|
||||
/// `full()` call is synchronous — by the time a window's `transcribe_stream`
|
||||
/// call returns, every segment it produced is already sitting in a fresh
|
||||
/// per-window channel, so this drains it inline rather than needing a second
|
||||
/// long-lived thread just to bridge segments out.
|
||||
/// The model: keep one growing "uncommitted" window of audio. Every `step_ms`,
|
||||
/// re-decode the whole window and emit it as an **interim** segment with a
|
||||
/// stable id — so the current line grows in place (fluid, low-latency) instead
|
||||
/// of popping in whole every few seconds. When the decoded text stops changing
|
||||
/// for a step (the speaker paused, so the extra audio was silence) the line is
|
||||
/// **committed** (`interim = false`, same id) and a fresh window/line begins —
|
||||
/// so lines break at natural sentence pauses, not on a fixed 4s clock. A
|
||||
/// `max_window_ms` backstop force-commits a pause-free monologue.
|
||||
///
|
||||
/// True incremental/partial-word streaming (and window overlap for continuity)
|
||||
/// are out of scope for Phase 1: whisper.cpp transcribes each window from
|
||||
/// scratch, so an overlapping window would re-emit the overlapped words a
|
||||
/// second time with no stitching logic to merge them — a worse rough edge for
|
||||
/// a live transcript than the occasional word clipped at a window boundary.
|
||||
/// "Near real time" (FR-TRX-2) is met by short (~4s) windows; `interim` stays
|
||||
/// `false` for every segment produced here.
|
||||
/// whisper.cpp isn't a true streaming recognizer (it re-decodes from scratch),
|
||||
/// so this trades CPU — the growing window is re-decoded every step — for a
|
||||
/// natural-looking transcript. `audio_ctx_for_window` keeps each decode's cost
|
||||
/// proportional to the window length rather than a full 30s encode, and
|
||||
/// committing on pauses keeps the window short for conversational speech.
|
||||
pub struct Streamer {
|
||||
step_len: usize,
|
||||
max_len: usize,
|
||||
min_commit_len: usize,
|
||||
window: Vec<f32>,
|
||||
committed_offset_ms: u64,
|
||||
since_last_decode: usize,
|
||||
next_id: u64,
|
||||
last_text: String,
|
||||
}
|
||||
|
||||
impl Streamer {
|
||||
pub fn new(tuning: StreamTuning) -> Self {
|
||||
let per_ms = STREAM_SAMPLE_RATE / 1000;
|
||||
Self {
|
||||
step_len: tuning.step_ms as usize * per_ms,
|
||||
max_len: tuning.max_window_ms as usize * per_ms,
|
||||
min_commit_len: tuning.min_commit_ms as usize * per_ms,
|
||||
window: Vec::new(),
|
||||
committed_offset_ms: 0,
|
||||
since_last_decode: 0,
|
||||
next_id: 0,
|
||||
last_text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append newly captured audio; decode + emit once a step's worth has
|
||||
/// accumulated. `decode(samples, offset_ms)` returns the transcript of the
|
||||
/// window so far (empty for silence).
|
||||
pub fn feed<D, F>(&mut self, chunk: &[f32], decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.window.extend_from_slice(chunk);
|
||||
self.since_last_decode += chunk.len();
|
||||
if self.since_last_decode >= self.step_len {
|
||||
self.since_last_decode = 0;
|
||||
self.tick(false, decode, on_segment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit whatever's in flight — called once when capture stops so the last
|
||||
/// in-progress line is finalized rather than left interim.
|
||||
pub fn flush<D, F>(&mut self, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.tick(true, decode, on_segment);
|
||||
}
|
||||
|
||||
fn tick<D, F>(&mut self, force: bool, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
if self.window.is_empty() {
|
||||
return;
|
||||
}
|
||||
let window_ms = (self.window.len() as u64 * 1000) / STREAM_SAMPLE_RATE as u64;
|
||||
let text = decode(&self.window, self.committed_offset_ms);
|
||||
let over_max = self.window.len() >= self.max_len;
|
||||
|
||||
// Nothing recognized yet (leading/standalone silence): don't show an
|
||||
// empty line, but still drop the buffer once it's grown too big so we
|
||||
// aren't re-decoding a long silence every step.
|
||||
if text.is_empty() {
|
||||
if over_max || force {
|
||||
self.reset(window_ms, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let stable = text == self.last_text;
|
||||
let commit = force || over_max || (stable && self.window.len() >= self.min_commit_len);
|
||||
on_segment(TranscriptSegment {
|
||||
id: self.next_id,
|
||||
start_ms: self.committed_offset_ms,
|
||||
end_ms: self.committed_offset_ms + window_ms,
|
||||
// Provisional speaker; the post-stop diarization pass reassigns.
|
||||
speaker: "S1".to_string(),
|
||||
text: text.clone(),
|
||||
confidence: None,
|
||||
interim: !commit,
|
||||
});
|
||||
if commit {
|
||||
self.reset(window_ms, true);
|
||||
} else {
|
||||
self.last_text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a fresh window/line after a commit (`new_line`) or after dropping
|
||||
/// leading silence (`!new_line`, which reuses the id since nothing was
|
||||
/// emitted for it).
|
||||
fn reset(&mut self, window_ms: u64, new_line: bool) {
|
||||
self.committed_offset_ms += window_ms;
|
||||
self.window.clear();
|
||||
self.last_text.clear();
|
||||
self.since_last_decode = 0;
|
||||
if new_line {
|
||||
self.next_id += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live streaming worker (FR-TRX-2): drives a `Streamer` from the `audio`
|
||||
/// service's 16kHz-mono frames, decoding each growing window with `transcriber`
|
||||
/// and forwarding interim-then-final segments to `on_segment` (a Tauri emit).
|
||||
///
|
||||
/// `transcribe_stream` is synchronous for whisper.cpp — by the time it returns,
|
||||
/// every segment it produced is already in the per-call channel — so this
|
||||
/// drains it inline and joins the text into the window's transcript for the
|
||||
/// `Streamer` to diff/commit.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub fn run_streaming_worker<T, F>(transcriber: &T, frame_rx: Receiver<Vec<f32>>, mut on_segment: F)
|
||||
where
|
||||
pub fn run_streaming_worker<T, F>(
|
||||
transcriber: &T,
|
||||
frame_rx: Receiver<Vec<f32>>,
|
||||
tuning: StreamTuning,
|
||||
mut on_segment: F,
|
||||
) where
|
||||
// `?Sized` so the dispatcher can hand us a `&dyn Transcriber` (whisper.cpp
|
||||
// or the NPU engine, chosen at runtime) rather than a concrete type.
|
||||
T: Transcriber + ?Sized,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
const SAMPLE_RATE: usize = 16_000;
|
||||
const WINDOW_SECS: f32 = 4.0;
|
||||
let window_len = (WINDOW_SECS * SAMPLE_RATE as f32) as usize;
|
||||
|
||||
let mut buf: Vec<f32> = Vec::new();
|
||||
let mut offset_ms: u64 = 0;
|
||||
|
||||
let run_window = |transcriber: &T, samples: Vec<f32>, offset_ms: u64, on_segment: &mut F| {
|
||||
let decode = |samples: &[f32], offset_ms: u64| -> String {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let window = AudioWindow { samples, offset_ms };
|
||||
let window = AudioWindow {
|
||||
samples: samples.to_vec(),
|
||||
offset_ms,
|
||||
};
|
||||
if let Err(e) = transcriber.transcribe_stream(window, tx) {
|
||||
tracing::warn!("transcription window failed: {e}");
|
||||
return String::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
while let Ok(segment) = rx.try_recv() {
|
||||
on_segment(segment);
|
||||
let t = segment.text.trim();
|
||||
if !t.is_empty() {
|
||||
parts.push(t.to_string());
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
};
|
||||
|
||||
let mut streamer = Streamer::new(tuning);
|
||||
while let Ok(chunk) = frame_rx.recv() {
|
||||
buf.extend_from_slice(&chunk);
|
||||
while buf.len() >= window_len {
|
||||
let samples: Vec<f32> = buf.drain(..window_len).collect();
|
||||
run_window(transcriber, samples, offset_ms, &mut on_segment);
|
||||
offset_ms += (window_len as u64 * 1000) / SAMPLE_RATE as u64;
|
||||
}
|
||||
}
|
||||
// Final partial window on stop, if there's enough audio to be worth a pass.
|
||||
if buf.len() > SAMPLE_RATE / 2 {
|
||||
run_window(transcriber, buf, offset_ms, &mut on_segment);
|
||||
streamer.feed(&chunk, &decode, &mut on_segment);
|
||||
}
|
||||
streamer.flush(&decode, &mut on_segment);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -361,6 +533,113 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Collect a step's emitted segments into `out` — the pushing closure lives
|
||||
// and dies inside the call, so `out` is free to read in the asserts after
|
||||
// (a single long-lived `on` closure would keep `out` mutably borrowed).
|
||||
fn feed_into<D: Fn(&[f32], u64) -> String>(
|
||||
s: &mut Streamer,
|
||||
chunk: &[f32],
|
||||
decode: &D,
|
||||
out: &mut Vec<TranscriptSegment>,
|
||||
) {
|
||||
s.feed(chunk, decode, &mut |seg| out.push(seg));
|
||||
}
|
||||
|
||||
fn flush_into<D: Fn(&[f32], u64) -> String>(
|
||||
s: &mut Streamer,
|
||||
decode: &D,
|
||||
out: &mut Vec<TranscriptSegment>,
|
||||
) {
|
||||
s.flush(decode, &mut |seg| out.push(seg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_grows_interim_then_commits_on_a_pause() {
|
||||
// Text grows for two steps, then repeats (the speaker paused, so the
|
||||
// extra second was silence) → the line commits.
|
||||
let tuning = StreamTuning {
|
||||
step_ms: 1000,
|
||||
max_window_ms: 12_000,
|
||||
min_commit_ms: 1500,
|
||||
};
|
||||
let mut s = Streamer::new(tuning);
|
||||
let script = std::cell::Cell::new(0usize);
|
||||
let texts = ["hello", "hello world", "hello world"];
|
||||
let decode = |_: &[f32], _off: u64| -> String {
|
||||
let i = script.get();
|
||||
script.set(i + 1);
|
||||
texts.get(i).copied().unwrap_or("hello world").to_string()
|
||||
};
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000]; // exactly one 1s step
|
||||
|
||||
feed_into(&mut s, &step, &decode, &mut out); // "hello" — interim
|
||||
feed_into(&mut s, &step, &decode, &mut out); // "hello world" — interim
|
||||
feed_into(&mut s, &step, &decode, &mut out); // stable + past min_commit → commit
|
||||
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out[0].interim && out[0].text == "hello");
|
||||
assert!(out[1].interim && out[1].text == "hello world");
|
||||
assert!(!out[2].interim && out[2].text == "hello world");
|
||||
assert_eq!(out[0].id, out[2].id, "same line id until it commits");
|
||||
|
||||
// A new line after the commit uses a fresh id and a later offset.
|
||||
let d2 = |_: &[f32], _o: u64| "next sentence".to_string();
|
||||
feed_into(&mut s, &step, &d2, &mut out);
|
||||
assert!(out[3].interim && out[3].text == "next sentence");
|
||||
assert_ne!(out[3].id, out[2].id);
|
||||
assert!(out[3].start_ms >= 3000, "starts after the 3 committed steps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_shows_no_line_for_pure_silence() {
|
||||
let mut s = Streamer::new(StreamTuning::new(false));
|
||||
let decode = |_: &[f32], _o: u64| String::new();
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000];
|
||||
for _ in 0..5 {
|
||||
feed_into(&mut s, &step, &decode, &mut out);
|
||||
}
|
||||
assert!(out.is_empty(), "silence must not emit an empty line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_flush_commits_the_in_progress_line() {
|
||||
let mut s = Streamer::new(StreamTuning::new(false));
|
||||
let decode = |_: &[f32], _o: u64| "partial".to_string();
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
feed_into(&mut s, &vec![0.0f32; 16_000], &decode, &mut out);
|
||||
assert!(out.last().unwrap().interim, "still growing before flush");
|
||||
flush_into(&mut s, &decode, &mut out);
|
||||
let last = out.last().unwrap();
|
||||
assert!(!last.interim && last.text == "partial", "flush finalizes it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_force_commits_a_pause_free_monologue_at_max() {
|
||||
// Text never repeats (continuous speech), so only the max-window
|
||||
// backstop can commit it — otherwise the window (and decode cost) grows
|
||||
// without bound.
|
||||
let tuning = StreamTuning {
|
||||
step_ms: 1000,
|
||||
max_window_ms: 3000,
|
||||
min_commit_ms: 1500,
|
||||
};
|
||||
let mut s = Streamer::new(tuning);
|
||||
let n = std::cell::Cell::new(0usize);
|
||||
let decode = |_: &[f32], _o: u64| {
|
||||
let i = n.get();
|
||||
n.set(i + 1);
|
||||
format!("word{i}")
|
||||
};
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000];
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 1s
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 2s
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 3s == max → force commit
|
||||
assert!(!out.last().unwrap().interim);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_ctx_scales_proportionally_to_window_length() {
|
||||
// The actual streaming WINDOW_SECS (4.0) -> ~200 (201 after `.ceil()`
|
||||
@@ -383,6 +662,17 @@ mod tests {
|
||||
assert_eq!(audio_ctx_for_window(60 * 16_000), 1500); // 60s window
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_likely_speech_drops_high_no_speech_probability_windows() {
|
||||
// Silence/near-silence: whisper.cpp's own greedy hallucination case
|
||||
// ("you", "Thank you.") on an otherwise-quiet window.
|
||||
assert!(!is_likely_speech(0.9));
|
||||
assert!(!is_likely_speech(NO_SPEECH_THRESHOLD + 0.01));
|
||||
// Confident speech kept, including right at the threshold.
|
||||
assert!(is_likely_speech(0.0));
|
||||
assert!(is_likely_speech(NO_SPEECH_THRESHOLD));
|
||||
}
|
||||
|
||||
/// GPU spike (opt-in): times whisper.cpp on a chosen backend against a real
|
||||
/// model + wav. With `--features vulkan` and `WA_BACKEND=intel` (or nvidia/amd)
|
||||
/// whisper.cpp offloads to the GPU; `WA_BACKEND=cpu` is the baseline. Run:
|
||||
|
||||
@@ -40,7 +40,9 @@ const CATALOG: &[Catalog] = &[
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "medium.en-q5_1",
|
||||
// ggerganov/whisper.cpp only ships a q5_0 quantization for medium
|
||||
// (q5_1 doesn't exist upstream for this size) — q5_1 here 404s.
|
||||
id: "medium.en-q5_0",
|
||||
label: "Medium (English, quantized) — best accuracy, slowest",
|
||||
size_mb: 540,
|
||||
multilingual: false,
|
||||
@@ -64,7 +66,8 @@ const CATALOG: &[Catalog] = &[
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "medium-q5_1",
|
||||
// Same upstream-availability caveat as medium.en above.
|
||||
id: "medium-q5_0",
|
||||
label: "Medium (multilingual, quantized) — best accuracy, slowest",
|
||||
size_mb: 540,
|
||||
multilingual: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "WhispAssist",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "bet.dou.whispassist",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+249
-50
@@ -7,17 +7,40 @@
|
||||
import Settings from "./lib/views/Settings.svelte";
|
||||
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
||||
import LevelMeter from "./lib/components/LevelMeter.svelte";
|
||||
import ImportMeeting from "./lib/components/ImportMeeting.svelte";
|
||||
import { trapFocus } from "./lib/actions/trapFocus";
|
||||
import { recording } from "./lib/stores/recording.svelte";
|
||||
import { settings } from "./lib/stores/settings.svelte";
|
||||
import { meetings } from "./lib/stores/meetings.svelte";
|
||||
import { api, type NoteTemplate } from "./lib/api";
|
||||
import { calendar } from "./lib/stores/calendar.svelte";
|
||||
import { api, type NoteTemplate, type CalendarEvent } from "./lib/api";
|
||||
import { onMount } from "svelte";
|
||||
import ThemeToggle from "./lib/components/ThemeToggle.svelte";
|
||||
import { Circle, Square, Trash2, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
|
||||
import Splitter from "./lib/components/Splitter.svelte";
|
||||
import { layout, clamp } from "./lib/stores/layout.svelte";
|
||||
import { t } from "./lib/i18n/index.svelte";
|
||||
import {
|
||||
Circle,
|
||||
Square,
|
||||
Trash2,
|
||||
FilePlus,
|
||||
Settings as SettingsIcon,
|
||||
AlertTriangle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let showConsent = $state(false);
|
||||
let showImport = $state(false);
|
||||
|
||||
// A freshly imported meeting: refresh the list and open it.
|
||||
async function onImported(id: string) {
|
||||
await meetings.load();
|
||||
await meetings.select(id);
|
||||
}
|
||||
|
||||
// Vault unlock gate (T8.8): if the vault is enabled but locked at startup,
|
||||
// prompt for the password so encrypted meetings are readable.
|
||||
@@ -40,7 +63,7 @@
|
||||
vaultLocked = false;
|
||||
meetings.load(); // refresh now that encrypted content is readable
|
||||
} catch {
|
||||
vaultErr = "Incorrect password";
|
||||
vaultErr = t("app.vault_incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +90,52 @@
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
});
|
||||
|
||||
// Auto-start recording on calendar events (opt-in, FR-CAL). No background
|
||||
// work: while the app is open we arm a single one-shot timer to the next
|
||||
// event's start; nothing polls (NFR-RES-1). Events already auto-started this
|
||||
// session are remembered so stopping a recording doesn't re-trigger the same
|
||||
// one, and the horizon caps the timer at setTimeout's safe range.
|
||||
let autoRecordTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const autoStarted = new Set<string>();
|
||||
const AUTO_RECORD_HORIZON_MS = 24 * 60 * 60 * 1000;
|
||||
function autoStartForEvent(ev: CalendarEvent) {
|
||||
if (recording.state !== "idle") return;
|
||||
autoStarted.add(ev.id);
|
||||
meetings.deselect();
|
||||
recording.start(
|
||||
ev.subject ?? undefined,
|
||||
settings.settings.default_record,
|
||||
selectedTemplateId || undefined,
|
||||
ev.id,
|
||||
);
|
||||
}
|
||||
$effect(() => {
|
||||
if (!settings.settings.auto_record_calendar || recording.state !== "idle") return;
|
||||
const now = Date.now();
|
||||
const next = calendar.events
|
||||
.filter((e) => e.starts_at && !autoStarted.has(e.id))
|
||||
.filter((e) => ((e.ends_at ?? e.starts_at) as number) * 1000 > now) // not already over
|
||||
.sort((a, b) => (a.starts_at as number) - (b.starts_at as number))[0];
|
||||
if (!next) return;
|
||||
const delay = (next.starts_at as number) * 1000 - now;
|
||||
if (delay <= 0) {
|
||||
autoStartForEvent(next); // event is happening right now
|
||||
return;
|
||||
}
|
||||
if (delay > AUTO_RECORD_HORIZON_MS) return; // too far out; re-armed on state/event change
|
||||
autoRecordTimer = setTimeout(() => autoStartForEvent(next), delay);
|
||||
return () => clearTimeout(autoRecordTimer);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
recording.init();
|
||||
settings.load();
|
||||
meetings.init();
|
||||
calendar.load(); // events power the auto-record timer above
|
||||
checkVault();
|
||||
api
|
||||
.listNoteTemplates()
|
||||
.then((t) => (noteTemplates = t))
|
||||
.then((tpls) => (noteTemplates = tpls))
|
||||
.catch(() => (noteTemplates = []));
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -100,7 +161,7 @@
|
||||
}
|
||||
|
||||
async function cancelRecording() {
|
||||
if (!confirm("Discard this recording? Its audio and transcript will be deleted.")) return;
|
||||
if (!confirm(t("app.discard_confirm"))) return;
|
||||
await recording.cancel();
|
||||
meetings.deselect();
|
||||
}
|
||||
@@ -126,9 +187,9 @@
|
||||
$effect(() => {
|
||||
const current = recording.state;
|
||||
if (current !== previousRecordingState) {
|
||||
if (current === "recording") recordingAnnouncement = "Recording started";
|
||||
else if (current === "paused") recordingAnnouncement = "Recording paused";
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = "Recording stopped";
|
||||
if (current === "recording") recordingAnnouncement = t("app.announce_started");
|
||||
else if (current === "paused") recordingAnnouncement = t("app.announce_paused");
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = t("app.announce_stopped");
|
||||
previousRecordingState = current;
|
||||
}
|
||||
});
|
||||
@@ -172,63 +233,73 @@
|
||||
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
|
||||
<header class="bar">
|
||||
<strong>WhispAssist</strong>
|
||||
<span class="muted">local · private</span>
|
||||
<span class="muted">{t("app.tagline")}</span>
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<select
|
||||
class="theme-select"
|
||||
bind:value={selectedTemplateId}
|
||||
aria-label="Note template"
|
||||
title="Note template"
|
||||
aria-label={t("app.note_template")}
|
||||
title={t("app.note_template")}
|
||||
>
|
||||
<option value="">No template</option>
|
||||
{#each noteTemplates as t (t.id)}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
<option value="">{t("app.no_template")}</option>
|
||||
{#each noteTemplates as tpl (tpl.id)}
|
||||
<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="Start recording (Ctrl+Shift+R)"
|
||||
title={t("app.record_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Circle size={11} fill="currentColor" aria-hidden="true" />
|
||||
Record
|
||||
{t("app.record")}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="stop-btn"
|
||||
onclick={() => recording.stop()}
|
||||
title="Stop recording (Ctrl+Shift+R)"
|
||||
title={t("app.stop_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Square size={11} fill="currentColor" aria-hidden="true" />
|
||||
Stop
|
||||
{t("app.stop")}
|
||||
</button>
|
||||
<button
|
||||
class="cancel-btn"
|
||||
onclick={cancelRecording}
|
||||
title="Discard this recording and delete it"
|
||||
>
|
||||
<button class="cancel-btn" onclick={cancelRecording} title={t("app.cancel_title")}>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
Cancel
|
||||
{t("app.cancel")}
|
||||
</button>
|
||||
<span class="rec">
|
||||
<span class="rec-dot" aria-hidden="true"></span>
|
||||
Recording…
|
||||
{t("app.recording")}
|
||||
</span>
|
||||
<LevelMeter rms={recording.levelRms} peak={recording.levelPeak} />
|
||||
<LevelMeter
|
||||
rms={recording.levelRms}
|
||||
peak={recording.levelPeak}
|
||||
micRms={recording.levelRmsMic}
|
||||
micPeak={recording.levelPeakMic}
|
||||
showMic={settings.settings.microphone_enabled}
|
||||
/>
|
||||
{#if settings.hardware}
|
||||
<span class="backend" title="Active transcription backend">{settings.hardware.active}</span>
|
||||
<span class="backend" title={t("app.backend_title")}>{settings.hardware.active}</span>
|
||||
{/if}
|
||||
<label class="retention" title="Save audio as .wav for this meeting">
|
||||
<label class="retention" title={t("app.retention_title")}>
|
||||
<input type="checkbox" checked={recording.retention} onchange={onToggleRetention} />
|
||||
<span>{recording.retention ? "saving" : "not saved"}</span>
|
||||
<span>{recording.retention ? t("app.saving") : t("app.not_saved")}</span>
|
||||
</label>
|
||||
{#if recording.deviceNotice}
|
||||
<span class="device-notice" role="status" title={recording.deviceNotice}>
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
reconnecting audio device…
|
||||
{t("app.reconnecting")}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -238,8 +309,8 @@
|
||||
/>
|
||||
<button
|
||||
class="icon"
|
||||
aria-label="Settings"
|
||||
title="Settings (Ctrl+,)"
|
||||
aria-label={t("app.settings")}
|
||||
title={t("app.settings_title")}
|
||||
aria-keyshortcuts="Control+,"
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
>
|
||||
@@ -252,7 +323,7 @@
|
||||
class="consent-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Recording consent"
|
||||
aria-label={t("app.consent_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
@@ -264,37 +335,124 @@
|
||||
<Settings onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showImport}
|
||||
<ImportMeeting onClose={() => (showImport = false)} {onImported} />
|
||||
{/if}
|
||||
|
||||
{#if vaultLocked}
|
||||
<div
|
||||
class="vault-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Unlock encryption vault"
|
||||
aria-label={t("app.vault_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="vault-card">
|
||||
<h2>Unlock encryption vault</h2>
|
||||
<p class="muted">Your meetings are encrypted at rest. Enter your password to read them.</p>
|
||||
<h2>{t("app.vault_title")}</h2>
|
||||
<p class="muted">{t("app.vault_desc")}</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Vault password"
|
||||
placeholder={t("app.vault_password")}
|
||||
bind:value={vaultPw}
|
||||
onkeydown={(e) => e.key === "Enter" && submitUnlock()}
|
||||
/>
|
||||
{#if vaultErr}<p class="vault-err">{vaultErr}</p>{/if}
|
||||
<div class="vault-actions">
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}>Unlock</button>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}>Continue locked</button>
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}
|
||||
>{t("app.unlock")}</button
|
||||
>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}
|
||||
>{t("app.continue_locked")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="panes">
|
||||
<aside class="left"><MeetingsList /></aside>
|
||||
<main
|
||||
class="panes"
|
||||
style="grid-template-columns: {layout.leftCollapsed
|
||||
? 'auto'
|
||||
: layout.leftWidth + 'px'} auto 1fr auto {layout.rightCollapsed
|
||||
? 'auto'
|
||||
: layout.rightWidth + 'px'};"
|
||||
>
|
||||
<aside class="side left" class:collapsed={layout.leftCollapsed}>
|
||||
{#if layout.leftCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={() => {
|
||||
layout.leftCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.show_meetings")}
|
||||
aria-label={t("app.show_meetings")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={() => {
|
||||
layout.leftCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.hide_meetings")}
|
||||
aria-label={t("app.hide_meetings")}
|
||||
>
|
||||
<PanelLeftClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="side-content"><MeetingsList /></div>
|
||||
{/if}
|
||||
</aside>
|
||||
{#if !layout.leftCollapsed}
|
||||
<Splitter
|
||||
label={t("app.resize_meetings")}
|
||||
onResize={(d) => (layout.leftWidth = clamp(layout.leftWidth + d, 200, 480))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<section class="center"><TranscriptNotes /></section>
|
||||
<aside class="right"><SummaryPanel /></aside>
|
||||
{#if !layout.rightCollapsed}
|
||||
<Splitter
|
||||
label={t("app.resize_summary")}
|
||||
onResize={(d) => (layout.rightWidth = clamp(layout.rightWidth - d, 240, 560))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<aside class="side right" class:collapsed={layout.rightCollapsed}>
|
||||
{#if layout.rightCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={() => {
|
||||
layout.rightCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.show_summary")}
|
||||
aria-label={t("app.show_summary")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={() => {
|
||||
layout.rightCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.hide_summary")}
|
||||
aria-label={t("app.hide_summary")}
|
||||
>
|
||||
<PanelRightClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="side-content"><SummaryPanel /></div>
|
||||
{/if}
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -442,7 +600,8 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.record-btn,
|
||||
.stop-btn {
|
||||
.stop-btn,
|
||||
.import-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -457,9 +616,18 @@
|
||||
transform 100ms ease-out;
|
||||
}
|
||||
.record-btn:active,
|
||||
.stop-btn:active {
|
||||
.stop-btn:active,
|
||||
.import-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.import-btn {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.import-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.record-btn {
|
||||
background: var(--danger);
|
||||
color: #ffffff;
|
||||
@@ -592,25 +760,56 @@
|
||||
}
|
||||
.panes {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 320px;
|
||||
/* grid-template-columns set inline — depends on collapse/resize state (FR-UX-1). */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-color: var(--border);
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.left {
|
||||
.side.left {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.right {
|
||||
.side.right {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.side.collapsed {
|
||||
align-items: center;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.side-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.pane-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.pane-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.pane-toggle.inline {
|
||||
align-self: flex-end;
|
||||
margin: 0.3rem 0.3rem 0;
|
||||
}
|
||||
.center {
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
min-width: 0;
|
||||
}
|
||||
.vault-overlay {
|
||||
position: fixed;
|
||||
|
||||
+56
-6
@@ -172,6 +172,10 @@ export interface Meeting {
|
||||
speakers: SpeakerInfo[];
|
||||
notes_markdown: string;
|
||||
summary: SummaryFile | null;
|
||||
// Confirmed/edited action items (table-backed source of truth) — falls back
|
||||
// to summary.action_items drafts until anything is saved. Manage via
|
||||
// confirmActionItems (add/edit/delete).
|
||||
action_items: ActionItem[];
|
||||
calendar_event_id: string | null;
|
||||
tags: string[];
|
||||
template_id: string | null;
|
||||
@@ -209,6 +213,11 @@ export interface CalendarEventDetail {
|
||||
participants: Participant[];
|
||||
}
|
||||
|
||||
export interface CalendarCleanupResult {
|
||||
deleted: number;
|
||||
protected: number;
|
||||
}
|
||||
|
||||
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
|
||||
|
||||
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
|
||||
@@ -318,6 +327,12 @@ export interface AppSettings {
|
||||
retention_max_size_gb: number | null;
|
||||
pst_last_path: string | null;
|
||||
pst_auto_sync: boolean;
|
||||
// Days of history to import (both a manual Import click and pst_auto_sync);
|
||||
// null = full mailbox history.
|
||||
pst_import_range_days: number | null;
|
||||
/** Auto-start recording when a calendar event begins while the app is open
|
||||
* (opt-in, off by default). Armed as a one-shot UI timer — nothing polls. */
|
||||
auto_record_calendar: boolean;
|
||||
audio_output_device: string | null;
|
||||
microphone_enabled: boolean;
|
||||
audio_input_device: string | null;
|
||||
@@ -387,6 +402,14 @@ export const api = {
|
||||
invoke<void>("set_recording_retention", { meetingId, record }),
|
||||
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
|
||||
|
||||
// Live notes (Granola-style redesign): both live-session only, err once
|
||||
// the meeting is finalized — use updateNotes on the merged notes.md instead.
|
||||
updateLiveNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_live_notes", { meetingId, markdown }),
|
||||
// anchorMs: the clicked segment's start_ms (not its id). text: "" clears it.
|
||||
setSegmentNote: (meetingId: MeetingId, anchorMs: number, text: string) =>
|
||||
invoke<void>("set_segment_note", { meetingId, anchorMs, text }),
|
||||
|
||||
appInfo: () => invoke<AppInfo>("app_info"),
|
||||
openUrl: (url: string) => invoke<void>("open_url", { url }),
|
||||
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
|
||||
@@ -397,16 +420,27 @@ export const api = {
|
||||
downloadNpuPackage: () => invoke<void>("download_npu_package"),
|
||||
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
|
||||
listModels: () => invoke<ModelInfo[]>("list_models"),
|
||||
// The fixed segmentation+embedding pair that speaker diarization needs
|
||||
// installed before it can separate speakers (T4.7, FR-MODEL-1). Same
|
||||
// ModelInfo shape as whisper models; download via `downloadModel` with the
|
||||
// `diar-seg`/`diar-emb` kind, remove via the shared `removeModel`.
|
||||
listDiarizationModels: () => invoke<ModelInfo[]>("list_diarization_models"),
|
||||
// T8.7/FR-TRX-4: static catalog of whisper.cpp-recognized language codes
|
||||
// for the Settings dropdown; "Auto-detect" is a frontend-only addition.
|
||||
listWhisperLanguages: () => invoke<LanguageOption[]>("list_whisper_languages"),
|
||||
downloadModel: (id: string, kind: "whisper" = "whisper") =>
|
||||
downloadModel: (id: string, kind: "whisper" | "diar-seg" | "diar-emb" = "whisper") =>
|
||||
invoke<void>("download_model", { args: { kind, id } }),
|
||||
removeModel: (id: string) => invoke<void>("remove_model", { id }),
|
||||
// `language` (T8.7): omit/undefined reuses whatever language the meeting
|
||||
// already had rather than resetting it to auto.
|
||||
reprocessTranscript: (meetingId: MeetingId, model: string, language?: string) =>
|
||||
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 }),
|
||||
resumeTranscription: (meetingId: MeetingId) =>
|
||||
invoke<void>("resume_transcription", { meetingId }),
|
||||
listMeetings: (filter?: MeetingFilter) =>
|
||||
@@ -426,9 +460,13 @@ export const api = {
|
||||
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
|
||||
updateNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_notes", { meetingId, markdown }),
|
||||
// dest is a file path for md/pdf/docx, a folder for bundle.
|
||||
exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "pdf" | "docx" | "bundle") =>
|
||||
invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// 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: (
|
||||
meetingId: MeetingId,
|
||||
dest: string,
|
||||
format: "md" | "pdf" | "docx" | "bundle" | "obsidian",
|
||||
) => invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// Every meeting matching tag/date filters, one file (or bundle folder) per
|
||||
// meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4).
|
||||
bulkExportMeetings: (
|
||||
@@ -443,6 +481,10 @@ export const api = {
|
||||
from: filter?.from,
|
||||
to: filter?.to,
|
||||
}),
|
||||
// Import bundle(s) exported with format "bundle" — dir is a single bundle
|
||||
// folder or a parent folder of them. Reconstructs each under a fresh id and
|
||||
// returns the count imported (FR-STORE-4).
|
||||
importMeetingBundle: (dir: string) => invoke<number>("import_meeting_bundle", { dir }),
|
||||
|
||||
llmStatus: () => invoke<LlmStatus>("llm_status"),
|
||||
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
|
||||
@@ -458,7 +500,15 @@ export const api = {
|
||||
invoke<void>("confirm_action_items", { meetingId, items }),
|
||||
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
|
||||
|
||||
importPst: (path: string, password?: string) => invoke<number>("import_pst", { path, password }),
|
||||
// rangeDays: only import events starting within the last N days; omitted/undefined imports
|
||||
// the full mailbox history (a long-lived .pst otherwise re-imports years of recurring/holiday
|
||||
// entries on every launch when pst_auto_sync is on).
|
||||
importPst: (path: string, password?: string, rangeDays?: number) =>
|
||||
invoke<number>("import_pst", { path, password, rangeDays }),
|
||||
// olderThanDays: undefined deletes every unlinked event ("Delete all").
|
||||
// An event attached to a recorded meeting is always kept either way.
|
||||
cleanupCalendarEvents: (olderThanDays?: number) =>
|
||||
invoke<CalendarCleanupResult>("cleanup_calendar_events", { olderThanDays }),
|
||||
listCalendarEvents: (from?: number, to?: number) =>
|
||||
invoke<CalendarEvent[]>("list_calendar_events", { from, to }),
|
||||
getCalendarEvent: (eventId: string) =>
|
||||
@@ -533,7 +583,7 @@ export const events = {
|
||||
onRetention: (cb: (p: { meetingId: string; record: boolean }) => void): Promise<UnlistenFn> =>
|
||||
listen("recording://retention", (e) => cb(e.payload as never)),
|
||||
onLevel: (
|
||||
cb: (p: { meetingId: string; rms: number; peak: number }) => void,
|
||||
cb: (p: { meetingId: string; rms: number; peak: number; mic: boolean }) => void,
|
||||
): Promise<UnlistenFn> => listen("recording://level", (e) => cb(e.payload as never)),
|
||||
onDeviceChanged: (
|
||||
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
// Settings "Record by default" toggle and the mid-meeting retention toggle
|
||||
// so both gate on the same copy and the same acknowledgment call.
|
||||
import { ShieldAlert } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let { onAccept, onCancel }: { onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="consent">
|
||||
<div class="heading">
|
||||
<ShieldAlert size={18} aria-hidden="true" />
|
||||
<strong>Before you record</strong>
|
||||
<strong>{t("consent.heading")}</strong>
|
||||
</div>
|
||||
<p>
|
||||
Recording conversations without the consent of participants may be illegal in your region. Check
|
||||
your local recording laws. This is a caution, not legal advice.
|
||||
</p>
|
||||
<p>{t("consent.body")}</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — enable recording</button>
|
||||
<button onclick={onCancel}>Cancel</button>
|
||||
<button class="primary" onclick={onAccept}>{t("consent.accept")}</button>
|
||||
<button onclick={onCancel}>{t("consent.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// (recording consent, ADR-0009): both gate a single Settings toggle AND a
|
||||
// second use-time trigger point on the same one-time flag.
|
||||
import { Globe } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
providerLabel,
|
||||
onAccept,
|
||||
@@ -16,16 +17,15 @@
|
||||
<div class="banner" role="alertdialog" aria-labelledby="hosted-ai-heading">
|
||||
<div class="heading">
|
||||
<Globe size={18} aria-hidden="true" />
|
||||
<strong id="hosted-ai-heading">Before using {providerLabel}</strong>
|
||||
<strong id="hosted-ai-heading">{t("hosted.heading", { provider: providerLabel })}</strong>
|
||||
</div>
|
||||
<p>
|
||||
Generating with <strong>{providerLabel}</strong> sends this meeting's transcript to their servers
|
||||
— it leaves this device and is subject to their privacy policy. WhispAssist has no control over data
|
||||
handling once it leaves your device. This is off by default; you're choosing it now.
|
||||
{t("hosted.body_1")} <strong>{providerLabel}</strong>
|
||||
{t("hosted.body_2")}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — continue</button>
|
||||
<button class="ghost" onclick={onCancel}>Cancel</button>
|
||||
<button class="primary" onclick={onAccept}>{t("hosted.accept")}</button>
|
||||
<button class="ghost" onclick={onCancel}>{t("hosted.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<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";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { X, FileUp, Link as LinkIcon } from "@lucide/svelte";
|
||||
|
||||
let { onClose, onImported }: { onClose: () => void; onImported: (id: string) => void } = $props();
|
||||
|
||||
// `source` is either a local file path (set via Browse) or a URL (typed).
|
||||
let source = $state("");
|
||||
let title = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function browse() {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: t("import.filter_av"),
|
||||
extensions: [
|
||||
"mp3",
|
||||
"m4a",
|
||||
"wav",
|
||||
"aac",
|
||||
"ogg",
|
||||
"opus",
|
||||
"flac",
|
||||
"mp4",
|
||||
"mkv",
|
||||
"mov",
|
||||
"webm",
|
||||
"avi",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (typeof path === "string") {
|
||||
source = path;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!source.trim() || busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const id = await api.importMedia(source.trim(), title.trim() || undefined);
|
||||
onImported(id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("import.dialog_label")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="panel">
|
||||
<header>
|
||||
<strong>{t("import.title")}</strong>
|
||||
<button
|
||||
class="close"
|
||||
onclick={onClose}
|
||||
aria-label={t("import.close")}
|
||||
title={t("import.close_title")}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="muted">{t("import.body")}</p>
|
||||
|
||||
<label class="wide">
|
||||
{t("import.file_or_url")}
|
||||
<div class="row">
|
||||
<input
|
||||
class="grow"
|
||||
bind:value={source}
|
||||
placeholder={t("import.source_placeholder")}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button onclick={browse} disabled={busy} title={t("import.choose_file")}>
|
||||
<FileUp size={14} aria-hidden="true" />
|
||||
{t("import.browse")}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={doImport} disabled={!source.trim() || busy}>
|
||||
{busy ? t("import.importing") : t("import.import")}
|
||||
</button>
|
||||
<button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
width: min(520px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
header strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
label em {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.row .grow {
|
||||
flex: 1;
|
||||
}
|
||||
.row button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
white-space: nowrap;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.actions .primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg, #fff);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions .primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.actions .link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,46 @@
|
||||
<script lang="ts">
|
||||
// Live input level meter while recording (T7.3, FR-CAP-5). A simple bar
|
||||
// (rms fill + peak marker) rather than a full scrolling waveform — either
|
||||
// satisfies the requirement, and this is far less UI/state to get right.
|
||||
let { rms, peak }: { rms: number; peak: number } = $props();
|
||||
// Live input level meter while recording (T7.3, FR-CAP-5/7). One bar with the
|
||||
// system/loopback level (green) and — when the mic is enabled — the microphone
|
||||
// level overlaid in the accent colour, so both sides of the call are visible
|
||||
// at a glance. Each stream shows an rms fill + a peak marker.
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
rms,
|
||||
peak,
|
||||
micRms = 0,
|
||||
micPeak = 0,
|
||||
showMic = false,
|
||||
}: {
|
||||
rms: number;
|
||||
peak: number;
|
||||
micRms?: number;
|
||||
micPeak?: number;
|
||||
showMic?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Perceptual loudness isn't linear; sqrt gives a meter that "looks right"
|
||||
// for typical speech levels instead of sitting near-empty most of the time.
|
||||
let rmsPct = $derived(Math.min(1, Math.sqrt(Math.max(0, rms))) * 100);
|
||||
let peakPct = $derived(Math.min(1, Math.sqrt(Math.max(0, peak))) * 100);
|
||||
const pct = (v: number) => Math.min(1, Math.sqrt(Math.max(0, v))) * 100;
|
||||
let rmsPct = $derived(pct(rms));
|
||||
let peakPct = $derived(pct(peak));
|
||||
let micRmsPct = $derived(pct(micRms));
|
||||
let micPeakPct = $derived(pct(micPeak));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="meter"
|
||||
role="meter"
|
||||
aria-label="Input level"
|
||||
aria-valuenow={Math.round(rmsPct)}
|
||||
aria-label={showMic ? t("levelmeter.system_mic") : t("levelmeter.system")}
|
||||
aria-valuenow={Math.round(Math.max(rmsPct, showMic ? micRmsPct : 0))}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div class="fill" style="width: {rmsPct}%"></div>
|
||||
<div class="peak" style="left: {peakPct}%"></div>
|
||||
<div class="fill system" style="width: {rmsPct}%"></div>
|
||||
<div class="peak system" style="left: {peakPct}%"></div>
|
||||
{#if showMic}
|
||||
<div class="fill mic" style="width: {micRmsPct}%"></div>
|
||||
<div class="peak mic" style="left: {micPeakPct}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -34,16 +55,28 @@
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--success);
|
||||
transition: width 60ms linear;
|
||||
/* Overlap is visible because the mic layer is translucent. */
|
||||
opacity: 0.7;
|
||||
}
|
||||
.fill.system {
|
||||
background: var(--success);
|
||||
}
|
||||
.fill.mic {
|
||||
background: var(--accent);
|
||||
}
|
||||
.peak {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--fg);
|
||||
opacity: 0.6;
|
||||
transition: left 60ms linear;
|
||||
}
|
||||
.peak.system {
|
||||
background: var(--fg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.peak.mic {
|
||||
background: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
// Draggable divider between two panes (FR-UX-1). Reports a delta in
|
||||
// pixels via onResize as the pointer moves; the parent owns the actual
|
||||
// size state and clamping. onResizeEnd fires once per drag (and per arrow
|
||||
// keypress) so the parent can persist without writing on every pixel.
|
||||
let {
|
||||
orientation = "vertical",
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
label,
|
||||
}: {
|
||||
orientation?: "vertical" | "horizontal";
|
||||
onResize: (deltaPx: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
label: string;
|
||||
} = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let lastPos = 0;
|
||||
|
||||
function posOf(e: PointerEvent): number {
|
||||
return orientation === "vertical" ? e.clientX : e.clientY;
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
dragging = true;
|
||||
lastPos = posOf(e);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
const pos = posOf(e);
|
||||
onResize(pos - lastPos);
|
||||
lastPos = pos;
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
onResizeEnd?.();
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 40 : 12;
|
||||
const negKey = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
|
||||
const posKey = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
|
||||
if (e.key === negKey) onResize(-step);
|
||||
else if (e.key === posKey) onResize(step);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
onResizeEnd?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- WAI-ARIA "window splitter" pattern: a focusable, keyboard-operable
|
||||
role="separator" is the correct/standard shape for a resize handle —
|
||||
the a11y linter's generic "non-interactive element" rule doesn't know
|
||||
about this pattern specifically. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="splitter {orientation}"
|
||||
class:dragging
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
aria-label={label}
|
||||
tabindex="0"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onkeydown={onKeydown}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.splitter {
|
||||
flex: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.splitter.vertical {
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.splitter.horizontal {
|
||||
height: 5px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
/* Wider invisible hit-area than the visible line — a 5px target is too
|
||||
thin to reliably grab (touch-target-size / no-precision-required). */
|
||||
.splitter::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.splitter.vertical::after {
|
||||
inset: 0 -4px;
|
||||
}
|
||||
.splitter.horizontal::after {
|
||||
inset: -4px 0;
|
||||
}
|
||||
.splitter:hover,
|
||||
.splitter:focus-visible,
|
||||
.splitter.dragging {
|
||||
background: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
// list it's rendered in (not a global delete — the caller decides what
|
||||
// "remove" means).
|
||||
import { X } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
@@ -20,12 +21,17 @@
|
||||
class="label"
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
title={onClick ? `Filter meetings tagged "${tag}"` : undefined}
|
||||
title={onClick ? t("tagchip.filter", { tag }) : undefined}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{#if removable}
|
||||
<button type="button" class="remove" onclick={onRemove} aria-label={`Remove tag ${tag}`}>
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={onRemove}
|
||||
aria-label={t("tagchip.remove", { tag })}
|
||||
>
|
||||
<X size={10} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// plain <select> — three icon buttons instead of a text dropdown, defaults
|
||||
// to "system" so the app follows the OS until the user picks an override.
|
||||
import { Monitor, Sun, Moon } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
value: "system" | "light" | "dark";
|
||||
@@ -10,21 +11,23 @@
|
||||
}
|
||||
let { value, onchange }: Props = $props();
|
||||
|
||||
// labelKey resolves through t() in the template so a language switch relabels
|
||||
// the buttons (a t() call in this module-level const would evaluate once).
|
||||
const options = [
|
||||
{ id: "system", label: "Match system", Icon: Monitor },
|
||||
{ id: "light", label: "Light", Icon: Sun },
|
||||
{ id: "dark", label: "Dark", Icon: Moon },
|
||||
{ id: "system", labelKey: "theme.system", Icon: Monitor },
|
||||
{ id: "light", labelKey: "theme.light", Icon: Sun },
|
||||
{ id: "dark", labelKey: "theme.dark", Icon: Moon },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="toggle" role="radiogroup" aria-label="Theme">
|
||||
<div class="toggle" role="radiogroup" aria-label={t("theme.group")}>
|
||||
{#each options as opt (opt.id)}
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={value === opt.id}
|
||||
class:active={value === opt.id}
|
||||
title={opt.label}
|
||||
aria-label={opt.label}
|
||||
title={t(opt.labelKey)}
|
||||
aria-label={t(opt.labelKey)}
|
||||
onclick={() => onchange(opt.id)}
|
||||
>
|
||||
<opt.Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
{
|
||||
"nav.recording": "Recording",
|
||||
"nav.hardware": "Hardware",
|
||||
"nav.storage": "Storage",
|
||||
"nav.calendar": "Calendar",
|
||||
"nav.sync": "Sync",
|
||||
"nav.ai": "AI",
|
||||
"nav.mcp": "MCP server",
|
||||
"nav.privacy": "Privacy",
|
||||
"nav.about": "About",
|
||||
"nav.language": "Language",
|
||||
|
||||
"settings.language.title": "Language",
|
||||
"settings.language.display": "Display language",
|
||||
"settings.language.display_hint": "The language of the app interface. Adding a language is as simple as dropping in one translation file.",
|
||||
|
||||
"settings.transcription.title": "Transcription Language",
|
||||
"settings.transcription.label": "Language",
|
||||
"settings.transcription.auto": "Auto-detect",
|
||||
"settings.transcription.applies_hint": "Applies to the next recording. The language actually used is shown on each meeting afterward.",
|
||||
"settings.transcription.english_only": "English only",
|
||||
"settings.transcription.model_english_only": "\"{model}\" is English-only.",
|
||||
"settings.transcription.no_model": "No model selected.",
|
||||
"settings.transcription.switch_multilingual": "Switch to a multilingual model above to choose a language.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.active_backend": "Active backend",
|
||||
"settings.hardware.model_meta": "· model {size}",
|
||||
"settings.hardware.preferred_backend": "Preferred backend",
|
||||
"settings.hardware.auto_backend": "Auto (best available)",
|
||||
"settings.hardware.not_available": "not available",
|
||||
"settings.hardware.fallback_hint": "Falls back automatically (NPU → NVIDIA → AMD → Intel → CPU) if the chosen backend fails to load.",
|
||||
"settings.hardware.audio_devices": "Audio Devices",
|
||||
"settings.hardware.recording_device": "Recording device",
|
||||
"settings.hardware.default_system_audio": "Default system audio",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist records whatever this device plays (loopback) — the other side of the call. Pick a specific output if you don't want it following Windows' system default.",
|
||||
"settings.hardware.microphone": "Microphone",
|
||||
"settings.hardware.mic_off": "Off — don't capture my microphone",
|
||||
"settings.hardware.default_mic": "Default microphone",
|
||||
"settings.hardware.mic_hint": "Adds your own voice to the live transcript so both sides of the meeting are captured. Stays on your device — nothing is uploaded. Choose “Off” to transcribe only the system audio above.",
|
||||
"settings.hardware.npu_detected": "NPU accelerator detected",
|
||||
"settings.hardware.ready": "Ready",
|
||||
"settings.hardware.downloading": "Downloading…",
|
||||
"settings.hardware.package_needed": "Package needed",
|
||||
"settings.hardware.npu_hint": "Your NPU needs a one-time acceleration package (Whisper ONNX model{extra}). It downloads in the background; you can also start it now.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO runtime",
|
||||
"settings.hardware.download_npu": "Download NPU package",
|
||||
"settings.hardware.dml_title": "GPU acceleration (DirectML)",
|
||||
"settings.hardware.dml_hint": "Your GPU can be accelerated without Vulkan via DirectML — a one-time package (Whisper ONNX model{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML runtime",
|
||||
"settings.hardware.download_dml": "Download DirectML package",
|
||||
"settings.hardware.detection_unavailable": "Hardware detection unavailable.",
|
||||
"settings.hardware.low_overhead": "Low overhead preset",
|
||||
"settings.hardware.low_overhead_note": "CPU + smallest model, for battery/background use",
|
||||
"settings.hardware.models": "Models",
|
||||
"settings.hardware.downloading_model": "Downloading {name}",
|
||||
"settings.hardware.active_badge": "Active",
|
||||
"settings.hardware.installed": "Installed",
|
||||
"settings.hardware.use": "Use",
|
||||
"settings.hardware.remove": "Remove",
|
||||
"settings.hardware.cant_remove_active": "Can't remove the active model",
|
||||
"settings.hardware.download": "Download",
|
||||
"settings.hardware.diarization": "Speaker diarization",
|
||||
"settings.hardware.diarization_hint": "Install both models to have finished recordings separated by speaker (Speaker 1, Speaker 2, …) instead of one running transcript. Runs fully offline, once per meeting after it stops. Without them, every line is attributed to a single speaker.",
|
||||
|
||||
"settings.storage.title": "Storage",
|
||||
"settings.storage.location_label": "Location",
|
||||
"settings.storage.location_hint": "Changing the storage location isn't supported yet — this is where meetings, models, and the database currently live.",
|
||||
"settings.storage.retention_title": "Retention",
|
||||
"settings.storage.retention_hint": "Automatically delete the oldest meetings past a limit. Checked once at startup; empty means no limit. Never touches a meeting that's currently recording.",
|
||||
"settings.storage.max_age": "Max age (days)",
|
||||
"settings.storage.max_size": "Max size (GB)",
|
||||
"settings.storage.no_limit": "no limit",
|
||||
"settings.storage.export_import_title": "Export & import",
|
||||
"settings.storage.export_hint_1": "Move recordings between computers as portable bundle folders (audio, transcript, notes, summary, and a",
|
||||
"settings.storage.export_hint_2": "manifest). Export to any folder — a synced drive, a USB stick, or a sync target's local mount — then import it on the other machine. Imported meetings get a fresh id, so re-importing never overwrites anything.",
|
||||
"settings.storage.export_dialog_title": "Export all meetings into…",
|
||||
"settings.storage.import_dialog_title": "Import a bundle (or a folder of bundles)…",
|
||||
"settings.storage.exporting": "Exporting…",
|
||||
"settings.storage.export_all": "Export all meetings…",
|
||||
"settings.storage.importing": "Importing…",
|
||||
"settings.storage.import_all": "Import meetings…",
|
||||
"settings.storage.exported_one": "Exported {n} meeting.",
|
||||
"settings.storage.exported_many": "Exported {n} meetings.",
|
||||
"settings.storage.imported_one": "Imported {n} meeting.",
|
||||
"settings.storage.imported_many": "Imported {n} meetings.",
|
||||
|
||||
"settings.calendar.title": "Calendar & Outlook .pst",
|
||||
"settings.calendar.intro_1": "Import events and attendees from a local Outlook",
|
||||
"settings.calendar.intro_2": "backup — read-only, nothing is written back to the file. Nothing leaves this device.",
|
||||
"settings.calendar.pst_file": ".pst file",
|
||||
"settings.calendar.no_file": "No file selected",
|
||||
"settings.calendar.browse": "Browse…",
|
||||
"settings.calendar.password": "Password",
|
||||
"settings.calendar.password_note": "rarely needed",
|
||||
"settings.calendar.optional": "optional",
|
||||
"settings.calendar.import_range": "Import range",
|
||||
"settings.calendar.range_30": "Last 30 days",
|
||||
"settings.calendar.range_90": "Last 90 days",
|
||||
"settings.calendar.range_180": "Last 6 months",
|
||||
"settings.calendar.range_365": "Last 1 year",
|
||||
"settings.calendar.range_all": "All time",
|
||||
"settings.calendar.range_hint": "A long-lived mailbox can hold years of recurring/holiday entries — narrowing the range keeps the imported calendar to what's actually relevant. Applies to both this Import button and automatic re-import on launch.",
|
||||
"settings.calendar.importing": "Importing…",
|
||||
"settings.calendar.import": "Import",
|
||||
"settings.calendar.import_progress": "{processed}/{total} events",
|
||||
"settings.calendar.auto_reimport": "Re-import this file automatically on launch",
|
||||
"settings.calendar.auto_reimport_hint": "Runs once at startup, not on a timer — re-import is safe to repeat (existing events are matched and updated, not duplicated).",
|
||||
"settings.calendar.import_failed": "Import failed: {error} — the file itself is untouched; check the path and try again.",
|
||||
"settings.calendar.cleanup_title": "Clean up",
|
||||
"settings.calendar.cleanup_hint": "Events already attached to a recorded meeting are always kept, no matter which option below is picked.",
|
||||
"settings.calendar.cleanup_30": "Older than 30 days",
|
||||
"settings.calendar.cleanup_90": "Older than 90 days",
|
||||
"settings.calendar.cleanup_180": "Older than 6 months",
|
||||
"settings.calendar.cleanup_365": "Older than 1 year",
|
||||
"settings.calendar.cleanup_all": "Delete all",
|
||||
"settings.calendar.cleaning": "Cleaning up…",
|
||||
"settings.calendar.cleanup_btn": "Clean up calendar",
|
||||
"settings.calendar.cleanup_result": "Deleted {deleted}{extra}.",
|
||||
"settings.calendar.cleanup_kept": ", kept {n} (linked to a meeting)",
|
||||
"settings.calendar.cleanup_failed": "Clean up failed: {error}",
|
||||
"settings.calendar.imported_events": "Imported events",
|
||||
"settings.calendar.no_events": "No events imported yet.",
|
||||
"settings.calendar.search_title": "Search title",
|
||||
"settings.calendar.search_placeholder": "Meeting name…",
|
||||
"settings.calendar.date_label": "Date",
|
||||
"settings.calendar.events_count": "{shown} of {total} events",
|
||||
"settings.calendar.untitled": "(untitled)",
|
||||
|
||||
"settings.dialog_title": "Settings",
|
||||
"settings.close": "Close",
|
||||
"settings.close_title": "Close (Esc)",
|
||||
|
||||
"settings.sync.stub": "Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI only.",
|
||||
"settings.sync.title": "Sync & upload",
|
||||
"settings.sync.enable_label": "Enable uploading meeting artifacts to configured targets",
|
||||
"settings.sync.enable_hint": "Off by default. Nothing is uploaded unless this is on and a target is enabled. Self-hosted targets keep data on your own server; third-party clouds are clearly labeled.",
|
||||
"settings.sync.targets_title": "Targets",
|
||||
"settings.sync.no_targets": "No targets yet. Add one below.",
|
||||
"settings.sync.third_party": "third-party",
|
||||
"settings.sync.your_server": "your server",
|
||||
"settings.sync.edit": "Edit",
|
||||
"settings.sync.remove": "Remove",
|
||||
"settings.sync.edit_target": "Edit target",
|
||||
"settings.sync.add_target": "Add a target",
|
||||
"settings.sync.webdav_hint": "WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable SeafDAV server-side.)",
|
||||
"settings.sync.name": "Name",
|
||||
"settings.sync.name_placeholder": "Home Nextcloud",
|
||||
"settings.sync.provider": "Provider",
|
||||
"settings.sync.server_url": "Server URL",
|
||||
"settings.sync.server_url_note_1": "Just your server URL — WhispAssist adds",
|
||||
"settings.sync.server_url_note_2": "automatically.",
|
||||
"settings.sync.remote_folder": "Remote folder",
|
||||
"settings.sync.username": "Username",
|
||||
"settings.sync.app_password": "App password",
|
||||
"settings.sync.pw_keep": "leave blank to keep current password",
|
||||
"settings.sync.pw_store": "stored in OS credential store",
|
||||
"settings.sync.upload_legend": "Upload",
|
||||
"settings.sync.artifact_transcript": "transcript",
|
||||
"settings.sync.artifact_notes": "notes",
|
||||
"settings.sync.artifact_summary": "summary",
|
||||
"settings.sync.artifact_recording": "recording (.wav, if retained)",
|
||||
"settings.sync.allow_plaintext": "Allow plaintext http for a LAN address (not recommended)",
|
||||
"settings.sync.encrypt_before": "Encrypt before upload (destination stores only ciphertext)",
|
||||
"settings.sync.requires_vault": "— requires an unlocked vault",
|
||||
"settings.sync.test_connection": "Test connection",
|
||||
"settings.sync.save_changes": "Save changes",
|
||||
"settings.sync.add_target_btn": "Add target",
|
||||
"settings.sync.cancel": "Cancel",
|
||||
"settings.sync.third_party_banner": "{kind} is a third-party cloud — uploading sends your data off your device to {kind}.",
|
||||
"settings.sync.link_account": "Link {kind} account…",
|
||||
"settings.sync.oauth_hint": "Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.",
|
||||
"settings.sync.footnote": "Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.",
|
||||
|
||||
"settings.ai.title": "AI summary provider",
|
||||
"settings.ai.intro_1": "Summaries run on a local LLM by default. Point this at Ollama on this PC or another machine on your LAN (e.g.",
|
||||
"settings.ai.intro_2": "— both count as local, so nothing leaves your network. Hosted providers (Anthropic) are optional, off by default, and send the transcript to a third party once you turn one on.",
|
||||
"settings.ai.provider": "Provider",
|
||||
"settings.ai.off": "Off",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Custom (OpenAI-compatible)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — hosted, leaves this device",
|
||||
"settings.ai.endpoint": "Endpoint",
|
||||
"settings.ai.model": "Model",
|
||||
"settings.ai.api_key": "API key",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (already set — leave blank to keep it)",
|
||||
"settings.ai.show_api_key": "Show API key",
|
||||
"settings.ai.hide_api_key": "Hide API key",
|
||||
"settings.ai.anthropic_banner": "Anthropic is a hosted, third-party service — this meeting's transcript leaves your device when you generate a summary.",
|
||||
"settings.ai.endpoint_banner": "This endpoint isn't on your machine or LAN — your transcript would leave your network.",
|
||||
"settings.ai.saving": "Saving…",
|
||||
"settings.ai.save": "Save",
|
||||
"settings.ai.test_connection": "Test connection",
|
||||
"settings.ai.this_hosted_provider": "this hosted provider",
|
||||
"settings.ai.reachable": "Reachable",
|
||||
"settings.ai.unreachable": "Unreachable",
|
||||
"settings.ai.model_count_one": "· {n} model",
|
||||
"settings.ai.model_count_many": "· {n} models",
|
||||
"settings.ai.on_lan": "· on your machine / LAN",
|
||||
"settings.ai.leaves_network": "· leaves your network",
|
||||
"settings.ai.advanced_title": "Advanced Ollama configuration",
|
||||
"settings.ai.changed": "{n} changed",
|
||||
"settings.ai.reset_all": "Reset all to defaults",
|
||||
"settings.ai.system_prompt": "System prompt",
|
||||
"settings.ai.system_prompt_placeholder": "e.g. You are a concise meeting summarizer.",
|
||||
"settings.ai.system_prompt_hint": "Added before WhispAssist's required output format, so your instructions can't break summary/action-item parsing.",
|
||||
"settings.ai.think": "Think",
|
||||
"settings.ai.think_help": "Reasoning effort (reasoning models only).",
|
||||
"settings.ai.keep_alive": "Keep alive",
|
||||
"settings.ai.keep_alive_help": "How long the model stays in RAM. e.g. 5m, 1h, 0 (unload), -1 (forever).",
|
||||
"settings.ai.runtime_hardware": "Runtime & hardware",
|
||||
"settings.ai.rarely_needed": "— rarely needed",
|
||||
"settings.ai.save_advanced": "Save advanced",
|
||||
"settings.ai.saved": "Saved",
|
||||
"settings.ai.turn_off": "Turn off",
|
||||
"settings.ai.default_value": "Default {value}.",
|
||||
"settings.ai.reset_default": "Reset to default",
|
||||
"settings.ai.reset_field": "Reset {name}",
|
||||
|
||||
"settings.mcp.title": "MCP server",
|
||||
"settings.mcp.banner_1": "This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting context on your local machine. Once connected,",
|
||||
"settings.mcp.banner_agent": "that agent",
|
||||
"settings.mcp.banner_2": "may forward what it reads to its own model provider's cloud — outside WhispAssist's control. WhispAssist itself never sends this data anywhere; the server only listens on this device (",
|
||||
"settings.mcp.banner_3": ") and every read is logged below.",
|
||||
"settings.mcp.enable": "Enable the MCP server",
|
||||
"settings.mcp.enable_hint": "Off by default. Loopback-only, token-gated — nothing is reachable from the network.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "Streamable HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (agent spawns a process)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "To change transport/port, turn the server off first, then back on.",
|
||||
"settings.mcp.scope_title": "Scope",
|
||||
"settings.mcp.expose": "Expose",
|
||||
"settings.mcp.scope_none": "None — nothing is shared",
|
||||
"settings.mcp.scope_selected": "Selected — only feature briefs you've marked shared",
|
||||
"settings.mcp.scope_all": "All — meetings, transcripts, action items, and shared briefs",
|
||||
"settings.mcp.expose_recordings": "Also allow meetings with a saved recording (off by default — a recorded meeting's transcript is withheld even in \"All\" scope until this is on)",
|
||||
"settings.mcp.token_title": "New auth token — shown once, copy it now",
|
||||
"settings.mcp.token_hint": "This won't be shown again. It's stored in your OS credential store; if you lose it, turn the server off and back on to mint a new one.",
|
||||
"settings.mcp.copied": "Copied",
|
||||
"settings.mcp.copy": "Copy",
|
||||
"settings.mcp.endpoint": "Endpoint",
|
||||
"settings.mcp.endpoint_hint": "Point your agent's MCP client config at this {kind}, with the token above as a bearer credential.",
|
||||
"settings.mcp.kind_command": "command",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Access log",
|
||||
"settings.mcp.access_log_hint": "Every tool read an agent makes, allowed or denied (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "No agent has read anything yet.",
|
||||
"settings.mcp.log_meeting": "meeting {id}",
|
||||
"settings.mcp.refresh": "Refresh",
|
||||
|
||||
"settings.privacy.title": "Privacy",
|
||||
"settings.privacy.intro": "What WhispAssist is actually allowed to send off this device right now. With everything off (the default), nothing leaves the device at all.",
|
||||
"settings.privacy.llm_endpoint": "LLM endpoint",
|
||||
"settings.privacy.off": "off",
|
||||
"settings.privacy.local_only": "local-only",
|
||||
"settings.privacy.leaves_device": "leaves this device",
|
||||
"settings.privacy.sync_label": "Sync",
|
||||
"settings.privacy.enabled": "enabled",
|
||||
"settings.privacy.mcp_label": "MCP server",
|
||||
"settings.privacy.mcp_on": "on · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Inbound on loopback only — it adds nothing to the egress list above. A connected agent may still forward what it reads to its own model provider; see the MCP server tab.",
|
||||
"settings.privacy.egress_title": "Egress allowlist",
|
||||
"settings.privacy.no_egress": "No hosts are allowlisted — WA makes no content egress.",
|
||||
"settings.privacy.sync_targets_title": "Sync targets",
|
||||
"settings.privacy.third_party": "third-party",
|
||||
"settings.privacy.your_server": "your server",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "no TLS",
|
||||
"settings.privacy.refresh": "Refresh",
|
||||
"settings.privacy.unavailable": "Privacy self-check unavailable.",
|
||||
"settings.privacy.vault_title": "Encryption vault",
|
||||
"settings.privacy.vault_intro": "Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are not encrypted yet.)",
|
||||
"settings.privacy.vault_password": "Vault password",
|
||||
"settings.privacy.enable_vault": "Enable vault",
|
||||
"settings.privacy.vault_pw_hint": "Use at least 8 characters. If you forget it, encrypted content can't be recovered.",
|
||||
"settings.privacy.vault_locked_1": "Vault is ",
|
||||
"settings.privacy.locked_word": "locked",
|
||||
"settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.",
|
||||
"settings.privacy.password": "Password",
|
||||
"settings.privacy.unlock": "Unlock",
|
||||
"settings.privacy.vault_unlocked_1": "Vault is ",
|
||||
"settings.privacy.unlocked_word": "unlocked",
|
||||
"settings.privacy.vault_unlocked_2": ". New notes, transcripts, and summaries are encrypted at rest.",
|
||||
"settings.privacy.lock_now": "Lock now",
|
||||
"settings.privacy.change_password": "Change password",
|
||||
"settings.privacy.current_password": "Current password",
|
||||
"settings.privacy.new_password": "New password",
|
||||
|
||||
"settings.about.title": "About",
|
||||
"settings.about.tagline": "A fully local, open-source, Windows-native meeting assistant.",
|
||||
"settings.about.build_commit": "Build commit",
|
||||
|
||||
"consent.heading": "Before you record",
|
||||
"consent.body": "Recording conversations without the consent of participants may be illegal in your region. Check your local recording laws. This is a caution, not legal advice.",
|
||||
"consent.accept": "I understand — enable recording",
|
||||
"consent.cancel": "Cancel",
|
||||
|
||||
"hosted.heading": "Before using {provider}",
|
||||
"hosted.body_1": "Generating with",
|
||||
"hosted.body_2": "sends this meeting's transcript to their servers — it leaves this device and is subject to their privacy policy. WhispAssist has no control over data handling once it leaves your device. This is off by default; you're choosing it now.",
|
||||
"hosted.accept": "I understand — continue",
|
||||
"hosted.cancel": "Cancel",
|
||||
|
||||
"theme.group": "Theme",
|
||||
"theme.system": "Match system",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
"import.dialog_label": "Add a meeting from a file or URL",
|
||||
"import.title": "Add a meeting",
|
||||
"import.close": "Close",
|
||||
"import.close_title": "Close (Esc)",
|
||||
"import.body": "Import an existing recording — a local audio/video file, or a link (YouTube, a streaming page, or a direct media URL). It's transcribed and diarized just like a live recording.",
|
||||
"import.file_or_url": "File or URL",
|
||||
"import.source_placeholder": "Paste a URL, or browse for a file…",
|
||||
"import.choose_file": "Choose a local file",
|
||||
"import.browse": "Browse…",
|
||||
"import.title_label": "Title",
|
||||
"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.import": "Import",
|
||||
"import.cancel": "Cancel",
|
||||
|
||||
"tagchip.filter": "Filter meetings tagged \"{tag}\"",
|
||||
"tagchip.remove": "Remove tag {tag}",
|
||||
|
||||
"levelmeter.system_mic": "System and microphone input level",
|
||||
"levelmeter.system": "System input level",
|
||||
|
||||
"app.tagline": "local · private",
|
||||
"app.note_template": "Note template",
|
||||
"app.no_template": "No template",
|
||||
"app.add_meeting": "Add meeting",
|
||||
"app.add_meeting_title": "Add a meeting from a file or URL",
|
||||
"app.record": "Record",
|
||||
"app.record_title": "Start recording (Ctrl+Shift+R)",
|
||||
"app.stop": "Stop",
|
||||
"app.stop_title": "Stop recording (Ctrl+Shift+R)",
|
||||
"app.cancel": "Cancel",
|
||||
"app.cancel_title": "Discard this recording and delete it",
|
||||
"app.recording": "Recording…",
|
||||
"app.backend_title": "Active transcription backend",
|
||||
"app.retention_title": "Save audio as .wav for this meeting",
|
||||
"app.saving": "saving",
|
||||
"app.not_saved": "not saved",
|
||||
"app.reconnecting": "reconnecting audio device…",
|
||||
"app.settings": "Settings",
|
||||
"app.settings_title": "Settings (Ctrl+,)",
|
||||
"app.discard_confirm": "Discard this recording? Its audio and transcript will be deleted.",
|
||||
"app.announce_started": "Recording started",
|
||||
"app.announce_paused": "Recording paused",
|
||||
"app.announce_stopped": "Recording stopped",
|
||||
"app.consent_dialog": "Recording consent",
|
||||
"app.vault_dialog": "Unlock encryption vault",
|
||||
"app.vault_title": "Unlock encryption vault",
|
||||
"app.vault_desc": "Your meetings are encrypted at rest. Enter your password to read them.",
|
||||
"app.vault_password": "Vault password",
|
||||
"app.vault_incorrect": "Incorrect password",
|
||||
"app.unlock": "Unlock",
|
||||
"app.continue_locked": "Continue locked",
|
||||
"app.show_meetings": "Show meetings list",
|
||||
"app.hide_meetings": "Hide meetings list",
|
||||
"app.show_summary": "Show summary panel",
|
||||
"app.hide_summary": "Hide summary panel",
|
||||
"app.resize_meetings": "Resize meetings list",
|
||||
"app.resize_summary": "Resize summary panel",
|
||||
|
||||
"meetings.search_placeholder": "Search meetings…",
|
||||
"meetings.search_aria": "Search meetings",
|
||||
"meetings.filter_tag_aria": "Filter by tag",
|
||||
"meetings.all_tags": "All tags",
|
||||
"meetings.from_date_aria": "From date",
|
||||
"meetings.to_date_aria": "To date",
|
||||
"meetings.bulk_format_aria": "Bulk export format",
|
||||
"meetings.bulk_export": "Bulk export",
|
||||
"meetings.bulk_export_title": "Export every meeting matching the tag/date filters above",
|
||||
"meetings.exporting": "Exporting…",
|
||||
"meetings.exported_one": "Exported {n} meeting.",
|
||||
"meetings.exported_many": "Exported {n} meetings.",
|
||||
"meetings.searching": "Searching…",
|
||||
"meetings.loading": "Loading meetings…",
|
||||
"meetings.no_matches": "No matches.",
|
||||
"meetings.empty": "No meetings yet — click Record above to start.",
|
||||
"meetings.status.recording": "recording",
|
||||
"meetings.status.transcribing": "transcribing",
|
||||
"meetings.status.recovering": "recovering",
|
||||
"meetings.status.error": "error",
|
||||
"meetings.resume": "Resume transcription",
|
||||
"meetings.delete_aria": "Delete meeting",
|
||||
"meetings.delete_confirm": "Delete this meeting? This removes its recording, transcript, and notes.",
|
||||
|
||||
"transcript.heading": "Transcript",
|
||||
"transcript.title_aria": "Meeting title",
|
||||
"transcript.lang_title": "Transcription language",
|
||||
"transcript.lang_auto": "auto-detecting…",
|
||||
"transcript.show": "Show transcript",
|
||||
"transcript.hide": "Hide transcript",
|
||||
"transcript.empty": "No transcript for this meeting.",
|
||||
"transcript.will_appear": "Transcript will appear here as you record.",
|
||||
"transcript.play_from_here": "Play from here",
|
||||
"transcript.resize": "Resize transcript and notes",
|
||||
"transcript.reprocess_placeholder": "Re-transcribe with…",
|
||||
"transcript.reprocess_lang_aria": "Reprocess language",
|
||||
"transcript.reprocess_keep_lang": "Keep current language",
|
||||
"transcript.reprocess_go": "Go",
|
||||
"transcript.retranscribing": "Re-transcribing…",
|
||||
"transcript.has_note": "Has a note",
|
||||
"transcript.note_placeholder": "Add a note for this moment…",
|
||||
"transcript.note_aria": "Note for this transcript line",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word document",
|
||||
"transcript.filter_obsidian": "Obsidian note",
|
||||
|
||||
"notes.heading": "Notes",
|
||||
"notes.show": "Show notes",
|
||||
"notes.hide": "Hide notes",
|
||||
"notes.placeholder": "Notes…",
|
||||
"notes.live_placeholder": "Type notes while you talk…",
|
||||
"notes.toolbar_aria": "Notes formatting",
|
||||
"notes.bold": "Bold",
|
||||
"notes.italic": "Italic",
|
||||
"notes.h1": "Heading 1",
|
||||
"notes.h2": "Heading 2",
|
||||
"notes.bullet": "Bullet list",
|
||||
"notes.checkbox_title": "Checkbox",
|
||||
"notes.checkbox_aria": "Checkbox list item",
|
||||
"notes.edit_raw": "Edit the raw markdown",
|
||||
"notes.render": "Render the markdown",
|
||||
"notes.editor": "Editor",
|
||||
"notes.preview": "Preview",
|
||||
"notes.export_md_title": "Export notes as .md",
|
||||
"notes.export_pdf_title": "Export notes as .pdf",
|
||||
"notes.export_docx_title": "Export notes as .docx",
|
||||
"notes.export_bundle_title": "Export audio + transcript + notes to a folder",
|
||||
"notes.export_obsidian_title": "Export one Obsidian note (notes + summary + transcript, no audio)",
|
||||
|
||||
"summary.recording_heading": "Recording",
|
||||
"summary.loading_recording": "Loading recording…",
|
||||
"summary.sync_heading": "Sync",
|
||||
"summary.uploading": "Uploading…",
|
||||
"summary.upload_now": "Upload now",
|
||||
"summary.no_uploads": "No uploads yet for this meeting.",
|
||||
"summary.retry": "retry",
|
||||
"summary.tags_heading": "Tags",
|
||||
"summary.tags_select": "Select a meeting to tag it.",
|
||||
"summary.tag_add_placeholder": "Add tag…",
|
||||
"summary.tag_first_placeholder": "project, client, topic…",
|
||||
"summary.generating": "Generating…",
|
||||
"summary.generate_tags": "Generate tags",
|
||||
"summary.saving": "Saving…",
|
||||
"summary.save_tags": "Save tags",
|
||||
"summary.summary_heading": "Summary",
|
||||
"summary.ai_provider": "AI provider",
|
||||
"summary.provider_off": "Off",
|
||||
"summary.provider_custom": "Custom",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "leaves this device",
|
||||
"summary.this_hosted_provider": "this hosted provider",
|
||||
"summary.select_generate": "Select a meeting to generate a summary.",
|
||||
"summary.decisions_heading": "Decisions",
|
||||
"summary.regenerate": "Regenerate",
|
||||
"summary.generated_hint": "Generated locally after the meeting (requires a local LLM provider).",
|
||||
"summary.no_provider": "No AI provider configured — enable one in Settings.",
|
||||
"summary.generate_summary": "Generate summary",
|
||||
"summary.briefs_heading": "Feature briefs",
|
||||
"summary.briefs_select": "Select a meeting to create or view feature briefs.",
|
||||
"summary.briefs_desc": "Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a coding agent, or serve it over the MCP server once that's on.",
|
||||
"summary.brief_repo_placeholder": "Target repo (optional), e.g. acme/reporting-web",
|
||||
"summary.distilling": "Distilling…",
|
||||
"summary.create_brief": "Create feature brief",
|
||||
"summary.brief_mcp_title": "Available when the MCP server is on",
|
||||
"summary.copied": "Copied",
|
||||
"summary.copy_md": "Copy as Markdown",
|
||||
"summary.copy_json": "Copy as JSON",
|
||||
"summary.brief_problem": "Problem",
|
||||
"summary.brief_outcome": "Desired outcome",
|
||||
"summary.brief_criteria": "Acceptance criteria",
|
||||
"summary.brief_none": "None captured.",
|
||||
"summary.brief_context": "Context",
|
||||
"summary.action_items_heading": "Action items",
|
||||
"summary.ai_select": "Select a meeting to see its action items.",
|
||||
"summary.ai_empty": "None yet — add one below, or generate a summary to extract them automatically.",
|
||||
"summary.confirmed": "Confirmed",
|
||||
"summary.ai_text_placeholder": "Action item…",
|
||||
"summary.ai_text_aria": "Action item text",
|
||||
"summary.owner": "Owner",
|
||||
"summary.due_date": "Due date",
|
||||
"summary.reminder_title": "Schedule a local reminder for this due date",
|
||||
"summary.ai_delete_title": "Delete this action item",
|
||||
"summary.ai_delete_aria": "Delete action item",
|
||||
"summary.add_action_item": "Add action item",
|
||||
"summary.save_action_items": "Save action items",
|
||||
"summary.calendar_heading": "Calendar event",
|
||||
"summary.cal_select": "Select a meeting to link it to a calendar event.",
|
||||
"summary.event_search_placeholder": "Search event title…",
|
||||
"summary.linked_event": "Linked event",
|
||||
"summary.change_event": "Change event…",
|
||||
"summary.link_event": "Link an event…",
|
||||
"summary.untitled_event": "(untitled)",
|
||||
"summary.no_events_1": "No events imported yet — import a",
|
||||
"summary.no_events_2": "from Settings → Calendar.",
|
||||
"summary.no_events_match": "No events match this search/date — try clearing one.",
|
||||
"summary.organizer_label": "Organizer: {name}",
|
||||
"summary.participants_heading": "Participants",
|
||||
"summary.participants_hint": "Populated from the linked calendar event.",
|
||||
"summary.speakers_heading": "Speakers",
|
||||
"summary.speakers_select": "Select a meeting to name its speakers.",
|
||||
"summary.no_speakers": "No speakers detected yet.",
|
||||
"summary.speaker_name_placeholder": "Speaker name",
|
||||
"summary.save": "Save",
|
||||
"summary.cancel": "Cancel",
|
||||
"summary.name_speaker": "Name this speaker…",
|
||||
"summary.add_new_name": "+ Add new name…",
|
||||
|
||||
"settings.recording.title": "Recording",
|
||||
"settings.recording.record_default": "Record meetings by default",
|
||||
"settings.recording.save_audio_as": "save audio as",
|
||||
"settings.recording.default_hint": "Off by default. When off, audio is used only to produce the transcript and is deleted when the meeting is finalized. You can also toggle recording per meeting.",
|
||||
"settings.recording.consent_label": "Consent: {status}.",
|
||||
"settings.recording.consent_ack": "acknowledged",
|
||||
"settings.recording.consent_not": "not yet acknowledged",
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Minimal hand-rolled i18n — no dependency. A reactive `locale` (persisted to
|
||||
// localStorage, UI ephemera like the layout store) plus a `t(key)` lookup over
|
||||
// per-language JSON dictionaries. English is the fallback for any missing key,
|
||||
// so a partial translation degrades to English rather than showing raw keys.
|
||||
//
|
||||
// Adding a language: create `<code>.json` next to en.json, import it, and add
|
||||
// it to DICTS + LOCALES below. Translating that one file is the whole job.
|
||||
|
||||
import en from "./en.json";
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
// Register languages here. en is the source-of-truth key set + fallback.
|
||||
const DICTS: Record<string, Dict> = { en };
|
||||
export const LOCALES: { code: string; label: string }[] = [{ code: "en", label: "English" }];
|
||||
|
||||
const KEY = "wa-locale-v1";
|
||||
|
||||
function load(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved && saved in DICTS) return saved;
|
||||
} catch {
|
||||
/* localStorage unavailable — fall through to default */
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
class I18n {
|
||||
locale = $state(load());
|
||||
|
||||
setLocale(code: string) {
|
||||
if (!(code in DICTS)) return;
|
||||
this.locale = code;
|
||||
try {
|
||||
localStorage.setItem(KEY, code);
|
||||
} catch {
|
||||
/* non-fatal: preference just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up `key` in the active locale, falling back to English then the key
|
||||
* itself. `vars` fills `{name}` placeholders. Reads `locale` so components
|
||||
* that call `t()` in markup re-render when the language changes. */
|
||||
t = (key: string, vars?: Record<string, string | number>): string => {
|
||||
const dict = DICTS[this.locale] ?? en;
|
||||
let s = dict[key] ?? (en as Dict)[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
export const t = i18n.t;
|
||||
@@ -22,12 +22,12 @@ class CalendarStore {
|
||||
}
|
||||
}
|
||||
|
||||
async importPst(path: string, password?: string) {
|
||||
async importPst(path: string, password?: string, rangeDays?: number) {
|
||||
this.importing = true;
|
||||
this.importError = null;
|
||||
this.importProgress = null;
|
||||
try {
|
||||
await api.importPst(path, password);
|
||||
await api.importPst(path, password, rangeDays);
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this.importError = errorMessage(e);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Resizable/hideable pane preferences (FR-UX-1) — pure client-side UI
|
||||
// ephemera (not app data), so localStorage is the right place for it
|
||||
// rather than a round-trip through Tauri settings.
|
||||
|
||||
const KEY = "wa-layout-v1";
|
||||
|
||||
interface LayoutPrefs {
|
||||
leftWidth: number;
|
||||
rightWidth: number;
|
||||
leftCollapsed: boolean;
|
||||
rightCollapsed: boolean;
|
||||
/** Width of the transcript sub-pane vs notes, 0..1. */
|
||||
transcriptFraction: number;
|
||||
transcriptCollapsed: boolean;
|
||||
notesCollapsed: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: LayoutPrefs = {
|
||||
leftWidth: 260,
|
||||
rightWidth: 320,
|
||||
leftCollapsed: false,
|
||||
rightCollapsed: false,
|
||||
transcriptFraction: 0.5,
|
||||
transcriptCollapsed: false,
|
||||
notesCollapsed: false,
|
||||
};
|
||||
|
||||
function load(): LayoutPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return { ...DEFAULTS };
|
||||
return { ...DEFAULTS, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
class LayoutStore {
|
||||
leftWidth = $state(DEFAULTS.leftWidth);
|
||||
rightWidth = $state(DEFAULTS.rightWidth);
|
||||
leftCollapsed = $state(DEFAULTS.leftCollapsed);
|
||||
rightCollapsed = $state(DEFAULTS.rightCollapsed);
|
||||
transcriptFraction = $state(DEFAULTS.transcriptFraction);
|
||||
transcriptCollapsed = $state(DEFAULTS.transcriptCollapsed);
|
||||
notesCollapsed = $state(DEFAULTS.notesCollapsed);
|
||||
|
||||
constructor() {
|
||||
Object.assign(this, load());
|
||||
}
|
||||
|
||||
/** Call after any mutation — explicit rather than an $effect so a batch
|
||||
* of drag-resize updates doesn't schedule a write per pixel. */
|
||||
persist() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({
|
||||
leftWidth: this.leftWidth,
|
||||
rightWidth: this.rightWidth,
|
||||
leftCollapsed: this.leftCollapsed,
|
||||
rightCollapsed: this.rightCollapsed,
|
||||
transcriptFraction: this.transcriptFraction,
|
||||
transcriptCollapsed: this.transcriptCollapsed,
|
||||
notesCollapsed: this.notesCollapsed,
|
||||
} satisfies LayoutPrefs),
|
||||
);
|
||||
} catch {
|
||||
// localStorage unavailable (private mode etc.) — layout just won't persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const layout = new LayoutStore();
|
||||
@@ -0,0 +1,29 @@
|
||||
// Shared recording-playback state so the transcript (center pane,
|
||||
// TranscriptNotes) and the <audio> element (side pane, SummaryPanel) can talk
|
||||
// to each other across the App layout: click a transcript segment to seek the
|
||||
// player, and highlight/auto-scroll the segment that's currently playing.
|
||||
// Pure UI ephemera — no persistence, reset per meeting.
|
||||
|
||||
class PlayerStore {
|
||||
/** Playback position in ms, pushed from the <audio> element's timeupdate. */
|
||||
currentMs = $state(0);
|
||||
/** Target of the latest seek request, in ms. */
|
||||
seekMs = $state<number | null>(null);
|
||||
/** Bumped on every seek() so clicking the *same* segment twice re-seeks
|
||||
* (a plain seekMs assignment wouldn't fire when the value is unchanged). */
|
||||
seekNonce = $state(0);
|
||||
|
||||
/** Ask the player to jump to `ms` and play (SummaryPanel watches seekNonce). */
|
||||
seek(ms: number) {
|
||||
this.seekMs = ms;
|
||||
this.seekNonce++;
|
||||
}
|
||||
|
||||
/** Clear when the selected meeting changes so a new recording starts at 0. */
|
||||
reset() {
|
||||
this.currentMs = 0;
|
||||
this.seekMs = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const player = new PlayerStore();
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { api, events, type TranscriptSegment, type MeetingId } from "../api";
|
||||
import { settings } from "./settings.svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
class RecordingStore {
|
||||
meetingId = $state<MeetingId | null>(null);
|
||||
@@ -11,11 +12,20 @@ class RecordingStore {
|
||||
segments = $state<TranscriptSegment[]>([]);
|
||||
/** Whether audio is being retained as .wav for the in-flight meeting (ADR-0009). */
|
||||
retention = $state(false);
|
||||
/** Live input level for the waveform/meter (FR-CAP-5); 0 when not recording. */
|
||||
/** Live system/loopback level for the waveform/meter (FR-CAP-5); 0 when not recording. */
|
||||
levelRms = $state(0);
|
||||
levelPeak = $state(0);
|
||||
/** Live microphone level, overlaid on the meter in a different colour
|
||||
* (FR-CAP-7); stays 0 when the mic is disabled or not recording. */
|
||||
levelRmsMic = $state(0);
|
||||
levelPeakMic = $state(0);
|
||||
/** 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. */
|
||||
notesText = $state("");
|
||||
/** anchor_ms (a segment's start_ms) -> note text, for moments annotated this recording. */
|
||||
segmentNotes = new SvelteMap<number, string>();
|
||||
private notesSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async init() {
|
||||
await events.onRecordingState((p) => {
|
||||
@@ -29,6 +39,8 @@ class RecordingStore {
|
||||
if (ended) {
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
}
|
||||
});
|
||||
await events.onRetention((p) => {
|
||||
@@ -40,43 +52,63 @@ class RecordingStore {
|
||||
if (i >= 0) this.segments[i] = segment;
|
||||
else this.segments.push(segment);
|
||||
});
|
||||
await events.onLevel(({ rms, peak }) => {
|
||||
this.levelRms = rms;
|
||||
this.levelPeak = peak;
|
||||
await events.onLevel(({ rms, peak, mic }) => {
|
||||
if (mic) {
|
||||
this.levelRmsMic = rms;
|
||||
this.levelPeakMic = peak;
|
||||
} else {
|
||||
this.levelRms = rms;
|
||||
this.levelPeak = peak;
|
||||
}
|
||||
});
|
||||
await events.onDeviceChanged(({ recovered, message }) => {
|
||||
this.deviceNotice = recovered ? null : message;
|
||||
});
|
||||
}
|
||||
|
||||
async start(title?: string, record = false, templateId?: string) {
|
||||
async start(title?: string, record = false, templateId?: string, calendarEventId?: string) {
|
||||
this.segments = [];
|
||||
this.retention = record;
|
||||
this.deviceNotice = null;
|
||||
this.notesText = "";
|
||||
this.segmentNotes.clear();
|
||||
// T8.7/FR-TRX-4: whatever language is currently configured in Settings
|
||||
// becomes this meeting's requested language, persisted on its record.
|
||||
const language = settings.settings.whisper_language ?? undefined;
|
||||
this.meetingId = await api.startRecording(title, undefined, record, templateId, language);
|
||||
this.meetingId = await api.startRecording(title, calendarEventId, record, templateId, language);
|
||||
this.state = "recording";
|
||||
}
|
||||
|
||||
async stop() {
|
||||
// The debounced save below can lag up to 500ms behind typing — flush
|
||||
// whatever's pending first so stop_recording's merge sees the latest text.
|
||||
await this.flushNotes();
|
||||
if (this.meetingId) await api.stopRecording(this.meetingId);
|
||||
this.state = "idle";
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
this.deviceNotice = null;
|
||||
}
|
||||
|
||||
/** Abandon an accidental recording: stop capture and delete it entirely. */
|
||||
async cancel() {
|
||||
if (this.notesSaveTimer) {
|
||||
clearTimeout(this.notesSaveTimer);
|
||||
this.notesSaveTimer = null;
|
||||
}
|
||||
if (this.meetingId) await api.cancelRecording(this.meetingId);
|
||||
this.meetingId = null;
|
||||
this.segments = [];
|
||||
this.state = "idle";
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
this.deviceNotice = null;
|
||||
this.notesText = "";
|
||||
this.segmentNotes.clear();
|
||||
}
|
||||
|
||||
/** Toggle audio retention mid-meeting (FR-REC-1); caller must have gated consent already. */
|
||||
@@ -85,6 +117,41 @@ class RecordingStore {
|
||||
await api.setRecordingRetention(this.meetingId, record);
|
||||
this.retention = record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live notes redesign: freeform text typed in the Notes pane while
|
||||
* recording. Debounced 500ms (same cadence as the post-finalize editor's
|
||||
* `scheduleSave`) so every keystroke doesn't round-trip to the backend.
|
||||
*/
|
||||
setNotesText(text: string) {
|
||||
this.notesText = text;
|
||||
if (!this.meetingId) return;
|
||||
const meetingId = this.meetingId;
|
||||
if (this.notesSaveTimer) clearTimeout(this.notesSaveTimer);
|
||||
this.notesSaveTimer = setTimeout(() => {
|
||||
this.notesSaveTimer = null;
|
||||
api.updateLiveNotes(meetingId, text).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private async flushNotes() {
|
||||
if (this.notesSaveTimer) {
|
||||
clearTimeout(this.notesSaveTimer);
|
||||
this.notesSaveTimer = null;
|
||||
}
|
||||
if (this.meetingId) {
|
||||
await api.updateLiveNotes(this.meetingId, this.notesText).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach (or clear, with `text: ""`) a note to a clicked transcript
|
||||
* segment's moment (the "click a transcript line, add a note" feature). */
|
||||
async setSegmentNote(anchorMs: number, text: string) {
|
||||
if (!this.meetingId) return;
|
||||
if (text.trim() === "") this.segmentNotes.delete(anchorMs);
|
||||
else this.segmentNotes.set(anchorMs, text);
|
||||
await api.setSegmentNote(this.meetingId, anchorMs, text);
|
||||
}
|
||||
}
|
||||
|
||||
export const recording = new RecordingStore();
|
||||
|
||||
@@ -43,6 +43,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
retention_max_size_gb: null,
|
||||
pst_last_path: null,
|
||||
pst_auto_sync: false,
|
||||
pst_import_range_days: null, // full mailbox history by default
|
||||
auto_record_calendar: false, // don't auto-start on calendar events by default
|
||||
audio_output_device: null, // system default render device (FR-CAP-1)
|
||||
microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7)
|
||||
audio_input_device: null, // system default capture device
|
||||
@@ -60,6 +62,10 @@ class SettingsStore {
|
||||
// Hardware + model management (Phase 3, T3.6/T3.7).
|
||||
hardware = $state<HardwareStatus | null>(null);
|
||||
models = $state<ModelInfo[]>([]);
|
||||
// Speaker-diarization models (segmentation + embedding). Both must be
|
||||
// installed before recordings separate speakers instead of labelling
|
||||
// everything "S1" (T4.7, FR-MODEL-1).
|
||||
diarizationModels = $state<ModelInfo[]>([]);
|
||||
// Transcription language catalog for the Settings dropdown (T8.7, FR-TRX-4).
|
||||
languages = $state<LanguageOption[]>([]);
|
||||
audioDevices = $state<AudioDeviceInfo[]>([]);
|
||||
@@ -100,6 +106,7 @@ class SettingsStore {
|
||||
await this.loadAudioDevices();
|
||||
await this.loadInputDevices();
|
||||
await this.loadModels();
|
||||
await this.loadDiarizationModels();
|
||||
await this.loadLanguages();
|
||||
await this.loadPrivacy();
|
||||
await this.loadLlmStatus();
|
||||
@@ -198,6 +205,29 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
async loadDiarizationModels() {
|
||||
try {
|
||||
this.diarizationModels = await api.listDiarizationModels();
|
||||
} catch {
|
||||
this.diarizationModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Download one diarization model. The two known ids map to the backend's
|
||||
* `diar-seg`/`diar-emb` kinds; `removeModel` needs no kind (it disambiguates
|
||||
* by catalog membership). */
|
||||
async downloadDiarizationModel(id: string) {
|
||||
this.clearProgress(id);
|
||||
await api.downloadModel(id, id.startsWith("seg") ? "diar-seg" : "diar-emb");
|
||||
this.clearProgress(id);
|
||||
await this.loadDiarizationModels();
|
||||
}
|
||||
|
||||
async removeDiarizationModel(id: string) {
|
||||
await api.removeModel(id);
|
||||
await this.loadDiarizationModels();
|
||||
}
|
||||
|
||||
async loadLanguages() {
|
||||
try {
|
||||
this.languages = await api.listWhisperLanguages();
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
import { api, type MeetingListItem, type SearchHit } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Search, Trash2, Download, RotateCcw } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
// Meeting status ("recording"/"transcribing"/"recovering"/"error") → its
|
||||
// localized badge label. The status value itself is app vocabulary, not user
|
||||
// content, so it gets keyed.
|
||||
const statusLabel = (s: string) => t(`meetings.status.${s}`);
|
||||
|
||||
let query = $state("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -55,7 +61,10 @@
|
||||
from: toUnix(fromFilter),
|
||||
to: toUnix(toFilter),
|
||||
});
|
||||
bulkResult = `Exported ${count} meeting${count === 1 ? "" : "s"}.`;
|
||||
bulkResult =
|
||||
count === 1
|
||||
? t("meetings.exported_one", { n: count })
|
||||
: t("meetings.exported_many", { n: count });
|
||||
} finally {
|
||||
bulkExporting = false;
|
||||
}
|
||||
@@ -93,7 +102,7 @@
|
||||
|
||||
async function removeMeeting(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Delete this meeting? This removes its recording, transcript, and notes.")) return;
|
||||
if (!confirm(t("meetings.delete_confirm"))) return;
|
||||
await meetings.remove(id);
|
||||
}
|
||||
|
||||
@@ -107,24 +116,38 @@
|
||||
<div class="search-box">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
placeholder="Search meetings…"
|
||||
aria-label="Search meetings"
|
||||
placeholder={t("meetings.search_placeholder")}
|
||||
aria-label={t("meetings.search_aria")}
|
||||
bind:value={query}
|
||||
oninput={onSearchInput}
|
||||
/>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select aria-label="Filter by tag" bind:value={tagFilter} onchange={onFilterChange}>
|
||||
<option value="">All tags</option>
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}>{t}</option>
|
||||
<select
|
||||
aria-label={t("meetings.filter_tag_aria")}
|
||||
bind:value={tagFilter}
|
||||
onchange={onFilterChange}
|
||||
>
|
||||
<option value="">{t("meetings.all_tags")}</option>
|
||||
{#each meetings.allTags as tag (tag)}
|
||||
<option value={tag}>{tag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input type="date" aria-label="From date" bind:value={fromFilter} onchange={onFilterChange} />
|
||||
<input type="date" aria-label="To date" bind:value={toFilter} onchange={onFilterChange} />
|
||||
<input
|
||||
type="date"
|
||||
aria-label={t("meetings.from_date_aria")}
|
||||
bind:value={fromFilter}
|
||||
onchange={onFilterChange}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
aria-label={t("meetings.to_date_aria")}
|
||||
bind:value={toFilter}
|
||||
onchange={onFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select aria-label="Bulk export format" bind:value={bulkFormat}>
|
||||
<select aria-label={t("meetings.bulk_format_aria")} bind:value={bulkFormat}>
|
||||
<option value="md">.md</option>
|
||||
<option value="pdf">.pdf</option>
|
||||
<option value="docx">.docx</option>
|
||||
@@ -134,23 +157,23 @@
|
||||
class="bulk-btn"
|
||||
onclick={bulkExport}
|
||||
disabled={bulkExporting}
|
||||
title="Export every meeting matching the tag/date filters above"
|
||||
title={t("meetings.bulk_export_title")}
|
||||
>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{bulkExporting ? "Exporting…" : "Bulk export"}
|
||||
{bulkExporting ? t("meetings.exporting") : t("meetings.bulk_export")}
|
||||
</button>
|
||||
</div>
|
||||
{#if bulkResult}
|
||||
<p class="muted small">{bulkResult}</p>
|
||||
{/if}
|
||||
{#if meetings.searching}
|
||||
<p class="muted">Searching…</p>
|
||||
<p class="muted">{t("meetings.searching")}</p>
|
||||
{:else if displayItems.length === 0 && meetings.loading}
|
||||
<p class="muted">Loading meetings…</p>
|
||||
<p class="muted">{t("meetings.loading")}</p>
|
||||
{:else if displayItems.length === 0 && meetings.searchResults !== null}
|
||||
<p class="muted">No matches.</p>
|
||||
<p class="muted">{t("meetings.no_matches")}</p>
|
||||
{:else if displayItems.length === 0}
|
||||
<p class="muted">No meetings yet — click Record above to start.</p>
|
||||
<p class="muted">{t("meetings.empty")}</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each displayItems as m (m.id)}
|
||||
@@ -159,11 +182,11 @@
|
||||
<span class="row">
|
||||
<span class="title">{m.title}</span>
|
||||
{#if m.status === "recording" || m.status === "transcribing"}
|
||||
<span class="badge live">{m.status}</span>
|
||||
<span class="badge live">{statusLabel(m.status)}</span>
|
||||
{:else if m.status === "recovering"}
|
||||
<span class="badge recovering">recovering</span>
|
||||
<span class="badge recovering">{statusLabel("recovering")}</span>
|
||||
{:else if m.status === "error"}
|
||||
<span class="badge error">error</span>
|
||||
<span class="badge error">{statusLabel("error")}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="row muted small">
|
||||
@@ -175,8 +198,8 @@
|
||||
{/if}
|
||||
{#if m.tags.length > 0}
|
||||
<span class="row tags">
|
||||
{#each m.tags as t (t)}
|
||||
<span class="chip">{t}</span>
|
||||
{#each m.tags as tag (tag)}
|
||||
<span class="chip">{tag}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -185,14 +208,14 @@
|
||||
{#if m.status === "recovering"}
|
||||
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
Resume transcription
|
||||
{t("meetings.resume")}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="link danger"
|
||||
onclick={(e) => removeMeeting(e, m.id)}
|
||||
aria-label="Delete meeting"
|
||||
title="Delete meeting"
|
||||
aria-label={t("meetings.delete_aria")}
|
||||
title={t("meetings.delete_aria")}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
+824
-343
File diff suppressed because it is too large
Load Diff
+282
-128
@@ -4,6 +4,7 @@
|
||||
import { meetings } from "../stores/meetings.svelte";
|
||||
import { calendar } from "../stores/calendar.svelte";
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import { player } from "../stores/player.svelte";
|
||||
import {
|
||||
api,
|
||||
errorMessage,
|
||||
@@ -14,6 +15,7 @@
|
||||
type LlmStatus,
|
||||
} from "../api";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import TagChip from "../components/TagChip.svelte";
|
||||
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||
import {
|
||||
@@ -30,6 +32,8 @@
|
||||
Check,
|
||||
Cpu,
|
||||
Globe,
|
||||
Plus,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
onMount(() => calendar.load());
|
||||
@@ -37,10 +41,12 @@
|
||||
// ---- Recording playback (FR-REC-5) ----
|
||||
let audioSrc = $state<string | null>(null);
|
||||
let audioError = $state<string | null>(null);
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
audioSrc = null;
|
||||
audioError = null;
|
||||
player.reset();
|
||||
if (m?.recorded) {
|
||||
const id = m.id;
|
||||
api
|
||||
@@ -54,6 +60,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Seek + play when a transcript segment is clicked (player.seek bumps the
|
||||
// nonce so re-clicking the same segment still jumps). Reading seekNonce is
|
||||
// what makes this effect re-run.
|
||||
$effect(() => {
|
||||
player.seekNonce;
|
||||
const ms = player.seekMs;
|
||||
if (ms == null || !audioEl) return;
|
||||
audioEl.currentTime = ms / 1000;
|
||||
void audioEl.play().catch(() => {});
|
||||
});
|
||||
|
||||
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
|
||||
let llmStatus = $state<LlmStatus | null>(null);
|
||||
async function refreshLlmStatus() {
|
||||
@@ -75,12 +92,24 @@
|
||||
// the exact same set_llm_provider command (and hence the same "anthropic's
|
||||
// endpoint is fixed" / "switching away resets a stale hosted endpoint"
|
||||
// guarantees — see apply_llm_provider_args in commands.rs).
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
off: "Off",
|
||||
ollama: "Ollama",
|
||||
custom: "Custom",
|
||||
anthropic: "Anthropic (Claude)",
|
||||
};
|
||||
// Provider dropdown values. Labels resolve through `providerLabel` (a
|
||||
// function, not a static map, so a language switch re-renders them);
|
||||
// brand names stay literal, only the generic "Off"/"Custom" are translated.
|
||||
const PROVIDER_IDS = ["off", "ollama", "custom", "anthropic"];
|
||||
function providerLabel(id: string): string {
|
||||
switch (id) {
|
||||
case "off":
|
||||
return t("summary.provider_off");
|
||||
case "custom":
|
||||
return t("summary.provider_custom");
|
||||
case "ollama":
|
||||
return "Ollama";
|
||||
case "anthropic":
|
||||
return "Anthropic (Claude)";
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
}
|
||||
function isHostedStatus(status: LlmStatus | null): boolean {
|
||||
return !!status && status.provider !== "off" && !status.isLocal;
|
||||
}
|
||||
@@ -92,7 +121,7 @@
|
||||
// gate that matters even if the quick switch's own check is skipped, e.g.
|
||||
// "custom" resolving to a hosted endpoint that was configured elsewhere).
|
||||
let showHostedBanner = $state(false);
|
||||
let hostedBannerLabel = $state("this hosted provider");
|
||||
let hostedBannerLabel = $state(t("summary.this_hosted_provider"));
|
||||
let pendingHostedAction = $state<(() => void) | null>(null);
|
||||
function requireHostedAck(providerLabel: string, action: () => void) {
|
||||
if (!settings.settings.hosted_ai_acknowledged) {
|
||||
@@ -129,13 +158,13 @@
|
||||
if (next === "anthropic") {
|
||||
// Deterministically hosted — worth confirming before even switching to
|
||||
// it, not just before the next generate.
|
||||
requireHostedAck(PROVIDER_LABELS[next], () => void switchProvider(next));
|
||||
requireHostedAck(providerLabel(next), () => void switchProvider(next));
|
||||
} else {
|
||||
void switchProvider(next);
|
||||
}
|
||||
}
|
||||
function onGenerateClick() {
|
||||
const label = llmStatus ? (PROVIDER_LABELS[llmStatus.provider] ?? llmStatus.provider) : "";
|
||||
const label = llmStatus ? providerLabel(llmStatus.provider) : "";
|
||||
if (isHostedStatus(llmStatus)) {
|
||||
requireHostedAck(label, generateSummary);
|
||||
} else {
|
||||
@@ -143,20 +172,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Editable copy so toggling a checkbox doesn't persist until "Save" is
|
||||
// pressed; recomputes whenever a different meeting (or a freshly generated
|
||||
// summary) comes in. bind:checked mutates each item in place, which a plain
|
||||
// $derived array tolerates fine — only the array identity is recomputed.
|
||||
let editableItems = $derived(
|
||||
(meetings.selected?.summary?.action_items ?? []).map((i) => ({ ...i }) as ActionItem),
|
||||
);
|
||||
// Editable working copy so add/delete/edit don't persist until "Save" is
|
||||
// pressed. Resynced from the meeting's table-backed action_items whenever a
|
||||
// *different* meeting is selected (same guard pattern as pendingTags), so
|
||||
// it isn't clobbered mid-edit.
|
||||
let editableItems = $state<ActionItem[]>([]);
|
||||
let loadedItemsForId: string | null = null;
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
if (m && m.id !== loadedItemsForId) {
|
||||
editableItems = m.action_items.map((i) => ({ ...i }));
|
||||
loadedItemsForId = m.id;
|
||||
} else if (!m) {
|
||||
loadedItemsForId = null;
|
||||
}
|
||||
});
|
||||
|
||||
function addActionItem() {
|
||||
editableItems = [
|
||||
...editableItems,
|
||||
{ id: null, text: "", owner: null, due_at: null, confirmed: false, reminder_set: false },
|
||||
];
|
||||
}
|
||||
function removeActionItem(i: number) {
|
||||
editableItems = editableItems.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
let savingItems = $state(false);
|
||||
async function saveActionItems() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
savingItems = true;
|
||||
try {
|
||||
await meetings.confirmActionItems(m.id, editableItems);
|
||||
// Drop blank rows (an added-but-never-typed item) rather than persisting
|
||||
// empty tasks; keep null owner rather than "".
|
||||
const items = editableItems
|
||||
.filter((i) => i.text.trim())
|
||||
.map((i) => ({ ...i, owner: i.owner?.trim() || null }));
|
||||
await meetings.confirmActionItems(m.id, items);
|
||||
// confirmActionItems reloaded the meeting — resync so freshly inserted
|
||||
// rows carry their new ids (a second save would otherwise re-insert them).
|
||||
editableItems = (meetings.selected?.action_items ?? []).map((i) => ({ ...i }));
|
||||
} finally {
|
||||
savingItems = false;
|
||||
}
|
||||
@@ -278,8 +334,8 @@
|
||||
});
|
||||
|
||||
function addTag(raw: string) {
|
||||
const t = raw.trim().toLowerCase();
|
||||
if (t && !pendingTags.includes(t)) pendingTags.push(t);
|
||||
const tag = raw.trim().toLowerCase();
|
||||
if (tag && !pendingTags.includes(tag)) pendingTags.push(tag);
|
||||
}
|
||||
// GitHub-topics-style input: a comma commits everything before it as its
|
||||
// own chip immediately, leaving whatever's after as the live draft.
|
||||
@@ -299,8 +355,8 @@
|
||||
if (tagDraft.trim()) addTag(tagDraft);
|
||||
tagDraft = "";
|
||||
}
|
||||
function removeTag(t: string) {
|
||||
pendingTags = pendingTags.filter((x) => x !== t);
|
||||
function removeTag(tag: string) {
|
||||
pendingTags = pendingTags.filter((x) => x !== tag);
|
||||
}
|
||||
|
||||
let savingTags = $state(false);
|
||||
@@ -450,7 +506,7 @@
|
||||
|
||||
<div class="wrap">
|
||||
{#if meetings.selected?.recorded}
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.recording_heading")}</h3>
|
||||
{#if audioSrc}
|
||||
<!-- a meeting recording has no caption track — no svelte-ignore needed,
|
||||
the current eslint-plugin-svelte doesn't flag this element. -->
|
||||
@@ -461,26 +517,29 @@
|
||||
controls
|
||||
controlsList="nodownload noplaybackrate"
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
bind:this={audioEl}
|
||||
ontimeupdate={() => audioEl && (player.currentMs = audioEl.currentTime * 1000)}
|
||||
src={audioSrc}
|
||||
></audio>
|
||||
{:else if audioError}
|
||||
<p class="muted">{audioError}</p>
|
||||
{:else}
|
||||
<p class="muted">Loading recording…</p>
|
||||
<p class="muted">{t("summary.loading_recording")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if meetings.selected && settings.settings.sync_enabled}
|
||||
<h3><UploadCloud size={14} aria-hidden="true" /> Sync</h3>
|
||||
<h3><UploadCloud size={14} aria-hidden="true" /> {t("summary.sync_heading")}</h3>
|
||||
<button
|
||||
class="upload-now"
|
||||
class="primary upload-now"
|
||||
disabled={meetings.uploading}
|
||||
onclick={() => {
|
||||
const m = meetings.selected;
|
||||
if (m) meetings.uploadNow(m.id);
|
||||
}}
|
||||
>
|
||||
{meetings.uploading ? "Uploading…" : "Upload now"}
|
||||
<UploadCloud size={14} aria-hidden="true" />
|
||||
{meetings.uploading ? t("summary.uploading") : t("summary.upload_now")}
|
||||
</button>
|
||||
{#if meetings.syncJobs.length}
|
||||
<ul class="jobs">
|
||||
@@ -499,34 +558,36 @@
|
||||
onclick={() => {
|
||||
const m = meetings.selected;
|
||||
if (m) meetings.retryJob(job.jobId, m.id);
|
||||
}}>retry</button
|
||||
}}>{t("summary.retry")}</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">No uploads yet for this meeting.</p>
|
||||
<p class="muted">{t("summary.no_uploads")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Tags size={14} aria-hidden="true" /> Tags</h3>
|
||||
<h3><Tags size={14} aria-hidden="true" /> {t("summary.tags_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to tag it.</p>
|
||||
<p class="muted">{t("summary.tags_select")}</p>
|
||||
{:else}
|
||||
<div class="tag-editor">
|
||||
{#each pendingTags as t (t)}
|
||||
{#each pendingTags as tag (tag)}
|
||||
<TagChip
|
||||
tag={t}
|
||||
{tag}
|
||||
removable
|
||||
onRemove={() => removeTag(t)}
|
||||
onClick={() => meetings.filterByTag(t)}
|
||||
onRemove={() => removeTag(tag)}
|
||||
onClick={() => meetings.filterByTag(tag)}
|
||||
/>
|
||||
{/each}
|
||||
<input
|
||||
class="tag-input"
|
||||
list="known-tags"
|
||||
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
|
||||
placeholder={pendingTags.length
|
||||
? t("summary.tag_add_placeholder")
|
||||
: t("summary.tag_first_placeholder")}
|
||||
bind:value={tagDraft}
|
||||
oninput={onTagInput}
|
||||
onkeydown={onTagInputKeydown}
|
||||
@@ -534,17 +595,17 @@
|
||||
/>
|
||||
</div>
|
||||
<datalist id="known-tags">
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}></option>
|
||||
{#each meetings.allTags as tag (tag)}
|
||||
<option value={tag}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{generatingTags ? "Generating…" : "Generate tags"}
|
||||
{generatingTags ? t("summary.generating") : t("summary.generate_tags")}
|
||||
</button>
|
||||
<button class="link" onclick={saveTags} disabled={savingTags}>
|
||||
{savingTags ? "Saving…" : "Save tags"}
|
||||
{savingTags ? t("summary.saving") : t("summary.save_tags")}
|
||||
</button>
|
||||
</div>
|
||||
{#if tagGenError}
|
||||
@@ -552,18 +613,18 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> {t("summary.summary_heading")}</h3>
|
||||
|
||||
<div class="provider-row">
|
||||
<label class="provider-select">
|
||||
<span class="sr-only">AI provider</span>
|
||||
<span class="sr-only">{t("summary.ai_provider")}</span>
|
||||
<select
|
||||
value={llmStatus?.provider ?? settings.settings.llm_provider}
|
||||
onchange={onProviderChange}
|
||||
disabled={switchingProvider}
|
||||
>
|
||||
{#each Object.entries(PROVIDER_LABELS) as [id, label] (id)}
|
||||
<option value={id}>{label}</option>
|
||||
{#each PROVIDER_IDS as id (id)}
|
||||
<option value={id}>{providerLabel(id)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
@@ -573,7 +634,7 @@
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
{llmStatus.isLocal ? "local" : "leaves this device"}
|
||||
{llmStatus.isLocal ? t("summary.local") : t("summary.leaves_device")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -586,9 +647,9 @@
|
||||
{/if}
|
||||
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to generate a summary.</p>
|
||||
<p class="muted">{t("summary.select_generate")}</p>
|
||||
{:else if meetings.summarizingId === meetings.selected.id}
|
||||
<p class="muted">Generating…</p>
|
||||
<p class="muted">{t("summary.generating")}</p>
|
||||
{#if meetings.summaryStream}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="summary-md">{@html renderMarkdown(meetings.summaryStream)}</div>
|
||||
@@ -597,22 +658,22 @@
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="summary-md">{@html renderMarkdown(meetings.selected.summary.summary_md)}</div>
|
||||
{#if meetings.selected.summary.decisions.length > 0}
|
||||
<h4>Decisions</h4>
|
||||
<h4>{t("summary.decisions_heading")}</h4>
|
||||
<ul class="decisions">
|
||||
{#each meetings.selected.summary.decisions as d, i (i)}
|
||||
<li>{d}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button class="link" onclick={onGenerateClick}>Regenerate</button>
|
||||
<button class="link" onclick={onGenerateClick}>{t("summary.regenerate")}</button>
|
||||
{:else}
|
||||
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
|
||||
<p class="muted">{t("summary.generated_hint")}</p>
|
||||
{#if llmStatus && llmStatus.provider === "off"}
|
||||
<p class="muted small">No AI provider configured — enable one in Settings.</p>
|
||||
<p class="muted small">{t("summary.no_provider")}</p>
|
||||
{:else}
|
||||
<button class="primary" onclick={onGenerateClick}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Generate summary
|
||||
{t("summary.generate_summary")}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -620,24 +681,21 @@
|
||||
<p class="error">{meetings.summaryError}</p>
|
||||
{/if}
|
||||
|
||||
<h3><FileText size={14} aria-hidden="true" /> Feature briefs</h3>
|
||||
<h3><FileText size={14} aria-hidden="true" /> {t("summary.briefs_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to create or view feature briefs.</p>
|
||||
<p class="muted">{t("summary.briefs_select")}</p>
|
||||
{:else}
|
||||
<p class="muted small">
|
||||
Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a
|
||||
coding agent, or serve it over the MCP server once that's on.
|
||||
</p>
|
||||
<p class="muted small">{t("summary.briefs_desc")}</p>
|
||||
<div class="brief-create">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Target repo (optional), e.g. acme/reporting-web"
|
||||
placeholder={t("summary.brief_repo_placeholder")}
|
||||
bind:value={briefTargetRepo}
|
||||
disabled={creatingBrief}
|
||||
/>
|
||||
<button class="primary" onclick={createBrief} disabled={creatingBrief}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{creatingBrief ? "Distilling…" : "Create feature brief"}
|
||||
{creatingBrief ? t("summary.distilling") : t("summary.create_brief")}
|
||||
</button>
|
||||
</div>
|
||||
{#if briefError}
|
||||
@@ -652,7 +710,7 @@
|
||||
{b.title}
|
||||
{#if b.target_repo}<span class="muted small">{b.target_repo}</span>{/if}
|
||||
</button>
|
||||
<label class="expose" title="Available when the MCP server is on">
|
||||
<label class="expose" title={t("summary.brief_mcp_title")}>
|
||||
<input type="checkbox" checked={b.exposed} onchange={() => toggleBriefExposed(b)} />
|
||||
<span class="muted small">MCP</span>
|
||||
</label>
|
||||
@@ -666,24 +724,24 @@
|
||||
<div class="brief-actions">
|
||||
<button class="link" onclick={copyBriefMarkdown}>
|
||||
{#if copiedFormat === "md"}
|
||||
<Check size={13} aria-hidden="true" /> Copied
|
||||
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
|
||||
{:else}
|
||||
<Copy size={13} aria-hidden="true" /> Copy as Markdown
|
||||
<Copy size={13} aria-hidden="true" /> {t("summary.copy_md")}
|
||||
{/if}
|
||||
</button>
|
||||
<button class="link" onclick={copyBriefJson}>
|
||||
{#if copiedFormat === "json"}
|
||||
<Check size={13} aria-hidden="true" /> Copied
|
||||
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
|
||||
{:else}
|
||||
<Copy size={13} aria-hidden="true" /> Copy as JSON
|
||||
<Copy size={13} aria-hidden="true" /> {t("summary.copy_json")}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<h4>Problem</h4>
|
||||
<h4>{t("summary.brief_problem")}</h4>
|
||||
<p class="brief-text">{selectedBrief.problem}</p>
|
||||
<h4>Desired outcome</h4>
|
||||
<h4>{t("summary.brief_outcome")}</h4>
|
||||
<p class="brief-text">{selectedBrief.desired_outcome}</p>
|
||||
<h4>Acceptance criteria</h4>
|
||||
<h4>{t("summary.brief_criteria")}</h4>
|
||||
{#if selectedBrief.acceptance_criteria.length}
|
||||
<ul class="decisions">
|
||||
{#each selectedBrief.acceptance_criteria as c, i (i)}
|
||||
@@ -691,10 +749,10 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted small">None captured.</p>
|
||||
<p class="muted small">{t("summary.brief_none")}</p>
|
||||
{/if}
|
||||
{#if selectedBrief.context_excerpts.length}
|
||||
<h4>Context</h4>
|
||||
<h4>{t("summary.brief_context")}</h4>
|
||||
<ul class="excerpts">
|
||||
{#each selectedBrief.context_excerpts as e, i (i)}
|
||||
<li><span class="speaker">{e.speaker}:</span> "{e.text}"</li>
|
||||
@@ -705,71 +763,111 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><ListTodo size={14} aria-hidden="true" /> Action items</h3>
|
||||
<h3><ListTodo size={14} aria-hidden="true" /> {t("summary.action_items_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to see its action items.</p>
|
||||
{:else if editableItems.length === 0}
|
||||
<p class="muted">Parsed from the summary; edit and confirm before they become tasks.</p>
|
||||
<p class="muted">{t("summary.ai_select")}</p>
|
||||
{:else}
|
||||
<ul class="action-items">
|
||||
{#each editableItems as item, i (i)}
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={item.confirmed} />
|
||||
<span>{item.text}</span>
|
||||
</label>
|
||||
{#if item.owner}<span class="muted small">{item.owner}</span>{/if}
|
||||
<input
|
||||
type="date"
|
||||
class="due-date"
|
||||
aria-label="Due date"
|
||||
value={dueDateInput(item.due_at)}
|
||||
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<label class="remind" title="Schedule a local reminder for this due date">
|
||||
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button onclick={saveActionItems} disabled={savingItems}>
|
||||
{savingItems ? "Saving…" : "Save action items"}
|
||||
</button>
|
||||
{#if editableItems.length === 0}
|
||||
<p class="muted">{t("summary.ai_empty")}</p>
|
||||
{:else}
|
||||
<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>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="ai-actions">
|
||||
<button class="link" onclick={addActionItem}>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
{t("summary.add_action_item")}
|
||||
</button>
|
||||
<button class="primary" onclick={saveActionItems} disabled={savingItems}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{savingItems ? t("summary.saving") : t("summary.save_action_items")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3><CalendarDays size={14} aria-hidden="true" /> Calendar event</h3>
|
||||
<h3><CalendarDays size={14} aria-hidden="true" /> {t("summary.calendar_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to link it to a calendar event.</p>
|
||||
<p class="muted">{t("summary.cal_select")}</p>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<input type="text" bind:value={eventLinkSearch} placeholder="Search event title…" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={eventLinkSearch}
|
||||
placeholder={t("summary.event_search_placeholder")}
|
||||
/>
|
||||
<input type="date" bind:value={eventLinkDateFilter} />
|
||||
</div>
|
||||
<label class="pick"
|
||||
>Linked event
|
||||
>{t("summary.linked_event")}
|
||||
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
|
||||
<option value="" disabled>{eventDetail ? "Change event…" : "Link an event…"}</option>
|
||||
<option value="" disabled
|
||||
>{eventDetail ? t("summary.change_event") : t("summary.link_event")}</option
|
||||
>
|
||||
{#each filteredLinkEvents as ev (ev.id)}
|
||||
<option value={ev.id}
|
||||
>{ev.subject ?? "(untitled)"} — {formatEventTime(ev.starts_at)}</option
|
||||
>{ev.subject ?? t("summary.untitled_event")} — {formatEventTime(ev.starts_at)}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if calendar.events.length === 0}
|
||||
<p class="muted small">
|
||||
No events imported yet — import a <code>.pst</code> from Settings → Calendar.
|
||||
{t("summary.no_events_1")} <code>.pst</code>
|
||||
{t("summary.no_events_2")}
|
||||
</p>
|
||||
{:else if filteredLinkEvents.length === 0}
|
||||
<p class="muted small">No events match this search/date — try clearing one.</p>
|
||||
<p class="muted small">{t("summary.no_events_match")}</p>
|
||||
{/if}
|
||||
|
||||
{#if eventDetail}
|
||||
<div class="event-card">
|
||||
<div class="event-title">{eventDetail.event.subject ?? "(untitled)"}</div>
|
||||
<div class="event-title">{eventDetail.event.subject ?? t("summary.untitled_event")}</div>
|
||||
{#if eventDetail.event.organizer}
|
||||
<div class="muted small">Organizer: {eventDetail.event.organizer}</div>
|
||||
<div class="muted small">
|
||||
{t("summary.organizer_label", { name: eventDetail.event.organizer })}
|
||||
</div>
|
||||
{/if}
|
||||
{#if eventDetail.event.starts_at}
|
||||
<div class="muted small">{formatEventTime(eventDetail.event.starts_at)}</div>
|
||||
@@ -778,9 +876,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Users size={14} aria-hidden="true" /> Participants</h3>
|
||||
<h3><Users size={14} aria-hidden="true" /> {t("summary.participants_heading")}</h3>
|
||||
{#if !eventDetail || eventDetail.participants.length === 0}
|
||||
<p class="muted">Populated from the linked calendar event.</p>
|
||||
<p class="muted">{t("summary.participants_hint")}</p>
|
||||
{:else}
|
||||
<ul class="participants">
|
||||
{#each eventDetail.participants as p (p.id)}
|
||||
@@ -792,11 +890,11 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Speakers</h3>
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.speakers_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to name its speakers.</p>
|
||||
<p class="muted">{t("summary.speakers_select")}</p>
|
||||
{:else if meetings.selected.speakers.length === 0}
|
||||
<p class="muted">No speakers detected yet.</p>
|
||||
<p class="muted">{t("summary.no_speakers")}</p>
|
||||
{:else}
|
||||
<ul class="speakers">
|
||||
{#each meetings.selected.speakers as s (s.label)}
|
||||
@@ -805,23 +903,26 @@
|
||||
{#if addingNameFor === s.label}
|
||||
<input
|
||||
class="grow"
|
||||
placeholder="Speaker name"
|
||||
placeholder={t("summary.speaker_name_placeholder")}
|
||||
bind:value={newNameDraft}
|
||||
onkeydown={(e) => e.key === "Enter" && confirmNewName(s.label)}
|
||||
/>
|
||||
<button class="link" onclick={() => confirmNewName(s.label)}>Save</button>
|
||||
<button class="link" onclick={() => (addingNameFor = null)}>Cancel</button>
|
||||
<button class="link" onclick={() => confirmNewName(s.label)}>{t("summary.save")}</button
|
||||
>
|
||||
<button class="link" onclick={() => (addingNameFor = null)}
|
||||
>{t("summary.cancel")}</button
|
||||
>
|
||||
{:else}
|
||||
<select
|
||||
class="grow"
|
||||
value={s.participant_id ?? ""}
|
||||
onchange={(e) => onPickSpeakerOption(e, s.label)}
|
||||
>
|
||||
<option value="" disabled>{s.display_name ?? "Name this speaker…"}</option>
|
||||
<option value="" disabled>{s.display_name ?? t("summary.name_speaker")}</option>
|
||||
{#each eventDetail?.participants ?? [] as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
<option value={NEW_NAME}>+ Add new name…</option>
|
||||
<option value={NEW_NAME}>{t("summary.add_new_name")}</option>
|
||||
</select>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -868,10 +969,6 @@
|
||||
gap: 0.5rem;
|
||||
margin: -0.3rem 0 0.6rem;
|
||||
}
|
||||
.provider-select select {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
.provider-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -899,9 +996,34 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:hover {
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.upload-now {
|
||||
align-self: start;
|
||||
}
|
||||
.provider-select select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.provider-select select:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.provider-select select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
@@ -959,8 +1081,7 @@
|
||||
ul.action-items li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -969,6 +1090,42 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ai-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ai-owner {
|
||||
flex: none;
|
||||
width: 5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ai-del {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.ai-del:hover {
|
||||
color: var(--danger);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.ai-actions .link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem;
|
||||
@@ -1052,9 +1209,6 @@
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
.upload-now {
|
||||
align-self: start;
|
||||
}
|
||||
.jobs {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
import { recording } from "../stores/recording.svelte";
|
||||
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 { 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 Splitter from "../components/Splitter.svelte";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -23,28 +27,82 @@
|
||||
NotebookPen,
|
||||
Eye,
|
||||
Pencil,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
|
||||
return speakers.find((s) => s.label === label)?.display_name ?? label;
|
||||
}
|
||||
|
||||
// Transcript timestamps: segment start_ms → "m:ss" (or "h:mm:ss" past an
|
||||
// hour). Shown as a quiet monospace prefix so a line reads "0:42 Alice: …".
|
||||
function fmtTs(ms: number): string {
|
||||
const total = Math.floor(ms / 1000);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const mm = h ? String(m).padStart(2, "0") : String(m);
|
||||
return `${h ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// T8.7/FR-TRX-4: the meeting view shows the language actually used, not
|
||||
// just the raw ISO code — falls back to the code itself if it's not in
|
||||
// the (curated) catalog, and to "auto-detecting…" before any is known.
|
||||
function languageLabel(code: string | null): string {
|
||||
if (!code) return "auto-detecting…";
|
||||
if (!code) return t("transcript.lang_auto");
|
||||
return settings.languages.find((l) => l.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
let notesText = $state("");
|
||||
// Notes is a single pane: raw markdown ("Editor") or the rendered result
|
||||
// ("Preview"), toggled by one button whose label flips to the other mode.
|
||||
let notesPreview = $state(false);
|
||||
// Defaults to Preview: the merged notes.md is now a properly sectioned
|
||||
// document (## Notes / ## Transcript), not worth reading as raw markdown
|
||||
// by default the way the old single-speaker-blob output arguably was.
|
||||
let notesPreview = $state(true);
|
||||
let editorEl: HTMLTextAreaElement | undefined = $state();
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let loadedForId: string | null = null;
|
||||
|
||||
// Transcript/notes split (FR-UX-1): resizable (drag the Splitter) and
|
||||
// each side independently hideable, shared across the finalized-meeting
|
||||
// and live-recording views via the layout store. `splitWidth` tracks the
|
||||
// container's current pixel width (bound below) so a drag delta in
|
||||
// pixels can be converted to a fraction of the available space.
|
||||
let splitWidth = $state(600);
|
||||
function splitColumns(): string {
|
||||
if (layout.transcriptCollapsed) return "auto auto 1fr";
|
||||
if (layout.notesCollapsed) return "1fr auto auto";
|
||||
return `${layout.transcriptFraction * 100}% auto ${(1 - layout.transcriptFraction) * 100}%`;
|
||||
}
|
||||
function onSplitResize(deltaPx: number) {
|
||||
if (splitWidth <= 0) return;
|
||||
layout.transcriptFraction = clamp(layout.transcriptFraction + deltaPx / splitWidth, 0.2, 0.8);
|
||||
}
|
||||
function toggleTranscript() {
|
||||
const wouldCollapse = !layout.transcriptCollapsed;
|
||||
if (wouldCollapse && layout.notesCollapsed) return; // never hide both
|
||||
layout.transcriptCollapsed = wouldCollapse;
|
||||
layout.persist();
|
||||
}
|
||||
function toggleNotes() {
|
||||
const wouldCollapse = !layout.notesCollapsed;
|
||||
if (wouldCollapse && layout.transcriptCollapsed) return;
|
||||
layout.notesCollapsed = wouldCollapse;
|
||||
layout.persist();
|
||||
}
|
||||
|
||||
// Live-recording transcript: which segment (by start_ms, the note anchor —
|
||||
// see recording.svelte.ts) is showing its note-entry field, if any.
|
||||
let selectedSegmentMs = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
void recording.meetingId; // dependency: reset the open note field for a new recording
|
||||
selectedSegmentMs = null;
|
||||
});
|
||||
|
||||
// Sync the editor buffer whenever a different meeting is selected.
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
@@ -105,7 +163,7 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.md`,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
filters: [{ name: t("transcript.filter_md"), extensions: ["md"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "md");
|
||||
}
|
||||
@@ -122,7 +180,7 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.pdf`,
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
filters: [{ name: t("transcript.filter_pdf"), extensions: ["pdf"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "pdf");
|
||||
}
|
||||
@@ -132,11 +190,52 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.docx`,
|
||||
filters: [{ name: "Word document", extensions: ["docx"] }],
|
||||
filters: [{ name: t("transcript.filter_docx"), extensions: ["docx"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "docx");
|
||||
}
|
||||
|
||||
// Export a single self-contained Obsidian note (frontmatter + notes + summary
|
||||
// + action items + timestamped transcript, no audio) — save it into a vault
|
||||
// folder from the dialog. FR-STORE-4 sibling of the bundle export.
|
||||
async function exportObsidian() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.md`,
|
||||
filters: [{ name: t("transcript.filter_obsidian"), extensions: ["md"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "obsidian");
|
||||
}
|
||||
|
||||
// FR-REC-5: the finalized segment currently playing (greatest start_ms at or
|
||||
// before the playhead), for highlight + auto-scroll. currentMs ticks ~4x/s
|
||||
// but this only changes value at a segment boundary, so the effect below is
|
||||
// quiet between boundaries.
|
||||
const activeSegId = $derived.by(() => {
|
||||
const ms = player.currentMs;
|
||||
let id: number | null = null;
|
||||
for (const s of meetings.selected?.segments ?? []) {
|
||||
if (s.start_ms <= ms) id = s.id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
// Auto-scroll the playing segment into view — unless the user scrolled the
|
||||
// transcript by hand recently, so playback doesn't yank them back.
|
||||
// ponytail: 4s manual-scroll grace; widen if it still feels grabby.
|
||||
let transcriptEl = $state<HTMLElement | null>(null);
|
||||
let lastManualScroll = 0;
|
||||
$effect(() => {
|
||||
const id = activeSegId;
|
||||
if (id == null || !transcriptEl) return;
|
||||
if (Date.now() - lastManualScroll < 4000) return;
|
||||
transcriptEl
|
||||
.querySelector<HTMLElement>(".seg.active")
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
|
||||
let reprocessModel = $state("");
|
||||
// T8.7/FR-TRX-4: "" reuses the meeting's current language (backend default
|
||||
// when `language` is omitted) rather than resetting it to auto.
|
||||
@@ -161,133 +260,332 @@
|
||||
class="meeting-title"
|
||||
value={m.title}
|
||||
onchange={onTitleChange}
|
||||
aria-label="Meeting title"
|
||||
aria-label={t("transcript.title_aria")}
|
||||
/>
|
||||
<div class="split">
|
||||
<div class="pane transcript">
|
||||
<h4>
|
||||
<MessageSquareText size={14} aria-hidden="true" /> Transcript
|
||||
<span class="badge lang" title="Transcription language">{languageLabel(m.language)}</span
|
||||
<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. -->
|
||||
<div
|
||||
class="pane transcript"
|
||||
class:collapsed={layout.transcriptCollapsed}
|
||||
bind:this={transcriptEl}
|
||||
onwheel={() => (lastManualScroll = Date.now())}
|
||||
ontouchmove={() => (lastManualScroll = Date.now())}
|
||||
>
|
||||
{#if layout.transcriptCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.show")}
|
||||
aria-label={t("transcript.show")}
|
||||
>
|
||||
</h4>
|
||||
{#if m.recorded && settings.models.some((mo) => mo.installed)}
|
||||
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
|
||||
<div class="reprocess">
|
||||
<select bind:value={reprocessModel}>
|
||||
<option value="">Re-transcribe with…</option>
|
||||
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
|
||||
<option value={mo.id}>{mo.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if reprocessModelInfo?.multilingual}
|
||||
<select bind:value={reprocessLanguage} aria-label="Reprocess language">
|
||||
<option value="">Keep current language</option>
|
||||
<option value="auto">Auto-detect</option>
|
||||
{#each settings.languages as l (l.code)}
|
||||
<option value={l.code}>{l.label}</option>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<MessageSquareText size={14} aria-hidden="true" />
|
||||
{t("transcript.heading")}
|
||||
<span class="badge lang" title={t("transcript.lang_title")}
|
||||
>{languageLabel(m.language)}</span
|
||||
>
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.hide")}
|
||||
aria-label={t("transcript.hide")}
|
||||
>
|
||||
<PanelLeftClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
{#if m.recorded && settings.models.some((mo) => mo.installed)}
|
||||
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
|
||||
<div class="reprocess">
|
||||
<select bind:value={reprocessModel}>
|
||||
<option value="">{t("transcript.reprocess_placeholder")}</option>
|
||||
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
|
||||
<option value={mo.id}>{mo.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
|
||||
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
|
||||
{reprocessing ? "Re-transcribing…" : "Go"}
|
||||
{#if reprocessModelInfo?.multilingual}
|
||||
<select
|
||||
bind:value={reprocessLanguage}
|
||||
aria-label={t("transcript.reprocess_lang_aria")}
|
||||
>
|
||||
<option value="">{t("transcript.reprocess_keep_lang")}</option>
|
||||
<option value="auto">{t("settings.transcription.auto")}</option>
|
||||
{#each settings.languages as l (l.code)}
|
||||
<option value={l.code}>{l.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
|
||||
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
|
||||
{reprocessing ? t("transcript.retranscribing") : t("transcript.reprocess_go")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if m.segments.length === 0}
|
||||
<p class="muted">{t("transcript.empty")}</p>
|
||||
{:else}
|
||||
{#each m.segments as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={s.id === activeSegId}
|
||||
onclick={() => player.seek(s.start_ms)}
|
||||
title={t("transcript.play_from_here")}
|
||||
>
|
||||
<span class="ts">{fmtTs(s.start_ms)}</span>
|
||||
<strong>{speakerName(s.speaker, m.speakers)}:</strong>
|
||||
{s.text}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
|
||||
<Splitter
|
||||
label={t("transcript.resize")}
|
||||
onResize={onSplitResize}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="pane notes" class:collapsed={layout.notesCollapsed}>
|
||||
{#if layout.notesCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.show")}
|
||||
aria-label={t("notes.show")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<NotebookPen size={14} aria-hidden="true" />
|
||||
{t("notes.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.hide")}
|
||||
aria-label={t("notes.hide")}
|
||||
>
|
||||
<PanelRightClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
<div class="toolbar" role="toolbar" aria-label={t("notes.toolbar_aria")}>
|
||||
<button
|
||||
onclick={() => wrapSelection("**")}
|
||||
title={t("notes.bold")}
|
||||
aria-label={t("notes.bold")}
|
||||
>
|
||||
<Bold size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => wrapSelection("_")}
|
||||
title={t("notes.italic")}
|
||||
aria-label={t("notes.italic")}
|
||||
>
|
||||
<Italic size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("# ")}
|
||||
title={t("notes.h1")}
|
||||
aria-label={t("notes.h1")}
|
||||
>
|
||||
<Heading1 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("## ")}
|
||||
title={t("notes.h2")}
|
||||
aria-label={t("notes.h2")}
|
||||
>
|
||||
<Heading2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- ")}
|
||||
title={t("notes.bullet")}
|
||||
aria-label={t("notes.bullet")}
|
||||
>
|
||||
<List size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- [ ] ")}
|
||||
title={t("notes.checkbox_title")}
|
||||
aria-label={t("notes.checkbox_aria")}
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={() => (notesPreview = !notesPreview)}
|
||||
title={notesPreview ? t("notes.edit_raw") : t("notes.render")}
|
||||
aria-pressed={notesPreview}
|
||||
>
|
||||
{#if notesPreview}
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
{t("notes.editor")}
|
||||
{:else}
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
{t("notes.preview")}
|
||||
{/if}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title={t("notes.export_md_title")}>
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
.md
|
||||
</button>
|
||||
<button onclick={exportPdf} title={t("notes.export_pdf_title")}>
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
PDF
|
||||
</button>
|
||||
<button onclick={exportDocx} title={t("notes.export_docx_title")}>
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
Word
|
||||
</button>
|
||||
<button onclick={exportBundle} title={t("notes.export_bundle_title")}>
|
||||
<FolderOutput size={13} aria-hidden="true" />
|
||||
Bundle
|
||||
</button>
|
||||
<button onclick={exportObsidian} title={t("notes.export_obsidian_title")}>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
Obsidian
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if m.segments.length === 0}
|
||||
<p class="muted">No transcript for this meeting.</p>
|
||||
{:else}
|
||||
{#each m.segments as s (s.id)}
|
||||
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="pane notes">
|
||||
<h4><NotebookPen size={14} aria-hidden="true" /> Notes</h4>
|
||||
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
|
||||
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold">
|
||||
<Bold size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic">
|
||||
<Italic size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => insertLinePrefix("# ")} title="Heading 1" aria-label="Heading 1">
|
||||
<Heading1 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => insertLinePrefix("## ")} title="Heading 2" aria-label="Heading 2">
|
||||
<Heading2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- ")}
|
||||
title="Bullet list"
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- [ ] ")}
|
||||
title="Checkbox"
|
||||
aria-label="Checkbox list item"
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={() => (notesPreview = !notesPreview)}
|
||||
title={notesPreview ? "Edit the raw markdown" : "Render the markdown"}
|
||||
aria-pressed={notesPreview}
|
||||
>
|
||||
<div class="editor-preview">
|
||||
{#if notesPreview}
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Editor
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="preview">{@html renderMarkdown(notesText)}</div>
|
||||
{:else}
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
Preview
|
||||
<textarea
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder={t("notes.placeholder")}
|
||||
></textarea>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title="Export notes as .md">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
.md
|
||||
</button>
|
||||
<button onclick={exportPdf} title="Export notes as .pdf">
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
PDF
|
||||
</button>
|
||||
<button onclick={exportDocx} title="Export notes as .docx">
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
Word
|
||||
</button>
|
||||
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
|
||||
<FolderOutput size={13} aria-hidden="true" />
|
||||
Bundle
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-preview">
|
||||
{#if notesPreview}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="preview">{@html renderMarkdown(notesText)}</div>
|
||||
{:else}
|
||||
<textarea
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder="Notes…"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if recording.segments.length === 0}
|
||||
<p class="muted pad">Transcript will appear here as you record.</p>
|
||||
{:else if recording.state === "idle"}
|
||||
<p class="muted pad">{t("transcript.will_appear")}</p>
|
||||
{:else}
|
||||
<div class="pad">
|
||||
{#each recording.segments as s (s.id)}
|
||||
<p class:interim={s.interim}>
|
||||
<strong>{speakerName(s.speaker)}:</strong>
|
||||
{s.text}
|
||||
</p>
|
||||
{/each}
|
||||
<!-- Granola-style redesign: the Notes pane is open and typable while
|
||||
recording, and clicking a transcript line attaches a note to that
|
||||
moment — both merged into notes.md with the transcript at stop. -->
|
||||
<div
|
||||
class="split"
|
||||
style="grid-template-columns: {splitColumns()};"
|
||||
bind:clientWidth={splitWidth}
|
||||
>
|
||||
<div class="pane transcript" class:collapsed={layout.transcriptCollapsed}>
|
||||
{#if layout.transcriptCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.show")}
|
||||
aria-label={t("transcript.show")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<MessageSquareText size={14} aria-hidden="true" />
|
||||
{t("transcript.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.hide")}
|
||||
aria-label={t("transcript.hide")}
|
||||
>
|
||||
<PanelLeftClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
{#if recording.segments.length === 0}
|
||||
<p class="muted">{t("transcript.will_appear")}</p>
|
||||
{:else}
|
||||
{#each recording.segments as s (s.id)}
|
||||
{@const hasNote = recording.segmentNotes.has(s.start_ms)}
|
||||
{@const open = selectedSegmentMs === s.start_ms}
|
||||
<div class="segment">
|
||||
<button
|
||||
type="button"
|
||||
class="segment-line"
|
||||
class:interim={s.interim}
|
||||
class:active={open}
|
||||
onclick={() => (selectedSegmentMs = open ? null : s.start_ms)}
|
||||
>
|
||||
<span class="ts">{fmtTs(s.start_ms)}</span>
|
||||
<strong>{speakerName(s.speaker)}:</strong>
|
||||
{s.text}
|
||||
{#if hasNote}
|
||||
<span class="note-badge" title={t("transcript.has_note")}>📝</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
<input
|
||||
type="text"
|
||||
class="segment-note-input"
|
||||
placeholder={t("transcript.note_placeholder")}
|
||||
value={recording.segmentNotes.get(s.start_ms) ?? ""}
|
||||
onchange={(e) =>
|
||||
recording.setSegmentNote(s.start_ms, (e.target as HTMLInputElement).value)}
|
||||
aria-label={t("transcript.note_aria")}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
|
||||
<Splitter
|
||||
label={t("transcript.resize")}
|
||||
onResize={onSplitResize}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="pane notes" class:collapsed={layout.notesCollapsed}>
|
||||
{#if layout.notesCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.show")}
|
||||
aria-label={t("notes.show")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<NotebookPen size={14} aria-hidden="true" />
|
||||
{t("notes.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.hide")}
|
||||
aria-label={t("notes.hide")}
|
||||
>
|
||||
<PanelRightClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
<textarea
|
||||
class="live-notes"
|
||||
value={recording.notesText}
|
||||
oninput={(e) => recording.setNotesText((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder={t("notes.live_placeholder")}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -328,9 +626,103 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.segment {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.segment-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.segment-line:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.segment-line.active {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.segment-line.interim {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
}
|
||||
.note-badge {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
/* Finalized-transcript line: a click-to-seek button styled to read as plain
|
||||
transcript text, highlighted while it's the segment currently playing. */
|
||||
.seg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.seg:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.seg.active {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
/* Transcript timestamp: a quiet monospace prefix, not competing with the
|
||||
speaker name or text for attention. */
|
||||
.ts {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.78em;
|
||||
color: var(--muted);
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
.segment-note-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0.2rem 0 0.4rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.live-notes {
|
||||
resize: none;
|
||||
width: 100%;
|
||||
height: calc(100% - 2rem);
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.live-notes:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
/* grid-template-columns set inline — depends on resize/collapse state (FR-UX-1). */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -342,6 +734,12 @@
|
||||
.pane.transcript {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.pane.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.pane h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -353,6 +751,25 @@
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pane-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.pane-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.pane-toggle.inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Transcription language (T8.7, FR-TRX-4) — a quiet pill, not a status
|
||||
color, since "which language" isn't a good/bad state to flag. */
|
||||
.badge.lang {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
// The WebView2 default right-click menu (Back/Forward/Reload/Inspect) doesn't
|
||||
// belong in a native-feeling desktop app — disabled app-wide until/unless a
|
||||
// WhispAssist-specific context menu replaces it (see project memory).
|
||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app;
|
||||
|
||||
Reference in New Issue
Block a user