Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f3d76ed0 | ||
|
|
19bd5815c0 | ||
|
|
a552ddb1fa | ||
|
|
de25ac70e7 | ||
|
|
28d9b2988a | ||
|
|
f24adc3a59 | ||
|
|
94425d456c | ||
|
|
49cdbd6a54 | ||
|
|
b4d72622da | ||
|
|
c1ccdce497 | ||
|
|
a319dd9e97 | ||
|
|
6a01afbba6 | ||
|
|
8c6a0ea25c | ||
|
|
4a000f07a2 | ||
|
|
0e2a3bb110 | ||
|
|
95b3d34713 | ||
|
|
588354f488 | ||
|
|
ee08f2bc88 | ||
|
|
a71c17965f | ||
|
|
3c73ec1cd4 | ||
|
|
8f805b8ef5 | ||
|
|
f83234bde1 | ||
|
|
d4b1578478 | ||
|
|
185e4ec803 | ||
|
|
30f4a4d3ec | ||
|
|
86c3587d19 | ||
|
|
d736b78ecb | ||
|
|
98f6fcdc16 | ||
|
|
683838bef1 | ||
|
|
c63cabaf88 | ||
|
|
fe355f034d | ||
|
|
e0e9377273 | ||
|
|
a95eafe952 | ||
|
|
5b9e0ed1f7 | ||
|
|
6c343c4204 | ||
|
|
7d16c864e8 | ||
|
|
e461d4eb22 | ||
|
|
b14728e1c8 | ||
|
|
b7e6f54d91 | ||
|
|
cf919f1499 | ||
|
|
c616e0bfec | ||
|
|
ecbe3e464c | ||
|
|
7f6c0fac81 | ||
|
|
6fa9916f7e | ||
|
|
d78a426a7e | ||
|
|
1b36df020a | ||
|
|
4af008da0f | ||
|
|
223dc5f34b | ||
|
|
27773a03e5 | ||
|
|
203e7b1bf6 | ||
|
|
a6e1728346 | ||
|
|
26808970df | ||
|
|
4a63b38ee0 | ||
|
|
5d6563231c | ||
|
|
2edd0382d3 | ||
|
|
1530bca976 | ||
|
|
55c5143de0 | ||
|
|
47071b5861 | ||
|
|
ded5e00750 | ||
|
|
2fa3ea6824 | ||
|
|
14300fccee |
@@ -138,10 +138,15 @@ during setup).
|
||||
On first run WA detects your hardware and picks the best transcription backend
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**). It works on your **CPU or GPU** (GPU via Vulkan) out of the
|
||||
box; to use an Intel **NPU**, open **Settings ▸ Hardware** and download the one-time NPU
|
||||
acceleration package. The app runs without admin rights, never adds itself to startup, and keeps all
|
||||
data under `%LOCALAPPDATA%\WhispAssist`.
|
||||
acceleration package. The app runs without admin rights, does not add itself to startup unless you
|
||||
opt in (**Settings ▸ Recording ▸ Launch at login**), and keeps all data under
|
||||
`%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
Prefer the NSIS installer? Grab **`WhispAssist_<version>_x64-setup.exe`** from the same page.
|
||||
Prefer the NSIS installer? Grab **`WhispAssist_<version>_x64-setup.exe`** from the same page — it
|
||||
lets you choose a **current-user** (no admin) or **all-users** install.
|
||||
|
||||
**Deploying to many machines?** See [`docs/enterprise-deployment.md`](docs/enterprise-deployment.md)
|
||||
for silent install, custom install location, and presetting defaults with a `wa-defaults.ini` file.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
@@ -155,7 +160,7 @@ If you do not have these installed, WhispAssist will still work, but some featur
|
||||
|
||||
WhispAssist is a **Tauri 2** application: a small Rust core with a compiled **Svelte + TypeScript**
|
||||
frontend rendered through the OS WebView2 (no bundled browser → low idle memory). Every decision
|
||||
and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0011).
|
||||
and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0012).
|
||||
|
||||
- **Shell / IPC:** Tauri 2 (Rust ⇄ WebView2)
|
||||
- **Audio capture:** WASAPI loopback
|
||||
@@ -176,7 +181,7 @@ and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0011).
|
||||
WhispAssist/
|
||||
├── docs/ # The engineering plan (read this first)
|
||||
│ ├── 00-overview.md … 07-research-findings.md
|
||||
│ └── adr/ Architecture Decision Records (0001–0011)
|
||||
│ └── adr/ Architecture Decision Records (0001–0012)
|
||||
├── src-tauri/ # Rust core — implemented service modules:
|
||||
│ └── src/{audio,transcription,diarization,storage,llm,calendar,
|
||||
│ hardware,notes,sync,mcp,vault}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# WhispAssist v0.5.0
|
||||
|
||||
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
|
||||
|
||||
This release is about **living with your recordings**: play back a meeting while following along in
|
||||
the transcript, manage action items by hand, move recordings between computers, drop a meeting into
|
||||
your Obsidian vault, and — new in this release — **use WhispAssist in your own language**. Everything
|
||||
stays off-by-default and local-first.
|
||||
|
||||
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
|
||||
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
### Click the transcript to play that moment
|
||||
The recording player and the transcript now talk to each other. **Click any transcript line to jump
|
||||
the audio to that moment** and start playing, and as playback runs the **current line highlights and
|
||||
scrolls into view** so you never lose your place. If you scroll by hand, auto-scroll steps aside for
|
||||
a few seconds so it doesn't fight you.
|
||||
|
||||
### Interface language selector (i18n)
|
||||
WhispAssist can now be **fully translated**. A new **Settings ▸ Language** picker switches the
|
||||
interface language, English ships as the baseline, and **every** user-facing string across the app —
|
||||
the shell, meetings list, transcript/notes, summary panel, and all of Settings — now flows through a
|
||||
single translation layer. Adding a new language is as simple as translating **one JSON file**; no
|
||||
code changes. (This release ships English; the groundwork is in place for community translations.)
|
||||
|
||||
### Manage action items yourself
|
||||
Action items are no longer just whatever the summary extracted. You can now **add, edit, and delete
|
||||
them by hand** in the summary panel, set an owner and due date, and toggle a local reminder. Your
|
||||
edits are the source of truth and are reconciled cleanly — deleting an item also cancels its reminder.
|
||||
|
||||
### Move recordings between computers (Export / Import)
|
||||
A new **Export & import** section in **Settings ▸ Storage** writes each meeting as a portable
|
||||
**bundle folder** (audio, transcript, notes, summary, and a `meeting.json` manifest) and imports them
|
||||
back on another machine. Imported meetings get a fresh id, so re-importing never overwrites anything.
|
||||
Export to any folder — a synced drive, a USB stick, or a sync target's local mount — and carry it across.
|
||||
|
||||
### Export a meeting to Obsidian
|
||||
A new **Obsidian** export writes a single self-contained vault note — YAML frontmatter
|
||||
(title, date, duration, participants, tags) plus notes, summary, action items, and a timestamped
|
||||
transcript — **without the audio**. Drop it in your vault and the transcript is fully searchable.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Also new since v0.4.0
|
||||
|
||||
- **Per-segment transcript timestamps** — every line now shows a quiet `m:ss` (or `h:mm:ss`) time
|
||||
prefix, in both the live and finalized views.
|
||||
- **Auto-resync on edit** — when sync is enabled, editing a meeting's notes, summary, transcript,
|
||||
tags, or action items **re-uploads just the changed artifacts** automatically (deduped by hash, so
|
||||
an unchanged save uploads nothing). Off unless sync is configured.
|
||||
|
||||
## 🐛 Fixes & polish
|
||||
- Transcript scroll-intent handling refined so playback auto-scroll never yanks you back while you're
|
||||
reading.
|
||||
- Import preserves each meeting's original date rather than stamping the import time.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Install
|
||||
|
||||
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
|
||||
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
|
||||
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
|
||||
|
||||
1. Download **`WhispAssist_0.5.0_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.5.0_x64-setup.exe`**).
|
||||
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
|
||||
3. Launch **WhispAssist** from the Start menu.
|
||||
|
||||
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
|
||||
without admin rights, never adds itself to startup, and keeps all data under
|
||||
`%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
## 🔐 Checksums (SHA-256)
|
||||
|
||||
```
|
||||
b423feed1a1171384e46c5e0b5aa63cbf912bfe8f29cc89a5a027bb15358075c WhispAssist_0.5.0_x64_en-US.msi
|
||||
2dce26b5603a5d02094533c594d814a3d72e05512e1d735ea69e1e309f15b710 WhispAssist_0.5.0_x64-setup.exe
|
||||
```
|
||||
|
||||
Verify after download:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\WhispAssist_0.5.0_x64_en-US.msi -Algorithm SHA256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy, unchanged
|
||||
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
|
||||
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
|
||||
MCP server is loopback-only and adds no egress. The reachable-host allowlist is derived from your
|
||||
settings and enforced in the core.
|
||||
@@ -1,73 +0,0 @@
|
||||
# WhispAssist v0.5.1
|
||||
|
||||
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
|
||||
|
||||
v0.5.0 put the **i18n groundwork** in place. v0.5.1 fills it in: WhispAssist now ships in
|
||||
**20 languages**, so you can run the whole app — the shell, meetings list, transcript/notes, summary
|
||||
panel, and every corner of Settings — in your own language. Everything stays off-by-default and
|
||||
local-first; this is a UI-language release with no change to what leaves your device (nothing, by default).
|
||||
|
||||
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
|
||||
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
### 20 interface languages
|
||||
Pick your language under **Settings ▸ Language**. Alongside English, this release adds full
|
||||
translations for:
|
||||
|
||||
- **Arabic** (العربية) · **Bengali** (বাংলা) · **German** (Deutsch)
|
||||
- **Spanish** — Spain (Español, España) and **Mexico** (Español, México)
|
||||
- **Finnish** (Suomi) · **French** — France (Français, France) and **Canada** (Français, Canada)
|
||||
- **Hindi** (हिन्दी) · **Korean** (한국어) · **Norwegian Bokmål** (Norsk bokmål)
|
||||
- **Portuguese** — Brazil (Português, Brasil) and **Portugal** (Português, Portugal)
|
||||
- **Russian** (Русский) · **Sinhala** (සිංහල) · **Swedish** (Svenska)
|
||||
- **Tamil** (தமிழ்) · **Urdu** (اردو) · **Mandarin Chinese, Simplified** (中文简体)
|
||||
|
||||
Your choice persists across launches, and any untranslated string quietly falls back to English rather
|
||||
than showing a raw key — so partial translations degrade gracefully.
|
||||
|
||||
### Right-to-left layout
|
||||
Selecting **Arabic** or **Urdu** flips the whole interface to **right-to-left**, so those languages
|
||||
read and lay out correctly rather than being crammed into an LTR shell.
|
||||
|
||||
Adding a further language remains a one-file job — drop in a single JSON translation, no code changes.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Install
|
||||
|
||||
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
|
||||
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
|
||||
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
|
||||
|
||||
1. Download **`WhispAssist_0.5.1_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.5.1_x64-setup.exe`**).
|
||||
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
|
||||
3. Launch **WhispAssist** from the Start menu.
|
||||
|
||||
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
|
||||
without admin rights, never adds itself to startup, and keeps all data under
|
||||
`%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
## 🔐 Checksums (SHA-256)
|
||||
|
||||
```
|
||||
911618e6996a079bad1cbe46731c89ee247f3f5f17c09b60091d7e6a5e7cbef0 WhispAssist_0.5.1_x64_en-US.msi
|
||||
ffabbd74def981da176931349b2813a3927d7376c0d18e9d191891abfc17c385 WhispAssist_0.5.1_x64-setup.exe
|
||||
```
|
||||
|
||||
Verify after download:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\WhispAssist_0.5.1_x64_en-US.msi -Algorithm SHA256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy, unchanged
|
||||
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
|
||||
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
|
||||
MCP server is loopback-only and adds no egress. The reachable-host allowlist is derived from your
|
||||
settings and enforced in the core.
|
||||
@@ -1,64 +0,0 @@
|
||||
# WhispAssist v0.5.2
|
||||
|
||||
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
|
||||
|
||||
A small but important bug-fix release. v0.5.2 fixes a startup crash that could stop WhispAssist
|
||||
from opening after an upgrade, and makes any future database problem show a clear message instead
|
||||
of failing silently. No feature or privacy changes — everything optional stays off-by-default and
|
||||
local-first.
|
||||
|
||||
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
|
||||
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Fixes
|
||||
|
||||
### Fixed: app failing to launch after an upgrade
|
||||
On some machines, upgrading could leave the app unable to open at all — no window, no message,
|
||||
just nothing. The cause was a database-migration mismatch during startup: WhispAssist would hit
|
||||
the error while opening its local database (`wa.db`) and, because the failure happened before the
|
||||
window existed, the process exited silently and Windows reported only a generic crash.
|
||||
|
||||
### Startup database errors now explain themselves
|
||||
Instead of that silent exit, a failure to open `wa.db` now shows a **native error dialog** naming
|
||||
the problem and pointing at the database location, then exits cleanly. Your recordings and notes
|
||||
are never touched — the message tells you exactly what happened and how to recover, rather than
|
||||
leaving you with an app that won't start.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Install
|
||||
|
||||
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
|
||||
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
|
||||
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
|
||||
|
||||
1. Download **`WhispAssist_0.5.2_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.5.2_x64-setup.exe`**).
|
||||
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
|
||||
3. Launch **WhispAssist** from the Start menu.
|
||||
|
||||
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
|
||||
without admin rights, never adds itself to startup, and keeps all data under
|
||||
`%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
## 🔐 Checksums (SHA-256)
|
||||
|
||||
```
|
||||
b173b6b15590a65e96d3b24d0ad97666b8d1da905169d807ecc1c51df0584d3e WhispAssist_0.5.2_x64_en-US.msi
|
||||
441b2e7ec7c68be39f9d2381af59ab9eadf916d6d23d0c5541d78bdd88c588b2 WhispAssist_0.5.2_x64-setup.exe
|
||||
```
|
||||
|
||||
Verify after download:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\WhispAssist_0.5.2_x64_en-US.msi -Algorithm SHA256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy, unchanged
|
||||
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
|
||||
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
|
||||
MCP server is loopback-only and adds no egress. The reachable-host allowlist is derived from your
|
||||
settings and enforced in the core.
|
||||
@@ -0,0 +1,78 @@
|
||||
# WhispAssist v0.7.0
|
||||
|
||||
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
|
||||
|
||||
This release is about **control over install and startup** — for a single user and for admins rolling
|
||||
WhispAssist out across a fleet. You can now launch WhispAssist at login, choose a per-user (no-admin)
|
||||
or all-users install, preset every default with a deployment file, and re-scan your hardware without
|
||||
restarting. No feature here changes the privacy posture: everything optional stays off-by-default and
|
||||
local-first.
|
||||
|
||||
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
|
||||
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ New
|
||||
|
||||
### Launch at login (opt-in)
|
||||
A new **Settings ▸ Recording ▸ Launch WhispAssist at login** toggle starts WhispAssist when you sign
|
||||
in to Windows. It's **off by default**, needs **no admin** (a per-user startup entry), and does **not**
|
||||
begin recording on its own.
|
||||
|
||||
### Choose a per-user or all-users install
|
||||
The NSIS setup (`WhispAssist_0.7.0_x64-setup.exe`) now lets you install for **just yourself**
|
||||
(no admin rights required) or for **all users**. The MSI remains the per-machine, all-users installer.
|
||||
|
||||
### Customize deployments with a file (`wa-defaults.ini`)
|
||||
Admins can preset WhispAssist's defaults for every machine using native Windows tooling (Group Policy,
|
||||
SCCM, Intune, `msiexec`) — no management console. Drop a `wa-defaults.ini` next to the installer or in
|
||||
`%PROGRAMDATA%\WhispAssist\`, and each machine's **first launch** seeds its settings from it: record-
|
||||
by-default, preferred backend, a model to auto-download, retention, storage location, AI provider, and
|
||||
more. You can also set a **custom install location** with standard `msiexec INSTALLDIR=…` / NSIS `/D=`
|
||||
flags. **Secrets are never read from this file** — API keys and tokens stay in the OS credential store.
|
||||
Full key reference and silent-install examples in
|
||||
[`docs/enterprise-deployment.md`](docs/enterprise-deployment.md).
|
||||
|
||||
### Refresh hardware
|
||||
A **Refresh** button in **Settings ▸ Hardware** re-detects your GPU/NPU on the spot — handy after
|
||||
installing a driver or plugging in an eGPU — no restart needed.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Install
|
||||
|
||||
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
|
||||
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
|
||||
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
|
||||
|
||||
1. Download **`WhispAssist_0.7.0_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.7.0_x64-setup.exe`**).
|
||||
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
|
||||
3. Launch **WhispAssist** from the Start menu.
|
||||
|
||||
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
|
||||
without admin rights, does **not** add itself to startup unless you opt in, and keeps all data under
|
||||
`%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
Deploying to many machines? See [`docs/enterprise-deployment.md`](docs/enterprise-deployment.md).
|
||||
|
||||
## 🔐 Checksums (SHA-256)
|
||||
|
||||
```
|
||||
730676580dbef5c4bb2c155f46a73a5791722139ab144a3fc0286e3a4449c432 WhispAssist_0.7.0_x64_en-US.msi
|
||||
6a34c16eb6f876fc92c7a79d37414b4fdd2c09f4f40873008149db36cd0a30aa WhispAssist_0.7.0_x64-setup.exe
|
||||
```
|
||||
|
||||
Verify after download:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\WhispAssist_0.7.0_x64_en-US.msi -Algorithm SHA256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy, unchanged
|
||||
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
|
||||
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
|
||||
MCP server is loopback-only and adds no egress; the deployment file never carries secrets. The
|
||||
reachable-host allowlist is derived from your settings and enforced in the core.
|
||||
@@ -0,0 +1,94 @@
|
||||
# WhispAssist v0.7.1
|
||||
|
||||
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
|
||||
|
||||
A quality-of-life release focused on **importing meetings, taking notes, and living in the
|
||||
background**. Adding a meeting from a file or link now runs without freezing the app and shows you
|
||||
exactly where it's up to; notes gained AI cleanup and quick formatting; and WhispAssist can now mute
|
||||
your mic mid-meeting and tuck itself into the system tray. No feature here changes the privacy
|
||||
posture: everything optional stays off-by-default and local-first.
|
||||
|
||||
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
|
||||
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ New
|
||||
|
||||
### Add a meeting — now in the background, with a progress tracker
|
||||
Importing a recording (a local audio/video file or a YouTube/streaming/direct URL) no longer blocks
|
||||
the app while it works. Click **Import** and the meeting appears in your list immediately with a
|
||||
**four-step progress tracker** — *Transcode → Transcribe → Identify speakers → Finalize* — where the
|
||||
current step pulses and finished steps show how long they took. A 25-minute video that used to lock
|
||||
the window for ~13 minutes now transcribes quietly in the background.
|
||||
|
||||
- **Pick the transcription model** right in the dialog, and see **"Transcribed with …"** on the
|
||||
finished meeting so you always know how it was produced.
|
||||
- **One-click links** to download `ffmpeg` and `yt-dlp` (still external, not bundled).
|
||||
- A failed import stays in your list marked **error** instead of vanishing.
|
||||
|
||||
### Better notes
|
||||
- **AI-enhance** (✨): turn rough notes into clean, structured notes using your local LLM, with a
|
||||
one-step **Undo**. Off unless you have a local model configured.
|
||||
- **Slash commands & a formatting toolbar**: type `/todo`, `/h1`, `/quote`, … or use the toolbar for
|
||||
headings, lists, checkboxes, quotes, and dividers.
|
||||
- **Fix:** notes no longer show stale text after re-transcribing a meeting — the pane updates in
|
||||
place, no restart needed.
|
||||
|
||||
### Mute your microphone — press **M**
|
||||
Mute/unmute the mic mid-meeting with the **M** key or the new mic button by the level meter. The mic
|
||||
channel goes silent (recording, live transcript, and meter) while system/loopback audio keeps
|
||||
capturing.
|
||||
|
||||
### Close to system tray
|
||||
Closing the window now **keeps WhispAssist running in the background** instead of quitting. Reopen it
|
||||
from the tray icon; the tray's **Quit** exits fully. On by default — toggle it in
|
||||
**Settings ▸ Recording ▸ Close to system tray**. (This release also fixes a bug that showed **two**
|
||||
WhispAssist icons in the tray — there's now just one.)
|
||||
|
||||
### Privacy & hardware odds and ends
|
||||
- **Vault lock card** in **Settings ▸ Privacy**: lock/unlock the encrypted store and change its
|
||||
password at a glance.
|
||||
- **Test your audio devices**: a live level meter for your mic and system audio, plus a test tone.
|
||||
- **Quick hardware stress test**: benchmark the available backends against your installed models and
|
||||
apply the fastest real-time combination.
|
||||
- Tidier recording header.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Install
|
||||
|
||||
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
|
||||
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
|
||||
(and **`yt-dlp`** for URLs) on your PATH — the Import dialog now links to both.
|
||||
|
||||
1. Download **`WhispAssist_0.7.1_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.7.1_x64-setup.exe`**).
|
||||
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
|
||||
3. Launch **WhispAssist** from the Start menu.
|
||||
|
||||
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
|
||||
without admin rights, does **not** add itself to startup unless you opt in, and keeps all data under
|
||||
`%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
Deploying to many machines? See [`docs/enterprise-deployment.md`](docs/enterprise-deployment.md).
|
||||
|
||||
## 🔐 Checksums (SHA-256)
|
||||
|
||||
```
|
||||
bd96a059db3658a9bee81161edc709cfc4741ed99d6c66a12194403b339f9369 WhispAssist_0.7.1_x64_en-US.msi
|
||||
711fa7df618c8fc3f03c18d543abad2288d56f750b532288204e0ec0f9426595 WhispAssist_0.7.1_x64-setup.exe
|
||||
```
|
||||
|
||||
Verify after download:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\WhispAssist_0.7.1_x64_en-US.msi -Algorithm SHA256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy, unchanged
|
||||
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
|
||||
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
|
||||
MCP server is loopback-only and adds no egress; the deployment file never carries secrets. The
|
||||
reachable-host allowlist is derived from your settings and enforced in the core.
|
||||
@@ -382,6 +382,13 @@ segment (the M1 grounding invariant, asserted by the golden-transcript test).
|
||||
"expose_recordings": false, // never serve .wav unless explicitly true (FR-MCP-3)
|
||||
},
|
||||
"privacy": { "encrypt_at_rest": false },
|
||||
// Launch WhispAssist at login (NFR-RES-4). Opt-in, OFF by default. Toggling this
|
||||
// via `set_auto_start` also writes a per-user `HKCU\...\Run` entry (no admin);
|
||||
// startup reconciles the OS entry to this flag (e.g. after a reinstall).
|
||||
"auto_start": false,
|
||||
// Closing the window hides WhispAssist to the system tray (keep running in background) instead of
|
||||
// quitting; ON by default. Tray "Quit" is the real exit. Enforced in the Rust on_window_event handler.
|
||||
"close_to_tray": true,
|
||||
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6). Opt-in, explicit consent via OAuth
|
||||
// PKCE — OFF by default. `credential_ref` points into the OS credential store; the token itself
|
||||
// is never written here (same invariant as sync credentials, FR-SYNC-6).
|
||||
@@ -389,6 +396,20 @@ segment (the M1 grounding invariant, asserted by the golden-transcript test).
|
||||
}
|
||||
```
|
||||
|
||||
## First-run deploy seeding (`wa-defaults.ini`)
|
||||
|
||||
For enterprise mass-deployment, the **first** launch on a machine (before `settings.json` exists)
|
||||
optionally seeds its defaults from an admin-supplied INI. First file found wins:
|
||||
|
||||
1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide (GPO / SCCM / Intune file copy).
|
||||
2. `<install dir>\wa-defaults.ini` — the bundled template (shipped fully commented → no-op).
|
||||
|
||||
Keys are flat `key = value` matching `settings.json` field names (bools/ints coerced), plus the
|
||||
special `auto_download_model = true` which fetches the configured `whisper_model` in the background.
|
||||
**No secrets** — any key containing `key`/`token`/`secret`/`credential`/`password` is ignored; those
|
||||
live only in the OS credential store. After first run the file is never read again. See
|
||||
`docs/enterprise-deployment.md` and `src-tauri/src/deploy.rs`.
|
||||
|
||||
## Retention & recovery semantics
|
||||
|
||||
- **Retention** (FR-STORE-2): a background job deletes whole meeting folders + rows once a meeting
|
||||
|
||||
@@ -23,6 +23,9 @@ start_recording(input: { meetingTitle?: string; calendarEventId?: string; record
|
||||
stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef
|
||||
pause_recording(input: { meetingId: MeetingId }): void
|
||||
resume_recording(input: { meetingId: MeetingId }): void
|
||||
// Mute/unmute the mic mid-meeting (FR-CAP-7): mic channel goes silent (recording + transcript + meter),
|
||||
// loopback keeps capturing. Returns the new muted state; emits recording://mic. Errs if the mic is off.
|
||||
toggle_microphone_mute(input: { meetingId: MeetingId }): boolean
|
||||
set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1)
|
||||
acknowledge_recording_consent(): void // one-time (FR-REC-2)
|
||||
|
||||
@@ -36,8 +39,14 @@ update_live_notes(input: { meetingId: MeetingId; markdown: string }): void
|
||||
set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void
|
||||
|
||||
// ---- Hardware ----
|
||||
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number }
|
||||
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number } // re-detects fresh each call — backs the Settings ▸ Hardware "Refresh" button
|
||||
set_preferred_backend(input: { backend: BackendId | "auto" }): void
|
||||
// Launch-at-login (NFR-RES-4). Writes/removes a per-user OS Run entry (no admin) and persists auto_start. Opt-in, off by default.
|
||||
set_auto_start(input: { enabled: boolean }): void
|
||||
// Device test: opens a mic ("input") or the render device in loopback ("loopback") for a few seconds and streams device://level (no recording, no retained audio). Refused while recording.
|
||||
monitor_audio_level(input: { kind: "input" | "loopback"; deviceId?: string; durationMs?: number }): void
|
||||
// Quick stress test: benchmarks each available backend × installed model (≤3 sizes) on a fixed sample, returns per-pair real-time factor + the most-accurate real-time-capable recommendation. Emits stress://progress. Refused while recording.
|
||||
stress_test_hardware(): { results: { backend: string; model: string; rtf: number; realtime: boolean }[]; recommended: { backend: string; model: string } | null }
|
||||
|
||||
// ---- Transcription / models ----
|
||||
// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it.
|
||||
@@ -79,6 +88,8 @@ export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf"
|
||||
// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an
|
||||
// edit that didn't alter a file uploads nothing.
|
||||
update_notes(input: { meetingId: MeetingId; markdown: string }): void
|
||||
// AI-enhance rough notes into structured Markdown grounded in the transcript (Granola-style), via the configured LlmProvider (no new egress). Takes the live buffer, returns the enhanced text WITHOUT persisting — the UI keeps or undoes it. Refused while recording; errors with no provider.
|
||||
enhance_notes(input: { meetingId: MeetingId; notes: string }): string
|
||||
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
|
||||
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
|
||||
set_tags(input: { meetingId: MeetingId; tags: string[] }): void
|
||||
@@ -93,6 +104,13 @@ bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "
|
||||
// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original
|
||||
// title/date/duration/speakers/tags/action items preserved). Returns the count imported.
|
||||
import_meeting_bundle(input: { dir: string }): number
|
||||
// Add a meeting from an existing recording: a local audio/video file path or a URL (YouTube/
|
||||
// streaming page or direct media URL). Needs ffmpeg (+ yt-dlp for URLs) on PATH; neither bundled.
|
||||
// `model` overrides the Settings whisper model for this import (recorded as meeting.model_used).
|
||||
// Returns the new meeting id IMMEDIATELY (status "transcribing"); transcode→transcribe→diarize→
|
||||
// finalize run in the background, streaming import://progress and ending with transcript://finalized.
|
||||
// A failed import is left in the list with status "error" (not deleted).
|
||||
import_media(input: { source: string; title?: string; model?: string }): MeetingId
|
||||
|
||||
// ---- LLM / AI provider (ADR-0007/0011) ----
|
||||
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
|
||||
@@ -183,8 +201,10 @@ privacy_self_check(): {
|
||||
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
|
||||
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
|
||||
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
|
||||
"recording://mic" { meetingId, muted: boolean } // mic mute toggled (FR-CAP-7)
|
||||
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2); may re-emit a committed segment with a refined `speaker` — replace by `segment.id`
|
||||
"transcript://finalized" { meetingId, segmentCount }
|
||||
"import://progress" { meetingId, phase: "prepare"|"transcribe"|"diarize"|"finalize", state: "active"|"done"|"error", elapsedMs: number|null, error: string|null } // background import_media tracker
|
||||
"diarization://updated" { meetingId, speakers: SpeakerInfo[] } // post-pass AND live 15s provisional passes (FR-SPK); carries "You" once the mic voiceprint matches
|
||||
"llm://token" { meetingId, text } // streamed summary (FR-LLM-4)
|
||||
"llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer
|
||||
@@ -193,6 +213,8 @@ privacy_self_check(): {
|
||||
"calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4)
|
||||
"calendar://progress" { processed, total } // MS Graph import (M4.4)
|
||||
"hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4)
|
||||
"device://level" { kind: "input"|"loopback", rms?, peak?, done?: boolean } // Settings device test meter; done=window ended
|
||||
"stress://progress" { backend: string, model: string } // quick stress test, per pairing benchmarked
|
||||
"recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3)
|
||||
"sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5
|
||||
"sync://done" { meetingId, targetId, uploaded: number, failed: number }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# ADR-0012 — Launch-at-login & enterprise deployment defaults
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-14
|
||||
- **Context source:** User request (2026-07-14) — auto-start on boot; customize an installation
|
||||
(install location, per-user/all-users, default settings) with native Windows tooling for
|
||||
mass-deployment.
|
||||
|
||||
## Context
|
||||
|
||||
Two related needs. (1) Users want WhispAssist to **launch automatically at login**. NFR-RES-4
|
||||
forbids adding WA to OS startup without explicit consent, so this must be opt-in. (2) An admin
|
||||
mass-deploying WA to many machines wants to **customize the deployment** — install location, whether
|
||||
it installs per-user (no admin) or all-users, and the app's default settings (record-by-default,
|
||||
preferred backend, a model to pre-download, retention, AI provider) — using **native Windows tools**
|
||||
(GPO / SCCM / Intune / `msiexec` / silent NSIS), not a bespoke management console.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Launch-at-login is opt-in, off by default.** A `set_auto_start` command uses
|
||||
`tauri-plugin-autostart` to write a **per-user** `HKCU\...\Run` entry (no admin) and persists an
|
||||
`auto_start` setting. Startup reconciles the OS entry to that flag (restores it after a reinstall).
|
||||
Nothing runs on a timer — this is a registry entry, not a background process (NFR-RES-1).
|
||||
2. **Install location & scope are native, no app code.**
|
||||
- Location: `msiexec INSTALLDIR=…` (MSI) / NSIS `/D=…` (silent).
|
||||
- Scope: NSIS `installMode: "both"` — the `.exe` setup lets the user choose **current-user
|
||||
(no admin)** or **all-users (admin)**. The MSI stays per-machine as the enterprise all-users
|
||||
artifact.
|
||||
3. **Default settings via a first-run `wa-defaults.ini`.** On a machine's **first** launch (before
|
||||
`settings.json` exists) WA reads an admin-supplied INI — `%PROGRAMDATA%\WhispAssist\wa-defaults.ini`
|
||||
first, else the bundled `<install dir>\wa-defaults.ini` — and seeds `settings.json` from it, with an
|
||||
optional `auto_download_model` to pre-fetch the model. The shipped template is fully commented, so
|
||||
a normal install is unaffected. This is deployable purely by copying a file with existing Windows
|
||||
management tooling; no WiX custom actions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** opt-in startup honors NFR-RES-4 with zero idle cost; install location/scope reuse the
|
||||
installers' native behavior (no custom code to maintain); one small INI + a first-run guard covers
|
||||
the whole deployment-customization surface and works for MSI, NSIS, and portable copies alike.
|
||||
- **Guardrail — no secrets in the deploy file (CLAUDE.md):** the INI must never carry credentials.
|
||||
`deploy.rs` drops any key containing `key`/`token`/`secret`/`credential`/`password` as defense in
|
||||
depth; API keys, OAuth tokens and sync passwords remain in the OS credential store only. Seeding a
|
||||
provider (e.g. `llm_provider=anthropic`) still requires the admin/user to provision its key
|
||||
separately — no new egress path is created by the file.
|
||||
- **Negative / care:** the seed runs only when `settings.json` is absent (truly first run); it does
|
||||
**not** re-apply on upgrade, matching "the user's own settings win thereafter." Array config merges
|
||||
in Tauri **replace** rather than append, so `wa-defaults.ini` must be listed in both
|
||||
`tauri.conf.json` and `tauri.vulkan.conf.json` bundle resources (the shipped build uses the latter).
|
||||
|
||||
## Revisit if
|
||||
|
||||
Admins need per-machine policy that **overrides** user settings on every launch (not just seeds
|
||||
first-run), or a signed/locked-down enterprise policy channel beyond a plain INI.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Enterprise deployment
|
||||
|
||||
How to mass-deploy WhispAssist and preset its defaults with native Windows tooling (Group Policy,
|
||||
SCCM, Intune, `msiexec`, silent NSIS). No management console, no phone-home. See ADR-0012.
|
||||
|
||||
WhispAssist ships two bundles:
|
||||
|
||||
| Bundle | Scope | Admin? |
|
||||
|---|---|---|
|
||||
| `WhispAssist_<ver>_x64_en-US.msi` | Per-machine (all users) | Yes |
|
||||
| `WhispAssist_<ver>_x64-setup.exe` (NSIS) | Current-user **or** all-users (prompts) | Only for all-users |
|
||||
|
||||
## Install location
|
||||
|
||||
- **MSI:** `msiexec /i WhispAssist_<ver>_x64_en-US.msi INSTALLDIR="D:\Apps\WhispAssist" /qn`
|
||||
(`INSTALLDIR` is Tauri's WiX install-dir property; confirm against the generated `.wxs` if a build
|
||||
changes it.)
|
||||
- **NSIS:** `WhispAssist_<ver>_x64-setup.exe /S /D=D:\Apps\WhispAssist`
|
||||
(`/S` = silent, `/D=` = install dir; `/D=` must be **last** and unquoted per NSIS.)
|
||||
|
||||
## Install scope (per-user vs all-users)
|
||||
|
||||
The NSIS `.exe` shows a "current user / all users" page. **Current user needs no admin** and installs
|
||||
under the user profile; **all users** requires elevation. Silent all-users:
|
||||
`WhispAssist_<ver>_x64-setup.exe /S`. The MSI is always per-machine (all-users) and requires admin.
|
||||
|
||||
## Auto-start at login
|
||||
|
||||
Off by default (NFR-RES-4). Turn it on for the user either in-app (Settings ▸ Recording ▸ *Launch
|
||||
WhispAssist at login*) or by presetting `auto_start = true` in `wa-defaults.ini` (below). It installs
|
||||
a **per-user** `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry — no admin, and it does
|
||||
**not** start recording on its own.
|
||||
|
||||
## Preset default settings — `wa-defaults.ini`
|
||||
|
||||
On a machine's **first** launch (before `settings.json` exists), WhispAssist reads an admin-supplied
|
||||
INI and seeds that user's `settings.json`. After that the user's own settings win and the file is
|
||||
ignored. First location found wins:
|
||||
|
||||
1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide. Deploy with a GPO/SCCM/Intune file copy.
|
||||
2. `<install dir>\wa-defaults.ini` — the template shipped next to the executable.
|
||||
|
||||
The shipped template is fully commented out, so a default install behaves as if it were absent.
|
||||
Uncomment and edit the keys you want to preset.
|
||||
|
||||
### Format
|
||||
|
||||
Flat `key = value`, one per line. `;` and `#` comment lines and `[section]` headers are ignored.
|
||||
`true`/`false` become switches, plain numbers become numbers, everything else is text. Unknown or
|
||||
misspelled keys are ignored.
|
||||
|
||||
> **Never put secrets in this file.** API keys, OAuth tokens and sync passwords live only in the OS
|
||||
> credential store. Any key containing `key`, `token`, `secret`, `credential` or `password` is
|
||||
> dropped on read. Presetting `llm_provider = anthropic` still requires the key to be provisioned
|
||||
> separately — the file adds no egress path.
|
||||
|
||||
### Keys
|
||||
|
||||
| Key | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `default_record` | true/false | Record every meeting by default (consent notice still applies). |
|
||||
| `preferred_backend` | auto\|npu\|nvidia\|amd\|intel\|cpu | Transcription backend. |
|
||||
| `whisper_model` | catalog id (e.g. `base.en-q5_1`) | Default transcription model. |
|
||||
| `auto_download_model` | true/false | Fetch `whisper_model` in the background on first launch. |
|
||||
| `whisper_language` | auto\|ISO-639-1 | Default language (multilingual model only). |
|
||||
| `low_overhead` | true/false | CPU + smallest model preset. |
|
||||
| `storage_root` | path | Where meetings are stored. |
|
||||
| `retention_max_age_days` | number | Delete meetings older than N days. |
|
||||
| `retention_max_size_gb` | number | Cap total storage at N GB. |
|
||||
| `llm_provider` | ollama\|custom\|anthropic\|off | Summary provider (key provisioned separately). |
|
||||
| `llm_endpoint` | url | LLM endpoint. |
|
||||
| `llm_model` | text | LLM model name. |
|
||||
| `microphone_enabled` | true/false | Capture the user's mic into the transcript. |
|
||||
| `auto_record_calendar` | true/false | Auto-start recording on calendar events (app open only). |
|
||||
| `theme` | system\|light\|dark | UI theme. |
|
||||
| `auto_start` | true/false | Launch WhispAssist at login (per-user Run entry). |
|
||||
| `sync_enabled` | true/false | Sync master switch (targets/credentials configured in-app). |
|
||||
|
||||
### Example
|
||||
|
||||
```ini
|
||||
default_record = true
|
||||
preferred_backend = npu
|
||||
whisper_model = small.en-q5_1
|
||||
auto_download_model = true
|
||||
retention_max_age_days = 90
|
||||
auto_start = true
|
||||
```
|
||||
|
||||
## Silent end-to-end example
|
||||
|
||||
```bat
|
||||
:: 1. Push machine-wide defaults (as SYSTEM via GPO/SCCM)
|
||||
mkdir "%ProgramData%\WhispAssist"
|
||||
copy wa-defaults.ini "%ProgramData%\WhispAssist\wa-defaults.ini"
|
||||
|
||||
:: 2. Install per-machine, custom location, no UI
|
||||
msiexec /i WhispAssist_<ver>_x64_en-US.msi INSTALLDIR="C:\Program Files\WhispAssist" /qn
|
||||
```
|
||||
|
||||
Each user's first launch then seeds their `settings.json` from the machine-wide file.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "whispassist",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.2",
|
||||
"type": "module",
|
||||
"description": "Privacy-first, fully local Windows meeting assistant.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
Generated
+57
-2
@@ -124,6 +124,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto-launch"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"thiserror 1.0.69",
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -848,6 +859,15 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||
dependencies = [
|
||||
"dirs-sys 0.3.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
@@ -866,6 +886,17 @@ dependencies = [
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users 0.4.6",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
@@ -1043,7 +1074,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4935,6 +4966,20 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-autostart"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
@@ -6043,7 +6088,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whispassist"
|
||||
version = "0.6.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
@@ -6073,6 +6118,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-dialog",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@@ -6767,6 +6813,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whispassist"
|
||||
version = "0.6.0"
|
||||
version = "0.7.2"
|
||||
description = "Privacy-first, fully local Windows meeting assistant"
|
||||
authors = ["WhispAssist contributors"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -81,6 +81,7 @@ ort = { version = "=2.0.0-rc.10", optional = true, default-features = false, fea
|
||||
rustfft = { version = "6", optional = true }
|
||||
sherpa-rs = { version = "0.6", optional = true, default-features = false, features = ["download-binaries"] } # sherpa-onnx bindings (Phase 4, ADR-0005)
|
||||
tauri-plugin-dialog = "2" # native Save/choose-folder (Phase 2 export)
|
||||
tauri-plugin-autostart = "2" # opt-in launch-on-login (per-user HKCU\Run, no admin; NFR-RES-4)
|
||||
|
||||
# notes export (Phase 8, FR-NOTE-4) — pure-Rust, no external binary/cloud
|
||||
# conversion service, consistent with the fully-local invariant.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -176,6 +176,48 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
|
||||
"type": "string",
|
||||
"const": "autostart:default",
|
||||
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-disable",
|
||||
"markdownDescription": "Enables the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-enable",
|
||||
"markdownDescription": "Enables the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-is-enabled",
|
||||
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-disable",
|
||||
"markdownDescription": "Denies the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-enable",
|
||||
"markdownDescription": "Denies the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-is-enabled",
|
||||
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -176,6 +176,48 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
|
||||
"type": "string",
|
||||
"const": "autostart:default",
|
||||
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-disable",
|
||||
"markdownDescription": "Enables the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-enable",
|
||||
"markdownDescription": "Enables the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-is-enabled",
|
||||
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-disable",
|
||||
"markdownDescription": "Denies the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-enable",
|
||||
"markdownDescription": "Denies the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-is-enabled",
|
||||
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -45,9 +45,26 @@ pub enum AudioError {
|
||||
pub struct CaptureHandle {
|
||||
running: Arc<AtomicBool>,
|
||||
paused: Arc<AtomicBool>,
|
||||
/// Mic-only (FR-CAP-7): when set, the microphone stream emits silence instead
|
||||
/// of real samples — the recording's mic-left channel and the live transcript
|
||||
/// go quiet, the meter drops to zero, while loopback keeps recording. Toggled
|
||||
/// live via `set_muted` (the "press M to mute" control).
|
||||
muted: Arc<AtomicBool>,
|
||||
thread: JoinHandle<Result<CaptureSummary, AudioError>>,
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
/// Mute/unmute this stream live. Only meaningful for the microphone capture.
|
||||
pub fn set_muted(&self, muted: bool) {
|
||||
self.muted.store(muted, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Whether this stream is currently muted.
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.muted.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where captured frames are delivered for live transcription: mono f32 @ 16kHz,
|
||||
/// bounded so a slow/absent consumer can never stall the capture thread.
|
||||
pub type FrameSink = SyncSender<Vec<f32>>;
|
||||
@@ -317,8 +334,10 @@ impl WasapiCapture {
|
||||
) -> Result<CaptureHandle, AudioError> {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
let muted = Arc::new(AtomicBool::new(false));
|
||||
let running_th = running.clone();
|
||||
let paused_th = paused.clone();
|
||||
let muted_th = muted.clone();
|
||||
let wav_path = wav_path.map(Path::to_path_buf);
|
||||
let device_id = device_id.map(str::to_string);
|
||||
|
||||
@@ -333,6 +352,7 @@ impl WasapiCapture {
|
||||
&event_sink,
|
||||
&running_th,
|
||||
&paused_th,
|
||||
&muted_th,
|
||||
bridge.as_ref(),
|
||||
voice_sample.as_ref(),
|
||||
split,
|
||||
@@ -344,6 +364,7 @@ impl WasapiCapture {
|
||||
Ok(CaptureHandle {
|
||||
running,
|
||||
paused,
|
||||
muted,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
@@ -610,6 +631,9 @@ fn capture_loop(
|
||||
event_sink: &EventSink,
|
||||
running: &AtomicBool,
|
||||
paused: &AtomicBool,
|
||||
// Mic-only live mute (FR-CAP-7): zeroes the decoded mic samples so the
|
||||
// recording, transcript, and meter all go silent while loopback continues.
|
||||
muted: &AtomicBool,
|
||||
bridge: Option<&Arc<MicBridge>>,
|
||||
voice_sample: Option<&Arc<VoiceSample>>,
|
||||
// FR-SPK: when true, the loopback WAV is stereo L=mic / R=loopback (the mic
|
||||
@@ -762,7 +786,14 @@ fn capture_loop(
|
||||
write_wav_bytes(w, &bytes, &session.format, &mic)?
|
||||
};
|
||||
}
|
||||
let mono = decode_mono_f32(&bytes, &session.format)?;
|
||||
let mut mono = decode_mono_f32(&bytes, &session.format)?;
|
||||
// Mic muted: replace the decoded samples with silence before anything
|
||||
// downstream sees them — the recording's mic channel, the bridge, the
|
||||
// transcript feed, the meter, and the voiceprint sample all go quiet.
|
||||
// Loopback (`is_loopback`) is never muted this way.
|
||||
if !is_loopback && muted.load(Ordering::Relaxed) {
|
||||
mono.iter_mut().for_each(|s| *s = 0.0);
|
||||
}
|
||||
|
||||
// Mic: feed the shared bridge (resampled to the loopback's rate) so the
|
||||
// loopback thread can fold it into the recording.
|
||||
|
||||
+679
-68
@@ -55,7 +55,7 @@ pub struct StartRecordingArgs {
|
||||
// `settings.json` per `docs/03-data-model.md`; meeting rows/files go through
|
||||
// `AppState.store` (Phase 2, `storage::SqliteStore`).
|
||||
|
||||
fn default_settings() -> Settings {
|
||||
pub(crate) fn default_settings() -> Settings {
|
||||
Settings {
|
||||
theme: "system".into(),
|
||||
storage_root: wa_root().display().to_string(),
|
||||
@@ -87,6 +87,8 @@ fn default_settings() -> Settings {
|
||||
mcp_port: 4849,
|
||||
mcp_expose: "none".into(),
|
||||
mcp_expose_recordings: false,
|
||||
auto_start: false,
|
||||
close_to_tray: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ pub(crate) fn load_settings() -> Settings {
|
||||
.unwrap_or_else(default_settings)
|
||||
}
|
||||
|
||||
fn save_settings(settings: &Settings) -> Result<(), WaError> {
|
||||
pub(crate) fn save_settings(settings: &Settings) -> Result<(), WaError> {
|
||||
let path = settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||
@@ -118,6 +120,32 @@ fn model_id_for(settings: &Settings) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The model id to *report* for a session on `backend`: the ONNX engine
|
||||
/// (NPU/DirectML) ignores the configured ggml model and always runs its own
|
||||
/// ONNX artifacts, so recording the ggml id would be a lie — e.g. a meeting
|
||||
/// shown as "medium.en-q5_0 · npu" actually transcribed with ONNX base.en.
|
||||
/// Mirrors the exact routing condition in `load_transcriber`; if the engine
|
||||
/// fails to load at runtime the worker falls back to whisper.cpp and the
|
||||
/// backend is corrected via `hardware://changed`, but this reported model
|
||||
/// isn't — acceptable for that rare failure path.
|
||||
fn effective_model_id(backend: BackendId, requested: &str) -> String {
|
||||
#[cfg(feature = "npu")]
|
||||
{
|
||||
use crate::hardware::{resolve_accel, AccelPath};
|
||||
use crate::transcription::onnx_models;
|
||||
if matches!(
|
||||
resolve_accel(backend),
|
||||
AccelPath::OnnxOpenVino | AccelPath::OnnxDirectML
|
||||
) && onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL)
|
||||
{
|
||||
return format!("{} (onnx)", onnx_models::DEFAULT_ONNX_MODEL);
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "npu"))]
|
||||
let _ = backend;
|
||||
requested.to_string()
|
||||
}
|
||||
|
||||
/// Picks the backend to transcribe with: "low overhead" always forces CPU;
|
||||
/// otherwise resolve the user's preferred backend (or auto-detect) against
|
||||
/// what's actually available (T3.2, T3.5).
|
||||
@@ -164,7 +192,22 @@ fn load_transcriber(
|
||||
if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) {
|
||||
let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL);
|
||||
match OnnxTranscriber::load(&dir, backend, language) {
|
||||
Ok(t) => return Ok((Box::new(t), backend)),
|
||||
Ok(t) => {
|
||||
// The definitive "is the accelerator actually engaged" line
|
||||
// (visible with `npm run tauri dev`): the encoder runs on
|
||||
// the EP below; the autoregressive decoder always runs on
|
||||
// the CPU EP, which is why Task Manager shows only short
|
||||
// periodic NPU spikes alongside sustained CPU load.
|
||||
tracing::info!(
|
||||
"transcription engine: ONNX {} (encoder EP: {}, decoder: CPU)",
|
||||
onnx_models::DEFAULT_ONNX_MODEL,
|
||||
match path {
|
||||
AccelPath::OnnxOpenVino => "OpenVINO/NPU",
|
||||
_ => "DirectML/GPU",
|
||||
}
|
||||
);
|
||||
return Ok((Box::new(t), backend));
|
||||
}
|
||||
Err(e) => tracing::warn!("ONNX engine load failed ({e}); falling back to CPU"),
|
||||
}
|
||||
} else {
|
||||
@@ -255,7 +298,8 @@ fn diarizer_from_installed_models() -> Option<SherpaDiarizer> {
|
||||
|
||||
/// Split attribution (FR-SPK, supersedes `phase3_attribute`): diarize the far
|
||||
/// side (right/loopback channel) into `Speaker N`, take "You" straight from
|
||||
/// left-channel (mic) voice activity, merge + assign + name. Shared by
|
||||
/// left-channel (mic) voice activity, attribute per channel
|
||||
/// (`diarization::assign_split`) + name. Shared by
|
||||
/// `stop_recording` and `reprocess_transcript`; both read it back from the file
|
||||
/// so they agree. `None` if the far-side pass fails (caller falls back).
|
||||
async fn attribute_split(
|
||||
@@ -266,16 +310,19 @@ async fn attribute_split(
|
||||
let diarizer_for_task = diarizer.clone();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
// Right channel = loopback/far side; diarize it alone (mic never in it).
|
||||
// Its VAD is the "was the far side talking at all" evidence assign_split
|
||||
// weighs against the mic channel.
|
||||
let far = crate::audio::read_wav_channel_16k(&wav_path, 1).map_err(|e| e.to_string())?;
|
||||
let far_vad = crate::audio::vad_spans(&far);
|
||||
let far_spans = diarizer_for_task
|
||||
.diarize_samples(far)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Left channel = mic; its voice activity is "You".
|
||||
let mic = crate::audio::read_wav_channel_16k(&wav_path, 0).map_err(|e| e.to_string())?;
|
||||
Ok::<_, String>((far_spans, crate::audio::vad_spans(&mic)))
|
||||
Ok::<_, String>((far_spans, far_vad, crate::audio::vad_spans(&mic)))
|
||||
})
|
||||
.await;
|
||||
let (far_spans, you_spans) = match result {
|
||||
let (far_spans, far_vad, you_spans) = match result {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("split attribution failed: {e}");
|
||||
@@ -286,6 +333,8 @@ async fn attribute_split(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
crate::diarization::assign_split(segments, &you_spans, &far_vad, &far_spans);
|
||||
// Merged span timeline, only for first-appearance naming order below.
|
||||
let mut spans: Vec<SpeakerSpan> = you_spans
|
||||
.iter()
|
||||
.map(|&(start_ms, end_ms)| SpeakerSpan {
|
||||
@@ -296,7 +345,6 @@ async fn attribute_split(
|
||||
.collect();
|
||||
spans.extend(far_spans);
|
||||
spans.sort_by_key(|s| s.start_ms);
|
||||
diarizer.assign(segments, &spans);
|
||||
// Uniform naming: "You" -> "You", far speakers -> "Speaker 2", "Speaker 3"….
|
||||
let labels = crate::diarization::voiceprint::first_appearance_order(&spans);
|
||||
Some(crate::diarization::voiceprint::build_name_map(&labels, "You"))
|
||||
@@ -572,7 +620,8 @@ pub async fn start_recording(
|
||||
let wav_path_for_diar = wav_path.clone();
|
||||
let segments_for_diar = segments.clone();
|
||||
let names_for_diar = speaker_names.clone();
|
||||
let voice_sample_for_diar = mic_voice_sample.clone();
|
||||
// Same condition as `audio_layout` below: mic on → split stereo file.
|
||||
let split_layout = settings.microphone_enabled;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// ponytail: reprocesses the whole recording-so-far each tick
|
||||
// rather than incremental/windowed segmentation — sherpa-onnx's
|
||||
@@ -595,6 +644,62 @@ pub async fn start_recording(
|
||||
break; // recording stopped (or a new one started) — nothing left to do
|
||||
}
|
||||
|
||||
// Split recording: the exact same channel-based attribution as
|
||||
// the post-stop pass — mic (left) is always "You", the far side
|
||||
// (right) is diarized alone. The old whole-mix pass + voiceprint
|
||||
// cosine match never reliably showed "You" live (clusters over
|
||||
// the summed mix reshuffle every tick and the match often missed
|
||||
// its threshold), so "You" only appeared after stop.
|
||||
let (speakers, changed) = if split_layout {
|
||||
let mut snapshot = segments_for_diar
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
if attribute_split(
|
||||
diarizer.clone(),
|
||||
wav_path_for_diar.clone(),
|
||||
&mut snapshot,
|
||||
)
|
||||
.await
|
||||
.map(|auto_names| {
|
||||
let mut names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Never overwrite a name already set (user rename or a
|
||||
// prior pass) — same guard as the post-stop pass.
|
||||
for (label, name) in auto_names {
|
||||
names.entry(label).or_insert(name);
|
||||
}
|
||||
})
|
||||
.is_none()
|
||||
{
|
||||
continue; // pass failed (already logged); retry next tick
|
||||
}
|
||||
let relabeled: HashMap<u64, String> = snapshot
|
||||
.into_iter()
|
||||
.map(|s| (s.id, s.speaker))
|
||||
.collect();
|
||||
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Snapshot prior labels so only segments whose speaker
|
||||
// actually changed this pass get re-emitted (ids are stable;
|
||||
// the frontend replaces by id). Segments committed while the
|
||||
// pass ran keep their placeholder until the next tick.
|
||||
let before: HashMap<u64, String> =
|
||||
segs.iter().map(|s| (s.id, s.speaker.clone())).collect();
|
||||
for seg in segs.iter_mut() {
|
||||
if let Some(speaker) = relabeled.get(&seg.id) {
|
||||
seg.speaker = speaker.clone();
|
||||
}
|
||||
}
|
||||
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let changed: Vec<TranscriptSegment> = segs
|
||||
.iter()
|
||||
.filter(|s| before.get(&s.id) != Some(&s.speaker))
|
||||
.cloned()
|
||||
.collect();
|
||||
(speaker_infos_from_segments(&segs, &names), changed)
|
||||
} else {
|
||||
// Summed recording (mic off): whole-signal pass. No mic
|
||||
// channel → no live "You" (the mic voice sample doesn't
|
||||
// exist either), matching the post-stop behavior.
|
||||
let diarizer_for_pass = diarizer.clone();
|
||||
let wav_path = wav_path_for_diar.clone();
|
||||
let spans = tauri::async_runtime::spawn_blocking(move || {
|
||||
@@ -612,39 +717,10 @@ pub async fn start_recording(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (speakers, changed) = {
|
||||
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Snapshot prior labels so only segments whose speaker
|
||||
// actually changed this pass get re-emitted (ids are stable;
|
||||
// the frontend replaces by id).
|
||||
let before: HashMap<u64, String> =
|
||||
segs.iter().map(|s| (s.id, s.speaker.clone())).collect();
|
||||
diarizer.assign(&mut segs, &spans);
|
||||
|
||||
// Live "You": match the mic voiceprint against this pass's
|
||||
// clusters. Clusters re-shuffle every tick so match every
|
||||
// tick; never overwrite a name already set (user rename or a
|
||||
// prior pass) — same guard as the post-stop pass.
|
||||
if let Some(voice_sample) = &voice_sample_for_diar {
|
||||
let mic_samples = voice_sample.samples();
|
||||
match crate::diarization::voiceprint::match_mic_speaker(
|
||||
&diarization_embedding_model_file(),
|
||||
&mic_samples,
|
||||
&wav_path_for_diar,
|
||||
&spans,
|
||||
) {
|
||||
Ok(auto_names) => {
|
||||
let mut names =
|
||||
names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (label, name) in auto_names {
|
||||
names.entry(label).or_insert(name);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("live voiceprint match failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let changed: Vec<TranscriptSegment> = segs
|
||||
.iter()
|
||||
@@ -680,7 +756,10 @@ pub async fn start_recording(
|
||||
transcription_worker,
|
||||
segments,
|
||||
active_backend,
|
||||
model_id,
|
||||
// Report the model the routed engine will actually run (the ONNX
|
||||
// engine ignores the configured ggml model), so the meeting's
|
||||
// "transcribed with … · npu" line is truthful (T3.4/T3.5).
|
||||
model_id: effective_model_id(backend, &model_id),
|
||||
language: language_state,
|
||||
diarizer,
|
||||
speaker_names,
|
||||
@@ -759,9 +838,17 @@ pub async fn stop_recording(
|
||||
let mut attributed = false;
|
||||
if session.audio_layout == "split" {
|
||||
if let Some(diarizer) = session.diarizer.clone() {
|
||||
if let Some(names) =
|
||||
if let Some(mut names) =
|
||||
attribute_split(diarizer, session.wav_path.clone(), &mut segments).await
|
||||
{
|
||||
// Renames made *during* the recording are user-authored — they
|
||||
// win over the automatic "You"/"Speaker N" defaults instead of
|
||||
// being silently dropped at finalize.
|
||||
if let Ok(user_names) = session.speaker_names.lock() {
|
||||
for (label, name) in user_names.iter() {
|
||||
names.insert(label.clone(), name.clone());
|
||||
}
|
||||
}
|
||||
speaker_names = names;
|
||||
attributed = true;
|
||||
}
|
||||
@@ -1047,6 +1134,43 @@ pub async fn resume_recording(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle the microphone mute state for the active recording (FR-CAP-7): the
|
||||
/// mic channel goes silent (recording + live transcript + meter) while loopback
|
||||
/// keeps capturing. Bound to the "M" key in the UI. Returns the new muted state.
|
||||
/// Errors if this meeting was started with the mic off (nothing to mute).
|
||||
#[tauri::command]
|
||||
pub async fn toggle_microphone_mute(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
) -> WaResult<bool> {
|
||||
let guard = state.session.lock().await;
|
||||
let session = guard
|
||||
.as_ref()
|
||||
.filter(|s| s.meeting_id == meeting_id)
|
||||
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
||||
let mic = session
|
||||
.mic_capture
|
||||
.as_ref()
|
||||
.ok_or_else(|| WaError::new("audio", "the microphone is off for this meeting"))?;
|
||||
let muted = !mic.is_muted();
|
||||
mic.set_muted(muted);
|
||||
drop(guard);
|
||||
let _ = app.emit(
|
||||
"recording://mic",
|
||||
serde_json::json!({ "meetingId": meeting_id, "muted": muted }),
|
||||
);
|
||||
crate::update_tray_tooltip(
|
||||
&app,
|
||||
if muted {
|
||||
"WhispAssist — recording (mic muted)"
|
||||
} else {
|
||||
"WhispAssist — recording"
|
||||
},
|
||||
);
|
||||
Ok(muted)
|
||||
}
|
||||
|
||||
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
|
||||
#[tauri::command]
|
||||
pub async fn set_recording_retention(
|
||||
@@ -1221,6 +1345,55 @@ async fn refresh_notes_and_notify(
|
||||
Ok(meeting.speakers)
|
||||
}
|
||||
|
||||
/// `notes.md` bakes display names into its `**Name:**` dialogue tags at
|
||||
/// finalize, so a post-meeting rename must rewrite them or the Notes pane
|
||||
/// (and every export) keeps the old name forever. Targeted tag replace, not
|
||||
/// a regenerate, so the user's own edits to notes.md are preserved.
|
||||
async fn rename_speaker_in_notes(
|
||||
state: &State<'_, AppState>,
|
||||
meeting_id: &MeetingId,
|
||||
old_name: &str,
|
||||
new_name: &str,
|
||||
) -> WaResult<()> {
|
||||
if old_name == new_name || new_name.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let meeting = state
|
||||
.store
|
||||
.get_meeting(meeting_id)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
let old_tag = format!("**{old_name}:**");
|
||||
if meeting.notes_markdown.contains(&old_tag) {
|
||||
let updated = meeting
|
||||
.notes_markdown
|
||||
.replace(&old_tag, &format!("**{new_name}:**"));
|
||||
state
|
||||
.store
|
||||
.update_notes(meeting_id, &updated)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A speaker's current name as notes.md renders it: display name, else the
|
||||
/// raw label. `None` if the meeting/speaker can't be read (nothing to rewrite).
|
||||
async fn current_speaker_name(
|
||||
state: &State<'_, AppState>,
|
||||
meeting_id: &MeetingId,
|
||||
label: &str,
|
||||
) -> Option<String> {
|
||||
let meeting = state.store.get_meeting(meeting_id).await.ok()?;
|
||||
let speaker = meeting.speakers.iter().find(|s| s.label == label)?;
|
||||
Some(
|
||||
speaker
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| label.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Name a speaker; applies to that speaker's past & future segments (T4.4,
|
||||
/// FR-SPK-2). Segments only ever carry the internal label ("S1"…) — never
|
||||
/// rewritten — so persisting the label→name mapping here is enough to cover
|
||||
@@ -1235,6 +1408,9 @@ pub async fn rename_speaker(
|
||||
label: String,
|
||||
name: String,
|
||||
) -> WaResult<()> {
|
||||
// Resolve the name notes.md currently shows *before* the rename lands, so
|
||||
// the finalized-meeting branch below can rewrite its dialogue tags.
|
||||
let old_name = current_speaker_name(&state, &meeting_id, &label).await;
|
||||
state
|
||||
.store
|
||||
.rename_speaker(&meeting_id, &label, &name)
|
||||
@@ -1270,6 +1446,9 @@ pub async fn rename_speaker(
|
||||
}
|
||||
None => {
|
||||
drop(guard);
|
||||
if let Some(old_name) = old_name {
|
||||
rename_speaker_in_notes(&state, &meeting_id, &old_name, &name).await?;
|
||||
}
|
||||
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
|
||||
}
|
||||
}
|
||||
@@ -1332,12 +1511,22 @@ pub async fn map_speaker_to_participant(
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
let old_name = current_speaker_name(&state, &meeting_id, &label).await;
|
||||
state
|
||||
.store
|
||||
.map_speaker_to_participant(&meeting_id, &label, &participant_id)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
|
||||
let speakers = refresh_notes_and_notify(&app, &state, &meeting_id).await?;
|
||||
// Rewrite notes.md's baked-in dialogue tags to the participant's name,
|
||||
// same as a free-text rename (the Notes pane must follow the Speakers pane).
|
||||
let new_name = speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone());
|
||||
if let (Some(old_name), Some(new_name)) = (old_name, new_name) {
|
||||
rename_speaker_in_notes(&state, &meeting_id, &old_name, &new_name).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1444,6 +1633,238 @@ pub async fn set_preferred_backend(args: SetPreferredBackendArgs) -> WaResult<()
|
||||
save_settings(&settings)
|
||||
}
|
||||
|
||||
/// Enable/disable launch-at-login (NFR-RES-4). Writes a per-user `HKCU\...\Run`
|
||||
/// entry via `tauri-plugin-autostart` (no admin) and persists the choice so the
|
||||
/// startup reconcile in `lib.rs` keeps the OS entry in sync after a reinstall.
|
||||
/// Opt-in only: nothing calls this unless the user toggles it (or an enterprise
|
||||
/// deploy file set `auto_start=true`).
|
||||
#[tauri::command]
|
||||
pub async fn set_auto_start(app: AppHandle, enabled: bool) -> WaResult<()> {
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
let manager = app.autolaunch();
|
||||
let res = if enabled {
|
||||
manager.enable()
|
||||
} else {
|
||||
manager.disable()
|
||||
};
|
||||
res.map_err(|e| WaError::new("autostart", e.to_string()))?;
|
||||
let mut settings = load_settings();
|
||||
settings.auto_start = enabled;
|
||||
save_settings(&settings)
|
||||
}
|
||||
|
||||
/// Live level meter for a device, without starting a recording (FR-CAP-5): open
|
||||
/// the selected mic (`"input"`) or the render device in loopback (`"loopback"`)
|
||||
/// for a few seconds and stream `device://level` events so Settings ▸ Hardware
|
||||
/// can show whether audio is coming through. Reuses the normal capture path;
|
||||
/// discards frames (no transcription, no retained audio). One capture at a time,
|
||||
/// so it refuses while a recording is active.
|
||||
/// ponytail: transient monitor handle; ceiling = one monitor at a time.
|
||||
#[tauri::command]
|
||||
pub async fn monitor_audio_level(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
kind: String,
|
||||
device_id: Option<String>,
|
||||
duration_ms: Option<u64>,
|
||||
) -> WaResult<()> {
|
||||
use crate::audio::{AudioCapture, CaptureEvent, WasapiCapture};
|
||||
if state.session.lock().await.is_some() {
|
||||
return Err(WaError::new(
|
||||
"audio",
|
||||
"stop the current recording before testing a device",
|
||||
));
|
||||
}
|
||||
let duration =
|
||||
std::time::Duration::from_millis(duration_ms.unwrap_or(6000).clamp(1000, 20_000));
|
||||
|
||||
// Bounded channels so a slow consumer can't stall capture; frames are dropped.
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
||||
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<CaptureEvent>(64);
|
||||
std::thread::spawn(move || frame_rx.into_iter().for_each(drop));
|
||||
|
||||
let app_ev = app.clone();
|
||||
let kind_ev = kind.clone();
|
||||
std::thread::spawn(move || {
|
||||
for event in event_rx {
|
||||
if let CaptureEvent::Level(level) = event {
|
||||
let _ = app_ev.emit(
|
||||
"device://level",
|
||||
serde_json::json!({ "kind": kind_ev, "rms": level.rms, "peak": level.peak }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// `start` (loopback) needs a WAV path; write to a temp file and delete it after.
|
||||
let tmp_wav = std::env::temp_dir().join(format!("wa-monitor-{}.wav", now_unix()));
|
||||
let handle = match kind.as_str() {
|
||||
"input" => WasapiCapture.start_microphone(device_id.as_deref(), frame_tx, event_tx),
|
||||
"loopback" => WasapiCapture.start(&tmp_wav, device_id.as_deref(), frame_tx, event_tx),
|
||||
_ => return Err(WaError::new("audio", "kind must be 'input' or 'loopback'")),
|
||||
}
|
||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
std::thread::sleep(duration);
|
||||
let _ = WasapiCapture.stop(handle);
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||
|
||||
if kind == "loopback" {
|
||||
let _ = std::fs::remove_file(&tmp_wav);
|
||||
}
|
||||
let _ = app.emit(
|
||||
"device://level",
|
||||
serde_json::json!({ "kind": kind, "done": true }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One row of the quick hardware stress test.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct StressResult {
|
||||
pub backend: String,
|
||||
pub model: String,
|
||||
pub rtf: f64,
|
||||
pub realtime: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct StressRecommendation {
|
||||
pub backend: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct StressTestResult {
|
||||
pub results: Vec<StressResult>,
|
||||
pub recommended: Option<StressRecommendation>,
|
||||
}
|
||||
|
||||
/// Real-time recommendation: among rows that keep up with live speech
|
||||
/// (`rtf < 1`), prefer the **largest** model (most accurate), breaking ties by
|
||||
/// the **lowest** rtf (most headroom). `sizes` maps model id → size_mb (an
|
||||
/// accuracy proxy). `None` when nothing runs in real time.
|
||||
fn pick_realtime_recommendation(
|
||||
results: &[StressResult],
|
||||
sizes: &HashMap<String, u32>,
|
||||
) -> Option<StressRecommendation> {
|
||||
results
|
||||
.iter()
|
||||
.filter(|r| r.realtime)
|
||||
.max_by(|a, b| {
|
||||
let sa = sizes.get(&a.model).copied().unwrap_or(0);
|
||||
let sb = sizes.get(&b.model).copied().unwrap_or(0);
|
||||
sa.cmp(&sb)
|
||||
.then(b.rtf.partial_cmp(&a.rtf).unwrap_or(std::cmp::Ordering::Equal))
|
||||
})
|
||||
.map(|r| StressRecommendation {
|
||||
backend: r.backend.clone(),
|
||||
model: r.model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Quick hardware stress test (FR-HW): benchmark each available backend against
|
||||
/// the installed whisper models on a fixed sample, measure real-time factor
|
||||
/// (elapsed / audio seconds), and recommend the most accurate model that still
|
||||
/// keeps up with live speech. Heavy (loads + runs each model) but explicit and
|
||||
/// progress-reported; runs off the async thread.
|
||||
/// ponytail: benchmarks installed models only, capped to 3 sizes.
|
||||
#[tauri::command]
|
||||
pub async fn stress_test_hardware(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> WaResult<StressTestResult> {
|
||||
if state.session.lock().await.is_some() {
|
||||
return Err(WaError::new(
|
||||
"hardware",
|
||||
"stop the current recording before running the stress test",
|
||||
));
|
||||
}
|
||||
|
||||
// Fixed ~10s 16kHz mono sample. RTF timing is ~content-independent, so a
|
||||
// synthetic quiet tone is enough — no bundled speech clip needed.
|
||||
const SAMPLE_SECS: f64 = 10.0;
|
||||
let n = (16_000.0 * SAMPLE_SECS) as usize;
|
||||
let samples: Vec<f32> = (0..n).map(|i| (i as f32 * 0.05).sin() * 0.1).collect();
|
||||
let wav = std::env::temp_dir().join("wa-stress-sample.wav");
|
||||
crate::audio::write_wav_mono_16k(&wav, &samples)
|
||||
.map_err(|e| WaError::new("hardware", e.to_string()))?;
|
||||
|
||||
let backends: Vec<BackendId> = WinHardwareDetector
|
||||
.detect()
|
||||
.into_iter()
|
||||
.filter(|b| b.available)
|
||||
.map(|b| b.id)
|
||||
.collect();
|
||||
|
||||
// Installed models, smallest → largest, capped to bound runtime.
|
||||
let mut models: Vec<(String, u32)> = model_catalog::list("")
|
||||
.into_iter()
|
||||
.filter(|m| m.installed)
|
||||
.map(|m| (m.id, m.size_mb))
|
||||
.collect();
|
||||
models.sort_by_key(|(_, sz)| *sz);
|
||||
models.truncate(3);
|
||||
let sizes: HashMap<String, u32> = models.iter().cloned().collect();
|
||||
|
||||
if backends.is_empty() || models.is_empty() {
|
||||
let _ = std::fs::remove_file(&wav);
|
||||
return Err(WaError::new(
|
||||
"hardware",
|
||||
"no installed whisper model to benchmark — download one in Settings first",
|
||||
));
|
||||
}
|
||||
|
||||
let wav_for_task = wav.clone();
|
||||
let app_for_task = app.clone();
|
||||
let results = tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut out: Vec<StressResult> = Vec::new();
|
||||
for backend in &backends {
|
||||
for (model_id, _size) in &models {
|
||||
let path = whisper_model_file(model_id);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let _ = app_for_task.emit(
|
||||
"stress://progress",
|
||||
serde_json::json!({ "backend": backend.as_str(), "model": model_id }),
|
||||
);
|
||||
let start = std::time::Instant::now();
|
||||
let ok = match load_transcriber(*backend, &path, None) {
|
||||
Ok((t, _used)) => t.transcribe_file(&wav_for_task).is_ok(),
|
||||
Err(e) => {
|
||||
tracing::warn!("stress test load failed ({backend:?}/{model_id}): {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
let rtf = start.elapsed().as_secs_f64() / SAMPLE_SECS;
|
||||
out.push(StressResult {
|
||||
backend: backend.as_str().to_string(),
|
||||
model: model_id.clone(),
|
||||
rtf,
|
||||
realtime: rtf < 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WaError::new("hardware", e.to_string()))?;
|
||||
|
||||
let _ = std::fs::remove_file(&wav);
|
||||
let recommended = pick_realtime_recommendation(&results, &sizes);
|
||||
Ok(StressTestResult {
|
||||
results,
|
||||
recommended,
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the NPU ONNX Whisper model is downloaded (false on non-NPU builds).
|
||||
fn npu_model_installed() -> bool {
|
||||
#[cfg(feature = "npu")]
|
||||
@@ -1938,7 +2359,7 @@ pub async fn reprocess_transcript(
|
||||
recorded: meeting.recorded,
|
||||
language: resolved_language,
|
||||
backend_used: Some(backend.as_str().to_string()),
|
||||
model_used: Some(model),
|
||||
model_used: Some(effective_model_id(backend, &model)),
|
||||
audio_layout: None, // reprocess preserves the recorded layout
|
||||
},
|
||||
)
|
||||
@@ -1978,21 +2399,49 @@ fn default_title_from_source(source: &str) -> Option<String> {
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Emit one `import://progress` tick for the Domino's-tracker UI. `state` is
|
||||
/// `active` (this phase is running), `done` (finished; `elapsed_ms` set), or
|
||||
/// `error` (`message` set). `phase` is one of prepare/transcribe/diarize/finalize.
|
||||
fn emit_import_progress(
|
||||
app: &AppHandle,
|
||||
meeting_id: &str,
|
||||
phase: &str,
|
||||
state: &str,
|
||||
elapsed_ms: Option<u128>,
|
||||
message: Option<&str>,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
"import://progress",
|
||||
serde_json::json!({
|
||||
"meetingId": meeting_id,
|
||||
"phase": phase,
|
||||
"state": state,
|
||||
"elapsedMs": elapsed_ms.map(|m| m as u64),
|
||||
"error": message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Manually add a meeting from an existing recording: a local audio/video file
|
||||
/// or a URL (YouTube/streaming page, or a direct media URL). Shells out to
|
||||
/// `ffmpeg` (transcode) and — for URLs — `yt-dlp` (both external, not bundled;
|
||||
/// a missing tool is a clear error). The produced 16kHz-mono WAV becomes the
|
||||
/// meeting's retained `audio.wav`, then goes through the same
|
||||
/// transcription + diarization + finalize path as a live recording.
|
||||
/// or a URL (YouTube/streaming page, or a direct media URL). Creates the meeting
|
||||
/// row (status `transcribing`) and returns its id **immediately**; the heavy
|
||||
/// transcode → transcribe → diarize → finalize work runs detached so the UI is
|
||||
/// never blocked (a 25-min video can take 10+ min). Progress streams via
|
||||
/// `import://progress` and completion via `transcript://finalized`. `model`
|
||||
/// overrides the Settings whisper model for this one import (so the user picks
|
||||
/// and can see what it was transcribed with); omit to use the Settings default.
|
||||
#[tauri::command]
|
||||
pub async fn import_media(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
source: String,
|
||||
title: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> WaResult<MeetingId> {
|
||||
let settings = load_settings();
|
||||
let model_id = model_id_for(&settings);
|
||||
let model_id = model
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.unwrap_or_else(|| model_id_for(&settings));
|
||||
let backend = backend_for(&settings);
|
||||
let model_path = whisper_model_file(&model_id);
|
||||
if !model_path.exists() {
|
||||
@@ -2021,12 +2470,47 @@ pub async fn import_media(
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
// `create_meeting` starts rows as `recording`; mark this one `transcribing`
|
||||
// so the meetings-list badge reads as an import in flight, not a live mic.
|
||||
let _ = state
|
||||
.store
|
||||
.set_meeting_status(&meeting_id, "transcribing")
|
||||
.await;
|
||||
|
||||
// Run the pipeline detached and return now — the dialog closes and the
|
||||
// meeting appears in the list with a live tracker fed by the events below.
|
||||
let store = state.store.clone();
|
||||
let dir = meeting_dir(&meeting_id);
|
||||
let wav_path = dir.join("audio.wav");
|
||||
let mid = meeting_id.clone();
|
||||
tauri::async_runtime::spawn(run_import_pipeline(
|
||||
app, store, mid, source, wav_path, dir, backend, model_id, model_path, language,
|
||||
));
|
||||
Ok(meeting_id)
|
||||
}
|
||||
|
||||
// Transcode into the meeting's audio.wav off the async runtime (shells out
|
||||
// to ffmpeg/yt-dlp). On any failure, drop the empty meeting so a bad import
|
||||
// doesn't leave a husk row behind.
|
||||
/// The detached body of `import_media`: transcode, transcribe, diarize, finalize,
|
||||
/// emitting an `import://progress` tick at the start and end of each phase. On
|
||||
/// the first failure it marks the meeting `error` (kept in the list, not
|
||||
/// deleted, so the user sees the failed import) and stops.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_import_pipeline(
|
||||
app: AppHandle,
|
||||
store: Arc<dyn crate::storage::Store>,
|
||||
meeting_id: MeetingId,
|
||||
source: String,
|
||||
wav_path: std::path::PathBuf,
|
||||
dir: std::path::PathBuf,
|
||||
backend: BackendId,
|
||||
model_id: String,
|
||||
model_path: std::path::PathBuf,
|
||||
language: Option<String>,
|
||||
) {
|
||||
use std::time::Instant;
|
||||
|
||||
// Phase 1: prepare — transcode (and, for URLs, yt-dlp download) to audio.wav.
|
||||
emit_import_progress(&app, &meeting_id, "prepare", "active", None, None);
|
||||
let t = Instant::now();
|
||||
let transcode = tauri::async_runtime::spawn_blocking({
|
||||
let source = source.clone();
|
||||
let wav_path = wav_path.clone();
|
||||
@@ -2039,15 +2523,19 @@ pub async fn import_media(
|
||||
r
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WaError::new("import", e.to_string()))?;
|
||||
if let Err(e) = transcode {
|
||||
let _ = state.store.delete_meeting(&meeting_id).await;
|
||||
return Err(WaError::new("import", e.to_string()));
|
||||
.await;
|
||||
match transcode {
|
||||
Ok(Ok(())) => {
|
||||
emit_import_progress(&app, &meeting_id, "prepare", "done", Some(t.elapsed().as_millis()), None)
|
||||
}
|
||||
Ok(Err(e)) => return fail_import(&app, &store, &meeting_id, "prepare", &e.to_string()).await,
|
||||
Err(e) => return fail_import(&app, &store, &meeting_id, "prepare", &e.to_string()).await,
|
||||
}
|
||||
|
||||
// Transcribe the produced WAV (same batch path as reprocess_transcript).
|
||||
let (mut segments, resolved_language) = tauri::async_runtime::spawn_blocking({
|
||||
// Phase 2: transcribe (same batch path as reprocess_transcript).
|
||||
emit_import_progress(&app, &meeting_id, "transcribe", "active", None, None);
|
||||
let t = Instant::now();
|
||||
let transcribed = tauri::async_runtime::spawn_blocking({
|
||||
let wav_path = wav_path.clone();
|
||||
let model_path = model_path.clone();
|
||||
let requested_language = language.clone();
|
||||
@@ -2061,12 +2549,20 @@ pub async fn import_media(
|
||||
Ok::<_, crate::transcription::TrxError>((segments, resolved))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WaError::new("transcription", e.to_string()))?
|
||||
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
||||
.await;
|
||||
let (mut segments, resolved_language) = match transcribed {
|
||||
Ok(Ok(v)) => {
|
||||
emit_import_progress(&app, &meeting_id, "transcribe", "done", Some(t.elapsed().as_millis()), None);
|
||||
v
|
||||
}
|
||||
Ok(Err(e)) => return fail_import(&app, &store, &meeting_id, "transcribe", &e.to_string()).await,
|
||||
Err(e) => return fail_import(&app, &store, &meeting_id, "transcribe", &e.to_string()).await,
|
||||
};
|
||||
|
||||
// One diarization pass if the models are installed, exactly like
|
||||
// stop_recording — otherwise every line stays the single "S1" placeholder.
|
||||
// Phase 3: diarize — one pass if the models are installed, else every line
|
||||
// stays the single "S1" placeholder (same as stop_recording).
|
||||
emit_import_progress(&app, &meeting_id, "diarize", "active", None, None);
|
||||
let t = Instant::now();
|
||||
let diarizer: Option<Arc<dyn Diarizer>> =
|
||||
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
|
||||
.await
|
||||
@@ -2082,15 +2578,18 @@ pub async fn import_media(
|
||||
Err(e) => tracing::warn!("import diarization task failed: {e}"),
|
||||
}
|
||||
}
|
||||
emit_import_progress(&app, &meeting_id, "diarize", "done", Some(t.elapsed().as_millis()), None);
|
||||
|
||||
// Phase 4: finalize — persist segments, notes, seal audio.
|
||||
emit_import_progress(&app, &meeting_id, "finalize", "active", None, None);
|
||||
let t = Instant::now();
|
||||
let speakers = speaker_infos_from_segments(&segments, &HashMap::new());
|
||||
let duration_secs = segments
|
||||
.last()
|
||||
.map(|s| (s.end_ms / 1000) as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
state
|
||||
.store
|
||||
if let Err(e) = store
|
||||
.finalize_meeting(
|
||||
&meeting_id,
|
||||
FinalizeMeeting {
|
||||
@@ -2100,12 +2599,14 @@ pub async fn import_media(
|
||||
recorded: true, // the imported WAV is the recording — keep it
|
||||
language: resolved_language,
|
||||
backend_used: Some(backend.as_str().to_string()),
|
||||
model_used: Some(model_id.clone()),
|
||||
model_used: Some(effective_model_id(backend, &model_id)),
|
||||
audio_layout: Some("summed".to_string()), // single-source import
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
{
|
||||
return fail_import(&app, &store, &meeting_id, "finalize", &e.to_string()).await;
|
||||
}
|
||||
|
||||
let notes_md = crate::notes::MarkdownNotes.merge(
|
||||
&segments,
|
||||
@@ -2114,7 +2615,7 @@ pub async fn import_media(
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = state.store.update_notes(&meeting_id, ¬es_md).await;
|
||||
let _ = store.update_notes(&meeting_id, ¬es_md).await;
|
||||
|
||||
// Seal the retained recording at rest when the vault is unlocked (T8.8),
|
||||
// matching stop_recording so imports aren't left as plaintext outliers.
|
||||
@@ -2125,12 +2626,25 @@ pub async fn import_media(
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_import_progress(&app, &meeting_id, "finalize", "done", Some(t.elapsed().as_millis()), None);
|
||||
|
||||
let _ = app.emit(
|
||||
"transcript://finalized",
|
||||
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
|
||||
);
|
||||
Ok(meeting_id)
|
||||
}
|
||||
|
||||
/// Mark a failed background import `error` (kept in the list) and emit the error
|
||||
/// tick for the phase that failed.
|
||||
async fn fail_import(
|
||||
app: &AppHandle,
|
||||
store: &Arc<dyn crate::storage::Store>,
|
||||
meeting_id: &str,
|
||||
phase: &str,
|
||||
message: &str,
|
||||
) {
|
||||
let _ = store.set_meeting_status(&meeting_id.to_string(), "error").await;
|
||||
emit_import_progress(app, meeting_id, phase, "error", None, Some(message));
|
||||
}
|
||||
|
||||
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
|
||||
@@ -3419,6 +3933,60 @@ pub async fn generate_tags(
|
||||
.map_err(|e| WaError::new("llm", e.to_string()))
|
||||
}
|
||||
|
||||
/// AI-enhance the user's rough notes into structured Markdown grounded in the
|
||||
/// transcript (Granola-style). Reuses the configured `LlmProvider` (local by
|
||||
/// default → no new egress). The caller passes the live editor buffer so we
|
||||
/// enhance exactly what the user sees, not a possibly-stale saved copy; we
|
||||
/// return the enhanced Markdown without persisting it — the UI decides to keep
|
||||
/// or undo it. Refuses a still-recording meeting; errors clearly with no
|
||||
/// provider configured. Invents nothing beyond the notes + transcript.
|
||||
#[tauri::command]
|
||||
pub async fn enhance_notes(
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
notes: String,
|
||||
) -> WaResult<String> {
|
||||
let guard = state.session.lock().await;
|
||||
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
|
||||
return Err(WaError::new(
|
||||
"llm",
|
||||
"cannot enhance notes while this meeting is still recording — wait until it's stopped",
|
||||
));
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
let settings = load_settings();
|
||||
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
|
||||
WaError::new(
|
||||
"llm",
|
||||
"no LLM provider is configured — enable one in Settings first",
|
||||
)
|
||||
})?;
|
||||
|
||||
let meeting = state
|
||||
.store
|
||||
.get_meeting(&meeting_id)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
let prompt = build_prompt(&meeting, None);
|
||||
|
||||
let system = "You expand a user's rough meeting notes into clear, well-structured Markdown. \
|
||||
Use ONLY facts stated in the transcript and the user's own notes — never invent details, names, \
|
||||
numbers, or decisions. Preserve the user's intent and any structure they started. Output Markdown \
|
||||
only, with no preamble or commentary.";
|
||||
let notes = notes.trim();
|
||||
let user = format!(
|
||||
"My rough notes:\n{}\n\nTranscript:\n{}",
|
||||
if notes.is_empty() { "(none yet)" } else { notes },
|
||||
prompt.transcript
|
||||
);
|
||||
provider
|
||||
.complete(system, &user)
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|e| WaError::new("llm", e.to_string()))
|
||||
}
|
||||
|
||||
// ---- Calendar / .pst (Phase 6) ----
|
||||
|
||||
/// Import events + attendees from a `.pst` (T6.1/T6.2, FR-CAL-1). The
|
||||
@@ -4908,6 +5476,49 @@ pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult<serde_js
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(backend: &str, model: &str, rtf: f64) -> StressResult {
|
||||
StressResult {
|
||||
backend: backend.into(),
|
||||
model: model.into(),
|
||||
rtf,
|
||||
realtime: rtf < 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendation_picks_largest_realtime_model() {
|
||||
let sizes = HashMap::from([
|
||||
("tiny".to_string(), 32),
|
||||
("base".to_string(), 60),
|
||||
("small".to_string(), 190),
|
||||
]);
|
||||
let results = vec![
|
||||
row("cpu", "tiny", 0.4),
|
||||
row("cpu", "base", 0.9),
|
||||
row("cpu", "small", 1.4), // too slow — excluded
|
||||
row("vulkan", "small", 0.6),
|
||||
];
|
||||
let rec = pick_realtime_recommendation(&results, &sizes).unwrap();
|
||||
// small is the largest model that still runs in real time (on vulkan).
|
||||
assert_eq!(rec.model, "small");
|
||||
assert_eq!(rec.backend, "vulkan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendation_breaks_size_ties_by_lowest_rtf() {
|
||||
let sizes = HashMap::from([("base".to_string(), 60)]);
|
||||
let results = vec![row("cpu", "base", 0.8), row("vulkan", "base", 0.3)];
|
||||
let rec = pick_realtime_recommendation(&results, &sizes).unwrap();
|
||||
assert_eq!(rec.backend, "vulkan"); // same model, more headroom
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendation_is_none_when_nothing_is_realtime() {
|
||||
let sizes = HashMap::from([("small".to_string(), 190)]);
|
||||
let results = vec![row("cpu", "small", 1.2)];
|
||||
assert!(pick_realtime_recommendation(&results, &sizes).is_none());
|
||||
}
|
||||
|
||||
/// Extracts the real hosted DirectML bundle (LZMA2+BCJ 7z) and checks the
|
||||
/// DLL lands flat. Skips if the binary isn't present (e.g. a lean checkout).
|
||||
#[cfg(feature = "npu")]
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Enterprise deployment: seed default settings from an admin-supplied `.ini`
|
||||
//! on **first run only** (before any `settings.json` exists).
|
||||
//!
|
||||
//! An admin mass-deploying WhispAssist (GPO / SCCM / Intune) drops a
|
||||
//! `wa-defaults.ini` and every fresh install picks it up once, seeding
|
||||
//! `settings.json` with their chosen defaults (record-by-default, preferred
|
||||
//! backend, retention, model to auto-download, …) — all via native Windows file
|
||||
//! deployment, no WiX custom actions. See `docs/enterprise-deployment.md`.
|
||||
//!
|
||||
//! **Guardrail (CLAUDE.md):** the file must never carry secrets. Keys that look
|
||||
//! like credentials are ignored here as defense in depth — API keys / OAuth
|
||||
//! tokens live only in the OS credential store.
|
||||
|
||||
use crate::models::Settings;
|
||||
use serde_json::{Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Special (non-`Settings`) INI key: when truthy, the first-run seed also fetches
|
||||
/// the configured `whisper_model` in the background so the machine is ready
|
||||
/// offline. Stripped before the settings merge.
|
||||
const AUTO_DOWNLOAD_KEY: &str = "auto_download_model";
|
||||
|
||||
/// Candidate locations, first found wins:
|
||||
/// 1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide enterprise path.
|
||||
/// 2. `<exe dir>\wa-defaults.ini` — the bundled template / per-install override.
|
||||
fn candidate_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Ok(program_data) = std::env::var("ProgramData") {
|
||||
paths.push(PathBuf::from(program_data).join("WhispAssist").join("wa-defaults.ini"));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
paths.push(dir.join("wa-defaults.ini"));
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// Reads the first existing defaults file and produces the seeded settings plus
|
||||
/// the whisper model id to auto-download (if `auto_download_model` was set).
|
||||
/// `None` when no file exists or it contains no overrides (the shipped template
|
||||
/// is fully commented, so normal installs get exactly today's behavior).
|
||||
pub fn seed_settings_from_defaults() -> Option<(Settings, Option<String>)> {
|
||||
let text = candidate_paths()
|
||||
.into_iter()
|
||||
.find_map(|p| std::fs::read_to_string(p).ok())?;
|
||||
seed_from_ini(&text)
|
||||
}
|
||||
|
||||
/// Testable core: parse INI text → merge onto the built-in defaults.
|
||||
fn seed_from_ini(text: &str) -> Option<(Settings, Option<String>)> {
|
||||
let mut overrides = parse_ini(text);
|
||||
if overrides.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Pull the non-Settings auto-download flag out before the merge.
|
||||
let auto_download = overrides
|
||||
.remove(AUTO_DOWNLOAD_KEY)
|
||||
.map(|v| truthy(&v))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Merge overrides onto the default settings' JSON form, then deserialize.
|
||||
// Unknown keys (typos) are ignored — `Settings` has no deny_unknown_fields.
|
||||
let mut base = match serde_json::to_value(crate::commands::default_settings()) {
|
||||
Ok(Value::Object(map)) => map,
|
||||
_ => return None,
|
||||
};
|
||||
for (k, v) in overrides {
|
||||
base.insert(k, v);
|
||||
}
|
||||
|
||||
let settings: Settings = serde_json::from_value(Value::Object(base)).ok()?;
|
||||
let model = if auto_download {
|
||||
Some(settings.whisper_model.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some((settings, model))
|
||||
}
|
||||
|
||||
/// Minimal INI reader: skips blanks, `;`/`#` comments and `[section]` headers;
|
||||
/// splits each `key = value` on the first `=`; coerces values to bool / integer /
|
||||
/// string so serde lands them on the typed `Settings` fields. Silently drops any
|
||||
/// key that looks like a secret (guardrail — no credentials in the deploy file).
|
||||
fn parse_ini(text: &str) -> Map<String, Value> {
|
||||
let mut map = Map::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty()
|
||||
|| line.starts_with(';')
|
||||
|| line.starts_with('#')
|
||||
|| line.starts_with('[')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim();
|
||||
if key.is_empty() || looks_like_secret(&key) {
|
||||
continue;
|
||||
}
|
||||
map.insert(key, coerce(value));
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// `true`/`false` → bool, all-integer → number, everything else → string.
|
||||
fn coerce(value: &str) -> Value {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"true" => return Value::Bool(true),
|
||||
"false" => return Value::Bool(false),
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(n) = value.parse::<i64>() {
|
||||
return Value::Number(n.into());
|
||||
}
|
||||
Value::String(value.to_string())
|
||||
}
|
||||
|
||||
fn truthy(v: &Value) -> bool {
|
||||
matches!(v, Value::Bool(true)) || matches!(v, Value::String(s) if s.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
/// Defense in depth: never seed anything that smells like a credential.
|
||||
fn looks_like_secret(key: &str) -> bool {
|
||||
let k = key.to_ascii_lowercase();
|
||||
["key", "token", "secret", "credential", "password"]
|
||||
.iter()
|
||||
.any(|needle| k.contains(needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fully_commented_file_is_a_noop() {
|
||||
let ini = "; default_record = true\n# preferred_backend = cpu\n[general]\n\n";
|
||||
assert!(seed_from_ini(ini).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coerces_bool_int_and_string_fields() {
|
||||
let ini = "default_record = true\nretention_max_age_days = 90\npreferred_backend = cpu\n";
|
||||
let (settings, model) = seed_from_ini(ini).expect("overrides present");
|
||||
assert!(settings.default_record);
|
||||
assert_eq!(settings.retention_max_age_days, Some(90));
|
||||
assert_eq!(settings.preferred_backend, "cpu");
|
||||
assert!(model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_download_returns_the_configured_model() {
|
||||
let ini = "whisper_model = base.en-q5_1\nauto_download_model = true\n";
|
||||
let (_settings, model) = seed_from_ini(ini).expect("overrides present");
|
||||
assert_eq!(model.as_deref(), Some("base.en-q5_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_fields_keep_their_defaults() {
|
||||
let ini = "default_record = true\n";
|
||||
let (settings, _) = seed_from_ini(ini).unwrap();
|
||||
// microphone stays on, auto_start stays off — only the named key changed.
|
||||
assert!(settings.microphone_enabled);
|
||||
assert!(!settings.auto_start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_keys_are_ignored() {
|
||||
let ini = "anthropic_api_key = sk-should-be-dropped\ndefault_record = true\n";
|
||||
let map = parse_ini(ini);
|
||||
assert!(!map.contains_key("anthropic_api_key"));
|
||||
assert!(map.contains_key("default_record"));
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,55 @@ pub fn assign_by_overlap(segments: &mut [TranscriptSegment], spans: &[SpeakerSpa
|
||||
}
|
||||
}
|
||||
|
||||
/// Split-layout attribution (FR-SPK): decides per segment between "You" (mic
|
||||
/// channel voice activity) and the far side's diarized speakers by comparing
|
||||
/// the *total* voiced overlap on each channel, not by picking the single
|
||||
/// longest span — a long far-side diarizer span could otherwise swallow a
|
||||
/// segment the user spoke most of, showing their words under "Speaker N".
|
||||
/// The mic channel is physically the user's voice alone, so channel evidence
|
||||
/// outranks cluster evidence; ties go to "You" (mislabeling the user's own
|
||||
/// words as someone else is the worse failure). A segment with no voiced
|
||||
/// overlap on either channel keeps its prior label rather than guessing.
|
||||
// ponytail: whole-segment labels — a segment genuinely containing both sides
|
||||
// still gets one speaker; the upgrade path is transcribing each channel
|
||||
// separately so segments can never mix voices.
|
||||
pub fn assign_split(
|
||||
segments: &mut [TranscriptSegment],
|
||||
you_spans: &[(u64, u64)],
|
||||
far_vad: &[(u64, u64)],
|
||||
far_spans: &[SpeakerSpan],
|
||||
) {
|
||||
fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 {
|
||||
a1.min(b1).saturating_sub(a0.max(b0))
|
||||
}
|
||||
for seg in segments.iter_mut() {
|
||||
let mic_ms: u64 = you_spans
|
||||
.iter()
|
||||
.map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e))
|
||||
.sum();
|
||||
let far_ms: u64 = far_vad
|
||||
.iter()
|
||||
.map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e))
|
||||
.sum();
|
||||
if mic_ms == 0 && far_ms == 0 {
|
||||
continue;
|
||||
}
|
||||
if mic_ms >= far_ms {
|
||||
seg.speaker = "You".to_string();
|
||||
} else if let Some(span) = far_spans
|
||||
.iter()
|
||||
.map(|sp| (overlap(seg.start_ms, seg.end_ms, sp.start_ms, sp.end_ms), sp))
|
||||
.filter(|(o, _)| *o > 0)
|
||||
.max_by_key(|(o, _)| *o)
|
||||
.map(|(_, sp)| sp)
|
||||
{
|
||||
seg.speaker = span.speaker.clone();
|
||||
}
|
||||
// Far side voiced but no diarizer span overlaps (e.g. a sub-700ms span
|
||||
// was filtered): keep the prior label rather than guess.
|
||||
}
|
||||
}
|
||||
|
||||
/// sherpa-onnx-backed diarizer: pyannote segmentation + speaker-embedding +
|
||||
/// fast clustering (ADR-0005, T4.1). `Diarize::compute` needs `&mut self`; it's
|
||||
/// wrapped in a `Mutex` to satisfy `Diarizer: Sync` — diarization is a
|
||||
@@ -210,6 +259,58 @@ mod overlap_tests {
|
||||
assign_by_overlap(&mut segments, &[]);
|
||||
assert_eq!(segments[0].speaker, "S1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_labels_a_mic_dominant_segment_you_even_against_a_longer_far_span() {
|
||||
// The user spoke 0-4000ms; the far side 4000-6000ms — but the far
|
||||
// cluster span covers the whole window, so the old merged max-overlap
|
||||
// pick handed the entire segment (the user's words included) to the
|
||||
// far speaker. Channel totals must side with the mic instead.
|
||||
let mut segments = vec![segment(0, 6000)];
|
||||
let you = vec![(0u64, 4000u64)];
|
||||
let far_vad = vec![(4000u64, 6000u64)];
|
||||
let far_spans = vec![span(0, 6000, "S1")]; // long far cluster span
|
||||
assign_split(&mut segments, &you, &far_vad, &far_spans);
|
||||
assert_eq!(segments[0].speaker, "You");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_ties_go_to_you() {
|
||||
let mut segments = vec![segment(0, 2000)];
|
||||
let you = vec![(0u64, 1000u64)];
|
||||
let far_vad = vec![(1000u64, 2000u64)];
|
||||
let far_spans = vec![span(1000, 2000, "S1")];
|
||||
assign_split(&mut segments, &you, &far_vad, &far_spans);
|
||||
assert_eq!(segments[0].speaker, "You");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_assigns_the_best_far_span_when_the_far_side_dominates() {
|
||||
let mut segments = vec![segment(0, 3000)];
|
||||
let you = vec![(0u64, 500u64)];
|
||||
let far_vad = vec![(500u64, 3000u64)];
|
||||
let far_spans = vec![span(500, 1000, "S1"), span(1000, 3000, "S2")];
|
||||
assign_split(&mut segments, &you, &far_vad, &far_spans);
|
||||
assert_eq!(segments[0].speaker, "S2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_the_prior_label_when_both_channels_are_silent() {
|
||||
let mut segments = vec![segment(5000, 6000)];
|
||||
segments[0].speaker = "S9".to_string();
|
||||
assign_split(&mut segments, &[(0, 1000)], &[(0, 1000)], &[span(0, 1000, "S1")]);
|
||||
assert_eq!(segments[0].speaker, "S9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_the_prior_label_when_far_is_voiced_but_no_far_span_overlaps() {
|
||||
// Far VAD hears speech but every diarizer span was filtered (sub-700ms):
|
||||
// don't guess a label.
|
||||
let mut segments = vec![segment(0, 1000)];
|
||||
segments[0].speaker = "S3".to_string();
|
||||
assign_split(&mut segments, &[], &[(0, 1000)], &[span(2000, 3000, "S1")]);
|
||||
assert_eq!(segments[0].speaker, "S3");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "diarization"))]
|
||||
|
||||
+109
-2
@@ -9,6 +9,7 @@ pub mod audio;
|
||||
pub mod briefs;
|
||||
pub mod calendar;
|
||||
pub mod commands;
|
||||
pub mod deploy;
|
||||
pub mod diarization;
|
||||
pub mod error;
|
||||
pub mod hardware;
|
||||
@@ -27,7 +28,8 @@ pub mod vault;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::thread::JoinHandle;
|
||||
use tauri::tray::TrayIcon;
|
||||
use tauri::menu::{Menu, MenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::Manager;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -155,6 +157,14 @@ pub fn run() {
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// Opt-in launch-at-login (NFR-RES-4). The macOS launcher arg is required
|
||||
// by the signature but unused on Windows, where enable/disable writes a
|
||||
// per-user HKCU\...\Run entry (no admin). Off until the user (or an
|
||||
// enterprise deploy file) turns `auto_start` on.
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
// In-memory streaming of recordings for the player (FR-REC-5): decrypts
|
||||
// on the fly so no plaintext audio is ever written to disk.
|
||||
.register_uri_scheme_protocol("waaudio", |_ctx, request| {
|
||||
@@ -165,13 +175,84 @@ pub fn run() {
|
||||
session: Mutex::new(None),
|
||||
})
|
||||
.setup(move |app| {
|
||||
// Single tray icon (the `trayIcon` in tauri.conf.json was removed so
|
||||
// this is the only one). It carries a Show/Quit menu and, on
|
||||
// left-click, restores the window — the always-available way back
|
||||
// from "close to tray".
|
||||
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
|
||||
let tray = tauri::tray::TrayIconBuilder::new()
|
||||
let show_item = MenuItem::with_id(app, "show", "Show WhispAssist", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||
let tray = TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.tooltip("WhispAssist — idle")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => show_main_window(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
app.manage(TrayHandle(tray));
|
||||
|
||||
// First-run enterprise deploy seeding (deploy.rs): if no settings.json
|
||||
// exists yet and an admin dropped a wa-defaults.ini, seed settings once
|
||||
// and optionally fetch the configured model in the background. One-shot
|
||||
// — guarded by the settings file's absence, so it never re-runs and adds
|
||||
// nothing to idle cost (NFR-RES-1).
|
||||
if !crate::paths::settings_path().exists() {
|
||||
if let Some((seeded, model_to_download)) = deploy::seed_settings_from_defaults() {
|
||||
match commands::save_settings(&seeded) {
|
||||
Ok(()) => {
|
||||
tracing::info!("seeded settings.json from wa-defaults.ini");
|
||||
if let Some(id) = model_to_download {
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = commands::download_model(
|
||||
app_handle,
|
||||
commands::DownloadModelArgs {
|
||||
kind: "whisper".into(),
|
||||
id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("deploy auto-download of model failed: {e:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("first-run deploy seeding failed to write settings: {e:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile launch-at-login with the persisted preference (NFR-RES-4):
|
||||
// if the user opted in but the OS entry is missing (e.g. after a
|
||||
// reinstall or a deploy file that set auto_start), restore it. One-shot.
|
||||
{
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
let manager = app.autolaunch();
|
||||
if commands::load_settings().auto_start && !manager.is_enabled().unwrap_or(false) {
|
||||
if let Err(e) = manager.enable() {
|
||||
tracing::warn!("failed to restore auto-start entry: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reminders (Phase 8, T8.6, FR-CAL-5) are Windows-scheduled toasts, not
|
||||
// an app-side timer — Windows itself is what's "polling", so this stays
|
||||
// within NFR-RES-1. init() just registers the AppUserModelID.
|
||||
@@ -265,6 +346,17 @@ pub fn run() {
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
// Close to tray (keep running in background): when the setting is on,
|
||||
// the window X hides instead of quitting; the tray "Quit" is the real
|
||||
// exit. Off → default behavior (closing the window quits the app).
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if commands::load_settings().close_to_tray {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::start_recording,
|
||||
commands::stop_recording,
|
||||
@@ -272,6 +364,7 @@ pub fn run() {
|
||||
commands::recording_playback_path,
|
||||
commands::pause_recording,
|
||||
commands::resume_recording,
|
||||
commands::toggle_microphone_mute,
|
||||
commands::set_recording_retention,
|
||||
commands::acknowledge_recording_consent,
|
||||
commands::update_live_notes,
|
||||
@@ -283,6 +376,9 @@ pub fn run() {
|
||||
commands::list_audio_devices,
|
||||
commands::list_input_devices,
|
||||
commands::set_preferred_backend,
|
||||
commands::set_auto_start,
|
||||
commands::monitor_audio_level,
|
||||
commands::stress_test_hardware,
|
||||
commands::list_models,
|
||||
commands::list_whisper_languages,
|
||||
commands::download_npu_package,
|
||||
@@ -311,6 +407,7 @@ pub fn run() {
|
||||
commands::generate_summary,
|
||||
commands::confirm_action_items,
|
||||
commands::generate_tags,
|
||||
commands::enhance_notes,
|
||||
commands::llm_setup_suggestions,
|
||||
commands::pull_ollama_model,
|
||||
commands::import_pst,
|
||||
@@ -355,6 +452,16 @@ pub fn run() {
|
||||
.expect("error while running WhispAssist");
|
||||
}
|
||||
|
||||
/// Restore the main window from the tray (show + unminimize + focus). Shared by
|
||||
/// the tray left-click and the "Show WhispAssist" menu item.
|
||||
fn show_main_window(app: &tauri::AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Used by `commands.rs` to keep the tray tooltip honest about capture state (FR-CAP-4).
|
||||
pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) {
|
||||
if let Some(tray) = app.try_state::<TrayHandle>() {
|
||||
|
||||
@@ -354,6 +354,17 @@ pub struct Settings {
|
||||
pub mcp_expose: String, // none|selected|all
|
||||
#[serde(default)]
|
||||
pub mcp_expose_recordings: bool,
|
||||
/// Launch WhispAssist automatically at login (opt-in, NFR-RES-4). OFF by
|
||||
/// default; toggled via `set_auto_start`, which writes a per-user
|
||||
/// `HKCU\...\Run` entry through `tauri-plugin-autostart` (no admin). An
|
||||
/// enterprise deploy file may set this to `true` (see `deploy.rs`).
|
||||
#[serde(default)]
|
||||
pub auto_start: bool,
|
||||
/// Closing the window hides WhispAssist to the system tray instead of
|
||||
/// quitting, so it keeps running in the background (tray "Quit" really
|
||||
/// exits). ON by default; the tray icon is the always-available way back.
|
||||
#[serde(default = "default_true")]
|
||||
pub close_to_tray: bool,
|
||||
}
|
||||
|
||||
fn default_mcp_transport() -> String {
|
||||
|
||||
@@ -276,6 +276,10 @@ pub trait Store: Send + Sync {
|
||||
) -> Result<Vec<MeetingListItem>, StoreError>;
|
||||
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
|
||||
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||
/// Overwrite a meeting's lifecycle `status` (e.g. mark a background import
|
||||
/// `transcribing` while it runs, or `error` if it fails). `finalize_meeting`
|
||||
/// is still the only path to `ready`.
|
||||
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError>;
|
||||
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
|
||||
/// (Re)builds this meeting's FTS index row from the current title and
|
||||
/// whatever's on disk/in the DB for transcript/notes/summary/tags (Phase
|
||||
@@ -976,6 +980,16 @@ impl Store for SqliteStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError> {
|
||||
sqlx::query("UPDATE meetings SET status = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(now_unix())
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> {
|
||||
write_artifact(
|
||||
&paths::meeting_dir(id).join("notes.md"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "WhispAssist",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.2",
|
||||
"identifier": "bet.dou.whispassist",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -22,18 +22,18 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; media-src 'self' http://waaudio.localhost; style-src 'self' 'unsafe-inline'"
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/tray.png",
|
||||
"tooltip": "WhispAssist"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["msi", "nsis"],
|
||||
"icon": ["icons/icon.ico"],
|
||||
"resources": ["wa-defaults.ini"],
|
||||
"windows": {
|
||||
"webviewInstallMode": { "type": "downloadBootstrapper" }
|
||||
"webviewInstallMode": { "type": "downloadBootstrapper" },
|
||||
"nsis": {
|
||||
"installMode": "both"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "gen/schemas/desktop-schema.json",
|
||||
"bundle": {
|
||||
"resources": ["vulkan-1.dll"]
|
||||
"resources": ["vulkan-1.dll", "wa-defaults.ini"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
; ============================================================================
|
||||
; WhispAssist enterprise deployment defaults (wa-defaults.ini)
|
||||
; ============================================================================
|
||||
; Read ONCE, on a machine's FIRST launch (before settings.json exists), to
|
||||
; seed the app's default settings. After that the user's own settings.json
|
||||
; wins and this file is ignored. Deploy it with native Windows tooling
|
||||
; (Group Policy / SCCM / Intune file copy) to either location — the first
|
||||
; one found wins:
|
||||
;
|
||||
; 1. %PROGRAMDATA%\WhispAssist\wa-defaults.ini (machine-wide)
|
||||
; 2. <install dir>\wa-defaults.ini (this bundled template)
|
||||
;
|
||||
; As shipped, every setting below is COMMENTED OUT, so a normal install
|
||||
; behaves exactly as if this file were absent. Uncomment and edit the lines
|
||||
; you want to preset. Format is flat "key = value" — no sections required;
|
||||
; [section] headers, ';' and '#' comment lines are ignored.
|
||||
;
|
||||
; Values: true / false for switches; a plain number for numeric fields;
|
||||
; text otherwise. Unknown / misspelled keys are ignored.
|
||||
;
|
||||
; SECURITY: never put secrets here. API keys, OAuth tokens and sync
|
||||
; passwords live only in the OS credential store; any key containing
|
||||
; "key", "token", "secret", "credential" or "password" is dropped on read.
|
||||
; ----------------------------------------------------------------------------
|
||||
|
||||
; ---- Recording (ADR-0009) --------------------------------------------------
|
||||
; Record every meeting to disk by default (consent notice still applies).
|
||||
; default_record = false
|
||||
|
||||
; ---- Transcription backend & model -----------------------------------------
|
||||
; preferred_backend = auto ; auto | npu | nvidia | amd | intel | cpu
|
||||
; whisper_model = base.en-q5_1 ; a catalog model id
|
||||
; whisper_language = auto ; auto | ISO-639-1 code (multilingual model only)
|
||||
; low_overhead = false
|
||||
|
||||
; Download whisper_model in the background on first launch so the machine is
|
||||
; ready offline. Requires network at first run.
|
||||
; auto_download_model = false
|
||||
|
||||
; ---- Storage & retention (FR-STORE-2) --------------------------------------
|
||||
; storage_root = C:\ProgramData\WhispAssist\data
|
||||
; retention_max_age_days = 90
|
||||
; retention_max_size_gb = 20
|
||||
|
||||
; ---- Local LLM / summaries (ADR-0007) --------------------------------------
|
||||
; llm_provider = ollama ; ollama | custom | anthropic | off
|
||||
; llm_endpoint = http://localhost:11434
|
||||
; llm_model = llama3
|
||||
|
||||
; ---- Capture & UX ----------------------------------------------------------
|
||||
; microphone_enabled = true
|
||||
; auto_record_calendar = false
|
||||
; theme = system ; system | light | dark
|
||||
|
||||
; ---- Startup (NFR-RES-4) ---------------------------------------------------
|
||||
; Launch WhispAssist automatically at login for the user.
|
||||
; auto_start = false
|
||||
|
||||
; ---- Sync master switch (ADR-0010; targets/creds configured in-app) --------
|
||||
; sync_enabled = false
|
||||
+74
-24
@@ -12,6 +12,7 @@
|
||||
import { recording } from "./lib/stores/recording.svelte";
|
||||
import { settings } from "./lib/stores/settings.svelte";
|
||||
import { meetings } from "./lib/stores/meetings.svelte";
|
||||
import { imports } from "./lib/stores/imports.svelte";
|
||||
import { calendar } from "./lib/stores/calendar.svelte";
|
||||
import { api, type NoteTemplate, type CalendarEvent } from "./lib/api";
|
||||
import { onMount } from "svelte";
|
||||
@@ -24,6 +25,8 @@
|
||||
Square,
|
||||
Trash2,
|
||||
FilePlus,
|
||||
Mic,
|
||||
MicOff,
|
||||
Settings as SettingsIcon,
|
||||
AlertTriangle,
|
||||
PanelLeftClose,
|
||||
@@ -131,6 +134,7 @@
|
||||
recording.init();
|
||||
settings.load();
|
||||
meetings.init();
|
||||
imports.init(); // live background-import progress for the tracker
|
||||
calendar.load(); // events power the auto-record timer above
|
||||
checkVault();
|
||||
api
|
||||
@@ -207,6 +211,18 @@
|
||||
} else if (e.ctrlKey && e.key === ",") {
|
||||
e.preventDefault();
|
||||
showSettings = !showSettings;
|
||||
} else if (
|
||||
// Press "M" to mute/unmute the mic mid-meeting (FR-CAP-7). Bare key (no
|
||||
// modifiers) and only while recording with the mic on.
|
||||
e.key.toLowerCase() === "m" &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
recording.state !== "idle" &&
|
||||
settings.settings.microphone_enabled
|
||||
) {
|
||||
e.preventDefault();
|
||||
recording.toggleMute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +248,16 @@
|
||||
<div class="app" data-theme={resolvedTheme}>
|
||||
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
|
||||
<header class="bar">
|
||||
<strong>WhispAssist</strong>
|
||||
<span class="muted">{t("app.tagline")}</span>
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<button
|
||||
class="record-btn"
|
||||
onclick={startRecording}
|
||||
title={t("app.record_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Circle size={11} fill="currentColor" aria-hidden="true" />
|
||||
{t("app.record")}
|
||||
</button>
|
||||
<select
|
||||
class="theme-select"
|
||||
bind:value={selectedTemplateId}
|
||||
@@ -247,23 +269,6 @@
|
||||
<option value={tpl.id}>{tpl.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="import-btn"
|
||||
onclick={() => (showImport = true)}
|
||||
title={t("app.add_meeting_title")}
|
||||
>
|
||||
<FilePlus size={13} aria-hidden="true" />
|
||||
{t("app.add_meeting")}
|
||||
</button>
|
||||
<button
|
||||
class="record-btn"
|
||||
onclick={startRecording}
|
||||
title={t("app.record_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Circle size={11} fill="currentColor" aria-hidden="true" />
|
||||
{t("app.record")}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="stop-btn"
|
||||
@@ -278,6 +283,18 @@
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
{t("app.cancel")}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<button
|
||||
class="import-btn"
|
||||
onclick={() => (showImport = true)}
|
||||
title={t("app.add_meeting_title")}
|
||||
>
|
||||
<FilePlus size={13} aria-hidden="true" />
|
||||
{t("app.add_meeting")}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="rec">
|
||||
<span class="rec-dot" aria-hidden="true"></span>
|
||||
{t("app.recording")}
|
||||
@@ -289,6 +306,23 @@
|
||||
micPeak={recording.levelPeakMic}
|
||||
showMic={settings.settings.microphone_enabled}
|
||||
/>
|
||||
{#if settings.settings.microphone_enabled}
|
||||
<button
|
||||
class="mute-btn"
|
||||
class:muted={recording.micMuted}
|
||||
onclick={() => recording.toggleMute()}
|
||||
aria-pressed={recording.micMuted}
|
||||
aria-keyshortcuts="M"
|
||||
title={recording.micMuted ? t("app.unmute_title") : t("app.mute_title")}
|
||||
>
|
||||
{#if recording.micMuted}
|
||||
<MicOff size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<Mic size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="sr-only">{recording.micMuted ? t("app.unmute") : t("app.mute")}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if settings.hardware}
|
||||
<span class="backend" title={t("app.backend_title")}>{settings.hardware.active}</span>
|
||||
{/if}
|
||||
@@ -588,10 +622,6 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.bar strong {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -718,6 +748,26 @@
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
.mute-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mute-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.mute-btn.muted {
|
||||
color: var(--danger, #d33);
|
||||
border-color: var(--danger, #d33);
|
||||
background: color-mix(in srgb, var(--danger, #d33) 12%, transparent);
|
||||
}
|
||||
.retention {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+67
-4
@@ -56,6 +56,19 @@ export interface AudioDeviceInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Quick hardware stress test (Settings ▸ Hardware): per-(backend, model)
|
||||
// real-time factor, plus the recommended real-time-capable pairing.
|
||||
export interface StressResult {
|
||||
backend: string;
|
||||
model: string;
|
||||
rtf: number;
|
||||
realtime: boolean;
|
||||
}
|
||||
export interface StressTestResult {
|
||||
results: StressResult[];
|
||||
recommended: { backend: string; model: string } | null;
|
||||
}
|
||||
|
||||
export interface LlmStatus {
|
||||
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
|
||||
reachable: boolean;
|
||||
@@ -83,6 +96,19 @@ export interface LanguageOption {
|
||||
|
||||
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
|
||||
|
||||
// The four ordered phases of a background media import (import://progress).
|
||||
export type ImportPhase = "prepare" | "transcribe" | "diarize" | "finalize";
|
||||
|
||||
// One `import://progress` tick. `state` is active (running), done (finished,
|
||||
// `elapsedMs` set) or error (`error` message set) for the given `phase`.
|
||||
export interface ImportProgress {
|
||||
meetingId: MeetingId;
|
||||
phase: ImportPhase;
|
||||
state: "active" | "done" | "error";
|
||||
elapsedMs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface MeetingListItem {
|
||||
id: MeetingId;
|
||||
title: string;
|
||||
@@ -336,6 +362,12 @@ export interface AppSettings {
|
||||
audio_output_device: string | null;
|
||||
microphone_enabled: boolean;
|
||||
audio_input_device: string | null;
|
||||
/** Launch WhispAssist at login (opt-in, off by default; NFR-RES-4). Toggled
|
||||
* via setAutoStart, which writes a per-user Run entry (no admin). */
|
||||
auto_start: boolean;
|
||||
/** Closing the window hides to the tray (keep running in background) instead
|
||||
* of quitting; on by default. Tray "Quit" is the real exit. */
|
||||
close_to_tray: boolean;
|
||||
}
|
||||
|
||||
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
|
||||
@@ -398,6 +430,10 @@ export const api = {
|
||||
invoke<string>("recording_playback_path", { meetingId }),
|
||||
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
|
||||
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
|
||||
// Toggle mic mute for the active recording (FR-CAP-7); returns the new muted
|
||||
// state. Errors if the meeting was started with the mic off.
|
||||
toggleMicrophoneMute: (meetingId: MeetingId) =>
|
||||
invoke<boolean>("toggle_microphone_mute", { meetingId }),
|
||||
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
|
||||
invoke<void>("set_recording_retention", { meetingId, record }),
|
||||
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
|
||||
@@ -417,6 +453,11 @@ export const api = {
|
||||
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
|
||||
setPreferredBackend: (backend: BackendId | "auto") =>
|
||||
invoke<void>("set_preferred_backend", { args: { backend } }),
|
||||
setAutoStart: (enabled: boolean) => invoke<void>("set_auto_start", { enabled }),
|
||||
// Test a device: stream device://level for a few seconds. Resolves when done.
|
||||
monitorAudioLevel: (kind: "input" | "loopback", deviceId: string | null, durationMs = 6000) =>
|
||||
invoke<void>("monitor_audio_level", { kind, deviceId, durationMs }),
|
||||
stressTestHardware: () => invoke<StressTestResult>("stress_test_hardware"),
|
||||
downloadNpuPackage: () => invoke<void>("download_npu_package"),
|
||||
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
|
||||
listModels: () => invoke<ModelInfo[]>("list_models"),
|
||||
@@ -437,10 +478,12 @@ export const api = {
|
||||
invoke<void>("reprocess_transcript", { meetingId, model, language }),
|
||||
// Manually add a meeting from an existing recording — a local audio/video
|
||||
// file path or a URL (YouTube/streaming page or direct media URL). Requires
|
||||
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. Returns the new
|
||||
// meeting's id once transcription + diarization have finished.
|
||||
importMedia: (source: string, title?: string) =>
|
||||
invoke<MeetingId>("import_media", { source, title }),
|
||||
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. `model` overrides
|
||||
// the Settings whisper model for this one import. Returns the new meeting's id
|
||||
// *immediately*; transcode/transcribe/diarize run in the background and stream
|
||||
// `import://progress` ticks, finishing with `transcript://finalized`.
|
||||
importMedia: (source: string, title?: string, model?: string) =>
|
||||
invoke<MeetingId>("import_media", { source, title, model }),
|
||||
resumeTranscription: (meetingId: MeetingId) =>
|
||||
invoke<void>("resume_transcription", { meetingId }),
|
||||
listMeetings: (filter?: MeetingFilter) =>
|
||||
@@ -460,6 +503,10 @@ export const api = {
|
||||
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
|
||||
updateNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_notes", { meetingId, markdown }),
|
||||
// AI-enhance rough notes into structured Markdown grounded in the transcript
|
||||
// (Granola-style). Returns the enhanced text; the caller decides to keep it.
|
||||
enhanceNotes: (meetingId: MeetingId, notes: string) =>
|
||||
invoke<string>("enhance_notes", { meetingId, notes }),
|
||||
// dest is a file path for md/pdf/docx/obsidian, a folder for bundle.
|
||||
// "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4.
|
||||
exportMeeting: (
|
||||
@@ -588,12 +635,19 @@ export const events = {
|
||||
onDeviceChanged: (
|
||||
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("recording://device", (e) => cb(e.payload as never)),
|
||||
// Mic mute toggled for the active recording (FR-CAP-7).
|
||||
onMicMuted: (
|
||||
cb: (p: { meetingId: string; muted: boolean }) => void,
|
||||
): Promise<UnlistenFn> => listen("recording://mic", (e) => cb(e.payload as never)),
|
||||
onSegment: (
|
||||
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
|
||||
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
|
||||
onFinalized: (
|
||||
cb: (p: { meetingId: string; segmentCount: number }) => void,
|
||||
): Promise<UnlistenFn> => listen("transcript://finalized", (e) => cb(e.payload as never)),
|
||||
// Per-phase progress of a background media import (feeds the import tracker).
|
||||
onImportProgress: (cb: (p: ImportProgress) => void): Promise<UnlistenFn> =>
|
||||
listen("import://progress", (e) => cb(e.payload as never)),
|
||||
// Live diarization refined the speaker list mid-recording (FR-SPK): updated
|
||||
// labels/display names, including the mic speaker resolved to "You".
|
||||
onDiarizationUpdated: (
|
||||
@@ -609,6 +663,15 @@ export const events = {
|
||||
onHardwareChanged: (
|
||||
cb: (p: { active: BackendId; reason: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("hardware://changed", (e) => cb(e.payload as never)),
|
||||
// Live level meter for a device test (Settings ▸ Hardware). `done` marks the
|
||||
// end of the monitor window.
|
||||
onDeviceLevel: (
|
||||
cb: (p: { kind: string; rms?: number; peak?: number; done?: boolean }) => void,
|
||||
): Promise<UnlistenFn> => listen("device://level", (e) => cb(e.payload as never)),
|
||||
// Per-(backend, model) progress ticks during the quick stress test.
|
||||
onStressProgress: (
|
||||
cb: (p: { backend: string; model: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("stress://progress", (e) => cb(e.payload as never)),
|
||||
onNpuDownload: (
|
||||
cb: (p: {
|
||||
stage: "model" | "runtime" | "done";
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
<script lang="ts">
|
||||
// Manually add a meeting from an existing recording (feature: "add a meeting
|
||||
// + upload a video URL or audio file"). Transcoding is done by the backend
|
||||
// via ffmpeg (+ yt-dlp for URLs) — both external, not bundled — so this is
|
||||
// just a small form: pick a local file or paste a URL, optional title, go.
|
||||
import { api, errorMessage } from "../api";
|
||||
// via ffmpeg (+ yt-dlp for URLs) — both external, not bundled — so this is a
|
||||
// small form: pick a file or URL, choose the transcription model, go. Import
|
||||
// runs in the background (import_media returns as soon as the meeting row
|
||||
// exists), so this closes immediately and the meetings list shows progress.
|
||||
import { api, errorMessage, type ModelInfo } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { onMount } from "svelte";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { X, FileUp, Link as LinkIcon } from "@lucide/svelte";
|
||||
import { X, FileUp, Download } from "@lucide/svelte";
|
||||
|
||||
let { onClose, onImported }: { onClose: () => void; onImported: (id: string) => void } = $props();
|
||||
|
||||
// External download pages for the two tools this feature shells out to.
|
||||
const FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/latest";
|
||||
const YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest";
|
||||
|
||||
// `source` is either a local file path (set via Browse) or a URL (typed).
|
||||
let source = $state("");
|
||||
let title = $state("");
|
||||
let model = $state("");
|
||||
let models = $state<ModelInfo[]>([]);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Only installed whisper models are selectable; default to the active one so
|
||||
// the pick matches the user's Settings default unless they change it here.
|
||||
onMount(async () => {
|
||||
try {
|
||||
const all = await api.listModels();
|
||||
models = all.filter((m) => m.installed);
|
||||
model = models.find((m) => m.active)?.id ?? models[0]?.id ?? "";
|
||||
} catch {
|
||||
models = [];
|
||||
}
|
||||
});
|
||||
|
||||
async function browse() {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
@@ -51,7 +72,7 @@
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const id = await api.importMedia(source.trim(), title.trim() || undefined);
|
||||
const id = await api.importMedia(source.trim(), title.trim() || undefined, model || undefined);
|
||||
onImported(id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -101,17 +122,32 @@
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="wide">
|
||||
<div class="grid">
|
||||
<label>
|
||||
{t("import.model_label")}
|
||||
<select bind:value={model} disabled={busy || models.length === 0}>
|
||||
{#each models as m (m.id)}
|
||||
<option value={m.id}>{m.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="hint">{t("import.model_hint")}</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("import.title_label")} <em>({t("import.optional")})</em>
|
||||
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="tools">
|
||||
<span class="muted small">{t("import.requires")}</span>
|
||||
<button class="tool" type="button" onclick={() => api.openUrl(FFMPEG_URL)}>
|
||||
<Download size={12} aria-hidden="true" /> ffmpeg
|
||||
</button>
|
||||
<button class="tool" type="button" onclick={() => api.openUrl(YTDLP_URL)}>
|
||||
<Download size={12} aria-hidden="true" /> yt-dlp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
@@ -121,6 +157,7 @@
|
||||
<button class="primary" onclick={doImport} disabled={!source.trim() || busy}>
|
||||
{busy ? t("import.importing") : t("import.import")}
|
||||
</button>
|
||||
<span class="muted small note">{t("import.background_note")}</span>
|
||||
<button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +180,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
width: min(520px, 100%);
|
||||
width: min(540px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
@@ -179,7 +216,8 @@
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
@@ -189,10 +227,18 @@
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus-visible {
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
@@ -213,18 +259,49 @@
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Model + title side by side on wide panels, stacked when cramped. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 0.75rem;
|
||||
}
|
||||
@media (max-width: 460px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--bg-hover, transparent);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.small {
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #d33);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -232,6 +309,10 @@
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.actions .note {
|
||||
flex: 1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.actions .primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg, #fff);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
// Domino's-pizza-tracker-style progress for a background media import: four
|
||||
// ordered steps, the running one pulses, finished ones show how long they
|
||||
// took. Fed by the `imports` store (import://progress events). Renders nothing
|
||||
// until the first tick arrives. Design per ui-ux-pro-max: color is never the
|
||||
// only signal (icon + label + time), tabular figures for the timers, and the
|
||||
// pulse is dropped under prefers-reduced-motion.
|
||||
import { imports } from "../stores/imports.svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { AudioLines, Captions, Users, FileCheck2, Check, X } from "@lucide/svelte";
|
||||
|
||||
let { meetingId }: { meetingId: string } = $props();
|
||||
|
||||
const run = $derived(imports.get(meetingId));
|
||||
|
||||
const ICONS = {
|
||||
prepare: AudioLines,
|
||||
transcribe: Captions,
|
||||
diarize: Users,
|
||||
finalize: FileCheck2,
|
||||
} as const;
|
||||
|
||||
// ms → compact, human duration for a finished step ("820 ms", "4.3s", "2m 05s").
|
||||
function fmtDur(ms: number | null): string {
|
||||
if (ms == null) return "";
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
const s = ms / 1000;
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = Math.round(s % 60);
|
||||
return `${m}m ${String(rem).padStart(2, "0")}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if run}
|
||||
<section class="tracker" aria-label={t("import.tracker.label")}>
|
||||
<header>
|
||||
{#if run.error}
|
||||
<span class="head err">{t("import.tracker.failed")}</span>
|
||||
{:else if run.done}
|
||||
<span class="head ok">{t("import.tracker.done")}</span>
|
||||
{:else}
|
||||
<span class="head">{t("import.tracker.running")}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<ol class="steps" aria-live="polite">
|
||||
{#each run.phases as p (p.phase)}
|
||||
{@const Icon = ICONS[p.phase]}
|
||||
<li class="step {p.state}">
|
||||
<div class="node">
|
||||
{#if p.state === "done"}
|
||||
<Check size={18} aria-hidden="true" />
|
||||
{:else if p.state === "error"}
|
||||
<X size={18} aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="name">{t(`import.phase.${p.phase}`)}</span>
|
||||
<span class="time">
|
||||
{#if p.state === "done"}{fmtDur(p.elapsedMs)}
|
||||
{:else if p.state === "active"}{t("import.tracker.working")}
|
||||
{:else if p.state === "error"}{t("import.tracker.stopped")}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if run.error}
|
||||
<p class="msg">{run.error}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tracker {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel, var(--bg-elevated));
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.head {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.head.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.head.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.step {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Connector from the previous node's center to this one's (each step is the
|
||||
same width, so -50%→+50% spans center to center), sitting behind the node. */
|
||||
.step::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: -50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
z-index: 0;
|
||||
}
|
||||
.step:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
.step.done::before,
|
||||
.step.active::before,
|
||||
.step.error::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0 auto 0.45rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
}
|
||||
.step.active .node {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft, transparent);
|
||||
color: var(--accent);
|
||||
animation: pulse 1.4s ease-out infinite;
|
||||
}
|
||||
.step.done .node {
|
||||
border-color: var(--success);
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
.step.error .node {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
.name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.step.active .name,
|
||||
.step.done .name {
|
||||
color: var(--fg);
|
||||
}
|
||||
.time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-height: 1em;
|
||||
}
|
||||
.step.active .time {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--danger);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.step.active .node {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+54
-5
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Switch to a multilingual model above to choose a language.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.refresh": "Refresh",
|
||||
"settings.hardware.test_output": "Test",
|
||||
"settings.hardware.test_mic": "Test",
|
||||
"settings.hardware.testing": "Listening…",
|
||||
"settings.hardware.play_tone": "Play tone",
|
||||
"settings.hardware.stress_title": "Quick stress test",
|
||||
"settings.hardware.stress_hint": "Benchmarks your installed models on each available backend and recommends the most accurate one that still keeps up with live speech. Takes a moment.",
|
||||
"settings.hardware.stress_run": "Run stress test",
|
||||
"settings.hardware.stress_running": "Running…",
|
||||
"settings.hardware.stress_progress": "Benchmarking {pair}…",
|
||||
"settings.hardware.stress_recommend": "Recommended: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Apply",
|
||||
"settings.hardware.stress_none": "No installed model keeps up with live speech on this hardware — try a smaller model.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Model",
|
||||
"settings.hardware.stress_rtf": "Speed (×real-time)",
|
||||
"settings.hardware.stress_realtime": "Live?",
|
||||
"settings.hardware.stress_yes": "Yes",
|
||||
"settings.hardware.stress_no": "No",
|
||||
"settings.hardware.active_backend": "Active backend",
|
||||
"settings.hardware.model_meta": "· model {size}",
|
||||
"settings.hardware.preferred_backend": "Preferred backend",
|
||||
@@ -278,6 +297,7 @@
|
||||
"settings.privacy.locked_word": "locked",
|
||||
"settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.",
|
||||
"settings.privacy.password": "Password",
|
||||
"settings.privacy.show_password": "Show password",
|
||||
"settings.privacy.unlock": "Unlock",
|
||||
"settings.privacy.vault_unlocked_1": "Vault is ",
|
||||
"settings.privacy.unlocked_word": "unlocked",
|
||||
@@ -320,12 +340,23 @@
|
||||
"import.optional": "optional",
|
||||
"import.title_placeholder": "Defaults to the file name",
|
||||
"import.filter_av": "Audio / video",
|
||||
"import.requires_1": "Requires",
|
||||
"import.requires_2": "installed and on your PATH (plus",
|
||||
"import.requires_3": "for URLs). WhispAssist doesn't bundle them.",
|
||||
"import.importing": "Importing… this can take a while",
|
||||
"import.model_label": "Transcription model",
|
||||
"import.model_hint": "Recorded with the meeting so you can see how it was transcribed.",
|
||||
"import.requires": "Needs these on your PATH (not bundled):",
|
||||
"import.background_note": "Runs in the background — track it in the list.",
|
||||
"import.importing": "Starting…",
|
||||
"import.import": "Import",
|
||||
"import.cancel": "Cancel",
|
||||
"import.tracker.label": "Import progress",
|
||||
"import.tracker.running": "Importing…",
|
||||
"import.tracker.done": "Import complete",
|
||||
"import.tracker.failed": "Import failed",
|
||||
"import.tracker.working": "working…",
|
||||
"import.tracker.stopped": "stopped",
|
||||
"import.phase.prepare": "Transcode",
|
||||
"import.phase.transcribe": "Transcribe",
|
||||
"import.phase.diarize": "Identify speakers",
|
||||
"import.phase.finalize": "Finalize",
|
||||
|
||||
"tagchip.filter": "Filter meetings tagged \"{tag}\"",
|
||||
"tagchip.remove": "Remove tag {tag}",
|
||||
@@ -345,6 +376,10 @@
|
||||
"app.cancel": "Cancel",
|
||||
"app.cancel_title": "Discard this recording and delete it",
|
||||
"app.recording": "Recording…",
|
||||
"app.mute": "Mute microphone",
|
||||
"app.unmute": "Unmute microphone",
|
||||
"app.mute_title": "Mute microphone (M)",
|
||||
"app.unmute_title": "Unmute microphone (M)",
|
||||
"app.backend_title": "Active transcription backend",
|
||||
"app.retention_title": "Save audio as .wav for this meeting",
|
||||
"app.saving": "saving",
|
||||
@@ -397,6 +432,7 @@
|
||||
|
||||
"transcript.heading": "Transcript",
|
||||
"transcript.title_aria": "Meeting title",
|
||||
"transcript.transcribed_with": "Transcribed with",
|
||||
"transcript.lang_title": "Transcription language",
|
||||
"transcript.lang_auto": "auto-detecting…",
|
||||
"transcript.show": "Show transcript",
|
||||
@@ -428,9 +464,18 @@
|
||||
"notes.italic": "Italic",
|
||||
"notes.h1": "Heading 1",
|
||||
"notes.h2": "Heading 2",
|
||||
"notes.h3": "Heading 3",
|
||||
"notes.bullet": "Bullet list",
|
||||
"notes.numbered": "Numbered list",
|
||||
"notes.quote": "Quote",
|
||||
"notes.divider": "Divider",
|
||||
"notes.checkbox_title": "Checkbox",
|
||||
"notes.checkbox_aria": "Checkbox list item",
|
||||
"notes.enhance": "Enhance",
|
||||
"notes.enhancing": "Enhancing…",
|
||||
"notes.enhance_title": "Expand these notes into structured Markdown using the transcript (AI)",
|
||||
"notes.enhanced_note": "Notes enhanced from the transcript.",
|
||||
"notes.undo_enhance": "Undo",
|
||||
"notes.edit_raw": "Edit the raw markdown",
|
||||
"notes.render": "Render the markdown",
|
||||
"notes.editor": "Editor",
|
||||
@@ -527,5 +572,9 @@
|
||||
"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."
|
||||
"settings.recording.auto_hint": "Only while WhispAssist is open. When an imported calendar event's start time arrives, a recording begins automatically (using your default retention setting above). Nothing runs in the background — the timer is armed only while the app is running. Import events under Settings → Calendar.",
|
||||
"settings.recording.autostart_label": "Launch WhispAssist at login",
|
||||
"settings.recording.autostart_hint": "Starts WhispAssist automatically when you sign in to Windows. Off by default; installs a per-user startup entry (no admin required) and does not begin recording on its own.",
|
||||
"settings.recording.close_tray_label": "Close to system tray",
|
||||
"settings.recording.close_tray_hint": "Closing the window keeps WhispAssist running in the background instead of quitting. Reopen it from the tray icon; use the tray's Quit to exit fully. On by default."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Live per-meeting progress of background media imports (feeds ImportTracker).
|
||||
// Fed entirely by `import://progress` events emitted by `import_media`; kept in
|
||||
// memory only (the meeting's `status` badge is the persistent story after a
|
||||
// restart). See commands.rs `run_import_pipeline`.
|
||||
|
||||
import { events, type ImportPhase, type ImportProgress, type MeetingId } from "../api";
|
||||
|
||||
export type PhaseState = "pending" | "active" | "done" | "error";
|
||||
|
||||
// The four phases in the order the backend runs (and the tracker renders) them.
|
||||
export const IMPORT_PHASES: ImportPhase[] = ["prepare", "transcribe", "diarize", "finalize"];
|
||||
|
||||
export interface PhaseInfo {
|
||||
phase: ImportPhase;
|
||||
state: PhaseState;
|
||||
elapsedMs: number | null;
|
||||
}
|
||||
|
||||
export interface ImportRun {
|
||||
meetingId: MeetingId;
|
||||
phases: PhaseInfo[];
|
||||
error: string | null;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
function freshRun(meetingId: MeetingId): ImportRun {
|
||||
return {
|
||||
meetingId,
|
||||
phases: IMPORT_PHASES.map((phase) => ({ phase, state: "pending", elapsedMs: null })),
|
||||
error: null,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
class ImportsStore {
|
||||
runs = $state<Record<MeetingId, ImportRun>>({});
|
||||
|
||||
get(meetingId: MeetingId): ImportRun | undefined {
|
||||
return this.runs[meetingId];
|
||||
}
|
||||
|
||||
async init() {
|
||||
await events.onImportProgress((p) => this.apply(p));
|
||||
}
|
||||
|
||||
private apply(p: ImportProgress) {
|
||||
// Re-read through the record after inserting so we mutate the $state proxy,
|
||||
// not the raw object (Svelte 5 deep reactivity only tracks the proxy).
|
||||
if (!this.runs[p.meetingId]) this.runs[p.meetingId] = freshRun(p.meetingId);
|
||||
const run = this.runs[p.meetingId];
|
||||
const info = run.phases.find((x) => x.phase === p.phase);
|
||||
if (!info) return;
|
||||
info.state = p.state;
|
||||
if (p.state === "done") info.elapsedMs = p.elapsedMs;
|
||||
if (p.state === "error") {
|
||||
run.error = p.error;
|
||||
run.done = true;
|
||||
}
|
||||
if (p.phase === "finalize" && p.state === "done") run.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const imports = new ImportsStore();
|
||||
@@ -22,6 +22,9 @@ class RecordingStore {
|
||||
* (FR-CAP-7); stays 0 when the mic is disabled or not recording. */
|
||||
levelRmsMic = $state(0);
|
||||
levelPeakMic = $state(0);
|
||||
/** Mic muted for the in-flight meeting (FR-CAP-7): mic channel goes silent
|
||||
* while loopback keeps recording. Toggled by the "M" key / mute button. */
|
||||
micMuted = $state(false);
|
||||
/** Set while a capture-device reconnect is in progress; cleared on recovery (FR-CAP-6). */
|
||||
deviceNotice = $state<string | null>(null);
|
||||
/** Live notes redesign: freeform text typed in the Notes pane while recording. */
|
||||
@@ -72,6 +75,22 @@ class RecordingStore {
|
||||
await events.onDeviceChanged(({ recovered, message }) => {
|
||||
this.deviceNotice = recovered ? null : message;
|
||||
});
|
||||
// Keep mute state in sync even if it was toggled elsewhere (e.g. a future
|
||||
// tray control), not just from this store's toggleMute().
|
||||
await events.onMicMuted(({ muted }) => {
|
||||
this.micMuted = muted;
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle mic mute for the active recording (FR-CAP-7); no-op if not
|
||||
* recording. Optimistically flips, then reconciles with the backend result. */
|
||||
async toggleMute() {
|
||||
if (!this.meetingId || this.state === "idle") return;
|
||||
try {
|
||||
this.micMuted = await api.toggleMicrophoneMute(this.meetingId);
|
||||
} catch {
|
||||
// Mic off for this meeting (or capture gone) — nothing to mute.
|
||||
}
|
||||
}
|
||||
|
||||
async start(title?: string, record = false, templateId?: string, calendarEventId?: string) {
|
||||
@@ -79,6 +98,7 @@ class RecordingStore {
|
||||
this.speakers = [];
|
||||
this.retention = record;
|
||||
this.deviceNotice = null;
|
||||
this.micMuted = false;
|
||||
this.notesText = "";
|
||||
this.segmentNotes.clear();
|
||||
// T8.7/FR-TRX-4: whatever language is currently configured in Settings
|
||||
@@ -98,6 +118,7 @@ class RecordingStore {
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
this.micMuted = false;
|
||||
this.deviceNotice = null;
|
||||
}
|
||||
|
||||
@@ -116,6 +137,7 @@ class RecordingStore {
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
this.micMuted = false;
|
||||
this.deviceNotice = null;
|
||||
this.notesText = "";
|
||||
this.segmentNotes.clear();
|
||||
|
||||
@@ -48,6 +48,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
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
|
||||
auto_start: false, // launch at login — opt-in, off by default (NFR-RES-4)
|
||||
close_to_tray: true, // closing the window hides to tray; on by default
|
||||
};
|
||||
|
||||
class SettingsStore {
|
||||
@@ -383,6 +385,18 @@ class SettingsStore {
|
||||
return this.patch({ default_record: on });
|
||||
}
|
||||
|
||||
/** Launch-at-login toggle (NFR-RES-4). Goes through its own command (not
|
||||
* patch) since the backend also writes the per-user OS Run entry; that
|
||||
* command persists auto_start itself, so we just mirror it locally. */
|
||||
async setAutoStart(on: boolean) {
|
||||
this.settings = { ...this.settings, auto_start: on };
|
||||
try {
|
||||
await api.setAutoStart(on);
|
||||
} catch {
|
||||
this.backendStub = true;
|
||||
}
|
||||
}
|
||||
|
||||
setRetentionPolicy(maxAgeDays: number | null, maxSizeGb: number | null) {
|
||||
return this.patch({ retention_max_age_days: maxAgeDays, retention_max_size_gb: maxSizeGb });
|
||||
}
|
||||
|
||||
+396
-24
@@ -9,9 +9,16 @@
|
||||
import { t, i18n, LOCALES } from "../i18n/index.svelte";
|
||||
import ConsentNotice from "../components/ConsentNotice.svelte";
|
||||
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||
import LevelMeter from "../components/LevelMeter.svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { api, errorMessage, events } from "../api";
|
||||
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
|
||||
import type {
|
||||
BackendId,
|
||||
StressTestResult,
|
||||
SyncKind,
|
||||
SyncTargetConfig,
|
||||
SyncTargetInfo,
|
||||
} from "../api";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import {
|
||||
X,
|
||||
@@ -23,7 +30,11 @@
|
||||
CalendarDays,
|
||||
UploadCloud,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
@@ -381,11 +392,98 @@
|
||||
let showConsent = $state(false);
|
||||
let testResult = $state<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
// ---- Audio device test (live level meter) ----
|
||||
let monitorKind = $state<"input" | "loopback" | null>(null);
|
||||
let monitorRms = $state(0);
|
||||
let monitorPeak = $state(0);
|
||||
let monitorUnlisten: (() => void) | null = null;
|
||||
function stopMonitor() {
|
||||
monitorUnlisten?.();
|
||||
monitorUnlisten = null;
|
||||
monitorKind = null;
|
||||
monitorRms = 0;
|
||||
monitorPeak = 0;
|
||||
}
|
||||
async function testDevice(kind: "input" | "loopback") {
|
||||
if (monitorKind) return;
|
||||
monitorKind = kind;
|
||||
monitorRms = 0;
|
||||
monitorPeak = 0;
|
||||
monitorUnlisten = await events.onDeviceLevel((p) => {
|
||||
if (p.kind !== kind) return;
|
||||
if (p.done) {
|
||||
stopMonitor();
|
||||
return;
|
||||
}
|
||||
monitorRms = p.rms ?? 0;
|
||||
monitorPeak = p.peak ?? 0;
|
||||
});
|
||||
const deviceId =
|
||||
kind === "input"
|
||||
? (settings.settings.audio_input_device ?? null)
|
||||
: (settings.settings.audio_output_device ?? null);
|
||||
try {
|
||||
await api.monitorAudioLevel(kind, deviceId, 6000);
|
||||
} catch (e) {
|
||||
testResult = { ok: false, message: errorMessage(e) };
|
||||
} finally {
|
||||
stopMonitor();
|
||||
}
|
||||
}
|
||||
// A 440Hz beep to the default output so the user can confirm speakers work.
|
||||
function playTone() {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = 440;
|
||||
gain.gain.value = 0.15;
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
osc.onended = () => ctx.close();
|
||||
} catch {
|
||||
/* no Web Audio available */
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Quick hardware stress test ----
|
||||
let stressRunning = $state(false);
|
||||
let stressProgress = $state<string | null>(null);
|
||||
let stressResult = $state<StressTestResult | null>(null);
|
||||
let stressError = $state<string | null>(null);
|
||||
async function runStressTest() {
|
||||
if (stressRunning) return;
|
||||
stressRunning = true;
|
||||
stressError = null;
|
||||
stressResult = null;
|
||||
const un = await events.onStressProgress((p) => {
|
||||
stressProgress = `${p.backend} · ${p.model}`;
|
||||
});
|
||||
try {
|
||||
stressResult = await api.stressTestHardware();
|
||||
} catch (e) {
|
||||
stressError = errorMessage(e);
|
||||
} finally {
|
||||
un();
|
||||
stressProgress = null;
|
||||
stressRunning = false;
|
||||
}
|
||||
}
|
||||
async function applyRecommendation() {
|
||||
const r = stressResult?.recommended;
|
||||
if (!r) return;
|
||||
await settings.setPreferredBackend(r.backend as BackendId | "auto");
|
||||
await settings.patch({ whisper_model: r.model });
|
||||
}
|
||||
|
||||
// ---- At-rest encryption vault (T8.8, FR-SEC-3) ----
|
||||
let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null);
|
||||
let vaultPw = $state("");
|
||||
let vaultPw2 = $state("");
|
||||
let vaultMsg = $state<string | null>(null);
|
||||
let vaultMsgError = $state(false);
|
||||
let showVaultPw = $state(false);
|
||||
async function loadVault() {
|
||||
try {
|
||||
vault = await api.vaultStatus();
|
||||
@@ -394,42 +492,46 @@
|
||||
}
|
||||
}
|
||||
onMount(loadVault);
|
||||
function setVaultMsg(msg: string | null, isError = false) {
|
||||
vaultMsg = msg;
|
||||
vaultMsgError = isError;
|
||||
}
|
||||
async function enableVault() {
|
||||
vaultMsg = null;
|
||||
setVaultMsg(null);
|
||||
try {
|
||||
await api.enableVault(vaultPw);
|
||||
vaultPw = "";
|
||||
vaultMsg = "Vault enabled and unlocked.";
|
||||
setVaultMsg("Vault enabled and unlocked.");
|
||||
await loadVault();
|
||||
} catch (e) {
|
||||
vaultMsg = errorMessage(e);
|
||||
setVaultMsg(errorMessage(e), true);
|
||||
}
|
||||
}
|
||||
async function unlockVault() {
|
||||
vaultMsg = null;
|
||||
setVaultMsg(null);
|
||||
try {
|
||||
await api.unlockVault(vaultPw);
|
||||
vaultPw = "";
|
||||
vaultMsg = "Unlocked.";
|
||||
setVaultMsg("Unlocked.");
|
||||
await loadVault();
|
||||
} catch (e) {
|
||||
vaultMsg = errorMessage(e);
|
||||
setVaultMsg(errorMessage(e), true);
|
||||
}
|
||||
}
|
||||
async function lockVault() {
|
||||
await api.lockVault();
|
||||
vaultMsg = "Locked.";
|
||||
setVaultMsg("Locked.");
|
||||
await loadVault();
|
||||
}
|
||||
async function changeVaultPassword() {
|
||||
vaultMsg = null;
|
||||
setVaultMsg(null);
|
||||
try {
|
||||
await api.changeVaultPassword(vaultPw, vaultPw2);
|
||||
vaultPw = "";
|
||||
vaultPw2 = "";
|
||||
vaultMsg = "Password changed.";
|
||||
setVaultMsg("Password changed.");
|
||||
} catch (e) {
|
||||
vaultMsg = errorMessage(e);
|
||||
setVaultMsg(errorMessage(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,13 +856,40 @@
|
||||
</label>
|
||||
<p class="muted">{t("settings.recording.auto_hint")}</p>
|
||||
|
||||
<label class="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.settings.auto_start}
|
||||
onchange={(e) => settings.setAutoStart((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>{t("settings.recording.autostart_label")}</span>
|
||||
</label>
|
||||
<p class="muted">{t("settings.recording.autostart_hint")}</p>
|
||||
|
||||
<label class="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.settings.close_to_tray}
|
||||
onchange={(e) =>
|
||||
settings.patch({ close_to_tray: (e.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
<span>{t("settings.recording.close_tray_label")}</span>
|
||||
</label>
|
||||
<p class="muted">{t("settings.recording.close_tray_hint")}</p>
|
||||
|
||||
{#if showConsent}
|
||||
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
|
||||
{/if}
|
||||
</section>
|
||||
{:else if section === "hardware"}
|
||||
<section>
|
||||
<div class="actions">
|
||||
<h3>{t("settings.hardware.title")}</h3>
|
||||
<button class="ghost" onclick={() => settings.loadHardware()}>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
{t("settings.hardware.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
{#if settings.hardware}
|
||||
<div class="row">
|
||||
{t("settings.hardware.active_backend")}<code>{settings.hardware.active}</code>
|
||||
@@ -804,6 +933,20 @@
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">{t("settings.hardware.recording_device_hint")}</p>
|
||||
<div class="device-test">
|
||||
<button onclick={() => testDevice("loopback")} disabled={monitorKind !== null}>
|
||||
<Volume2 size={13} aria-hidden="true" />
|
||||
{monitorKind === "loopback"
|
||||
? t("settings.hardware.testing")
|
||||
: t("settings.hardware.test_output")}
|
||||
</button>
|
||||
<button onclick={playTone} disabled={monitorKind !== null}>
|
||||
{t("settings.hardware.play_tone")}
|
||||
</button>
|
||||
{#if monitorKind === "loopback"}
|
||||
<LevelMeter rms={monitorRms} peak={monitorPeak} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label
|
||||
>{t("settings.hardware.microphone")}
|
||||
@@ -825,6 +968,75 @@
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">{t("settings.hardware.mic_hint")}</p>
|
||||
<div class="device-test">
|
||||
<button
|
||||
onclick={() => testDevice("input")}
|
||||
disabled={monitorKind !== null || !settings.settings.microphone_enabled}
|
||||
>
|
||||
<Mic size={13} aria-hidden="true" />
|
||||
{monitorKind === "input"
|
||||
? t("settings.hardware.testing")
|
||||
: t("settings.hardware.test_mic")}
|
||||
</button>
|
||||
{#if monitorKind === "input"}
|
||||
<LevelMeter rms={monitorRms} peak={monitorPeak} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h4>{t("settings.hardware.stress_title")}</h4>
|
||||
<p class="muted">{t("settings.hardware.stress_hint")}</p>
|
||||
<button onclick={runStressTest} disabled={stressRunning}>
|
||||
<Zap size={13} aria-hidden="true" />
|
||||
{stressRunning ? t("settings.hardware.stress_running") : t("settings.hardware.stress_run")}
|
||||
</button>
|
||||
{#if stressProgress}
|
||||
<p class="muted">{t("settings.hardware.stress_progress", { pair: stressProgress })}</p>
|
||||
{/if}
|
||||
{#if stressError}<p class="muted err">{stressError}</p>{/if}
|
||||
{#if stressResult}
|
||||
{#if stressResult.recommended}
|
||||
<div class="stress-rec">
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
<span
|
||||
>{t("settings.hardware.stress_recommend", {
|
||||
backend: stressResult.recommended.backend,
|
||||
model: stressResult.recommended.model,
|
||||
})}</span
|
||||
>
|
||||
<button class="primary" onclick={applyRecommendation}
|
||||
>{t("settings.hardware.stress_apply")}</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{t("settings.hardware.stress_none")}</p>
|
||||
{/if}
|
||||
<table class="stress-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("settings.hardware.stress_backend")}</th>
|
||||
<th>{t("settings.hardware.stress_model")}</th>
|
||||
<th>{t("settings.hardware.stress_rtf")}</th>
|
||||
<th>{t("settings.hardware.stress_realtime")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each stressResult.results as r (r.backend + r.model)}
|
||||
<tr>
|
||||
<td>{r.backend}</td>
|
||||
<td>{r.model}</td>
|
||||
<td class="num">{r.rtf.toFixed(2)}×</td>
|
||||
<td>
|
||||
{#if r.realtime}
|
||||
<Check size={13} aria-hidden="true" /> {t("settings.hardware.stress_yes")}
|
||||
{:else}
|
||||
{t("settings.hardware.stress_no")}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
{#if settings.hardware.npu?.present}
|
||||
{@const npu = settings.hardware.npu}
|
||||
@@ -1918,34 +2130,81 @@
|
||||
{/if}
|
||||
|
||||
{#if vault}
|
||||
<div
|
||||
class="vault-card"
|
||||
class:locked={vault.enabled && !vault.unlocked}
|
||||
class:unlocked={vault.enabled && vault.unlocked}
|
||||
>
|
||||
<div class="vault-head">
|
||||
{#if !vault.enabled}
|
||||
<ShieldCheck size={18} aria-hidden="true" />
|
||||
{:else if !vault.unlocked}
|
||||
<Lock size={18} aria-hidden="true" />
|
||||
{:else}
|
||||
<LockOpen size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
<h4>{t("settings.privacy.vault_title")}</h4>
|
||||
{#if vault.enabled}
|
||||
<span class="badge" class:busy={!vault.unlocked}>
|
||||
{vault.unlocked
|
||||
? t("settings.privacy.unlocked_word")
|
||||
: t("settings.privacy.locked_word")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !vault.enabled}
|
||||
<p class="muted">{t("settings.privacy.vault_intro")}</p>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.vault_password")}<input
|
||||
type="password"
|
||||
<div class="pw-row">
|
||||
<input
|
||||
type={showVaultPw ? "text" : "password"}
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
placeholder={t("settings.privacy.vault_password")}
|
||||
aria-label={t("settings.privacy.vault_password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon pw-toggle"
|
||||
onclick={() => (showVaultPw = !showVaultPw)}
|
||||
aria-label={t("settings.privacy.show_password")}
|
||||
title={t("settings.privacy.show_password")}
|
||||
>
|
||||
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
|
||||
>{t("settings.privacy.enable_vault")}</button
|
||||
>
|
||||
<p class="muted">{t("settings.privacy.vault_pw_hint")}</p>
|
||||
<p class="muted hint">{t("settings.privacy.vault_pw_hint")}</p>
|
||||
{:else if !vault.unlocked}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_locked_1")}<strong
|
||||
>{t("settings.privacy.locked_word")}</strong
|
||||
>{t("settings.privacy.vault_locked_2")}
|
||||
</p>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.password")}<input
|
||||
type="password"
|
||||
<div class="pw-row">
|
||||
<input
|
||||
type={showVaultPw ? "text" : "password"}
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
placeholder={t("settings.privacy.password")}
|
||||
aria-label={t("settings.privacy.password")}
|
||||
onkeydown={(e) => e.key === "Enter" && vaultPw && unlockVault()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon pw-toggle"
|
||||
onclick={() => (showVaultPw = !showVaultPw)}
|
||||
aria-label={t("settings.privacy.show_password")}
|
||||
title={t("settings.privacy.show_password")}
|
||||
>
|
||||
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
|
||||
>{t("settings.privacy.unlock")}</button
|
||||
@@ -1956,7 +2215,10 @@
|
||||
>{t("settings.privacy.unlocked_word")}</strong
|
||||
>{t("settings.privacy.vault_unlocked_2")}
|
||||
</p>
|
||||
<button onclick={lockVault}>{t("settings.privacy.lock_now")}</button>
|
||||
<button onclick={lockVault}>
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
{t("settings.privacy.lock_now")}
|
||||
</button>
|
||||
<details>
|
||||
<summary>{t("settings.privacy.change_password")}</summary>
|
||||
<div class="grid">
|
||||
@@ -1978,7 +2240,8 @@
|
||||
>
|
||||
</details>
|
||||
{/if}
|
||||
{#if vaultMsg}<p class="muted">{vaultMsg}</p>{/if}
|
||||
{#if vaultMsg}<p class="vault-msg" class:error={vaultMsgError}>{vaultMsg}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if section === "language"}
|
||||
@@ -2429,6 +2692,115 @@
|
||||
color: var(--accent, #2563eb);
|
||||
border-color: currentColor;
|
||||
}
|
||||
.vault-card {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.vault-card.locked {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.vault-card.unlocked {
|
||||
border-color: color-mix(in srgb, var(--success, #16a34a) 45%, var(--border));
|
||||
}
|
||||
.vault-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.vault-head h4 {
|
||||
margin: 0;
|
||||
}
|
||||
.vault-head :global(svg) {
|
||||
color: var(--muted);
|
||||
}
|
||||
.vault-card.locked .vault-head :global(svg) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.vault-card.unlocked .vault-head :global(svg) {
|
||||
color: var(--success, #16a34a);
|
||||
}
|
||||
.vault-head .badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
.pw-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
max-width: 22rem;
|
||||
}
|
||||
.pw-row input {
|
||||
flex: 1;
|
||||
}
|
||||
.pw-toggle {
|
||||
flex: none;
|
||||
}
|
||||
.vault-card .hint {
|
||||
margin: 0;
|
||||
}
|
||||
.vault-msg {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--success, #16a34a);
|
||||
}
|
||||
.vault-msg.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.device-test {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.35rem 0 0.6rem;
|
||||
}
|
||||
.device-test :global(.meter) {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.stress-rec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.6rem 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg));
|
||||
}
|
||||
.stress-rec :global(svg) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.stress-rec span {
|
||||
flex: 1;
|
||||
}
|
||||
.stress-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.stress-table th,
|
||||
.stress-table td {
|
||||
text-align: left;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stress-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.stress-table .num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
.npu-package {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
// nonce so re-clicking the same segment still jumps). Reading seekNonce is
|
||||
// what makes this effect re-run.
|
||||
$effect(() => {
|
||||
player.seekNonce;
|
||||
void player.seekNonce;
|
||||
const ms = player.seekMs;
|
||||
if (ms == null || !audioEl) return;
|
||||
audioEl.currentTime = ms / 1000;
|
||||
@@ -770,9 +770,13 @@
|
||||
{#if editableItems.length === 0}
|
||||
<p class="muted">{t("summary.ai_empty")}</p>
|
||||
{:else}
|
||||
<!-- Two-row card per item: the action text owns the full first row (it
|
||||
was unreadable when six controls shared one row in this narrow
|
||||
pane); owner/due/reminder are a secondary meta row beneath it. -->
|
||||
<ul class="action-items">
|
||||
{#each editableItems as item, i (i)}
|
||||
<li>
|
||||
<div class="ai-main">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={item.confirmed}
|
||||
@@ -785,6 +789,16 @@
|
||||
placeholder={t("summary.ai_text_placeholder")}
|
||||
aria-label={t("summary.ai_text_aria")}
|
||||
/>
|
||||
<button
|
||||
class="ai-del"
|
||||
onclick={() => removeActionItem(i)}
|
||||
title={t("summary.ai_delete_title")}
|
||||
aria-label={t("summary.ai_delete_aria")}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-meta">
|
||||
<input
|
||||
class="ai-owner"
|
||||
value={item.owner ?? ""}
|
||||
@@ -803,14 +817,7 @@
|
||||
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</label>
|
||||
<button
|
||||
class="ai-del"
|
||||
onclick={() => removeActionItem(i)}
|
||||
title={t("summary.ai_delete_title")}
|
||||
aria-label={t("summary.ai_delete_aria")}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -1080,9 +1087,9 @@
|
||||
}
|
||||
ul.action-items li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0;
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
ul.action-items label {
|
||||
@@ -1090,14 +1097,28 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ai-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.ai-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
/* Meta row indented under the text (past the confirm checkbox), wrapping
|
||||
rather than crushing its inputs when the pane is narrow. */
|
||||
.ai-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
.ai-owner {
|
||||
flex: none;
|
||||
width: 5rem;
|
||||
flex: 1;
|
||||
min-width: 5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ai-del {
|
||||
|
||||
@@ -6,19 +6,27 @@
|
||||
import { meetings } from "../stores/meetings.svelte";
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import { player } from "../stores/player.svelte";
|
||||
import { api, type SpeakerInfo } from "../api";
|
||||
import { api, errorMessage, type SpeakerInfo } from "../api";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
import { layout, clamp } from "../stores/layout.svelte";
|
||||
import { imports } from "../stores/imports.svelte";
|
||||
import Splitter from "../components/Splitter.svelte";
|
||||
import ImportTracker from "../components/ImportTracker.svelte";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
Quote,
|
||||
Minus,
|
||||
Sparkles,
|
||||
Undo2,
|
||||
FileDown,
|
||||
FileText,
|
||||
FolderOutput,
|
||||
@@ -66,6 +74,10 @@
|
||||
let editorEl: HTMLTextAreaElement | undefined = $state();
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let loadedForId: string | null = null;
|
||||
// The server copy the buffer was last synced against — lets the effect below
|
||||
// tell a server-side notes change (speaker rename, reprocess) apart from the
|
||||
// user's own unsaved edits.
|
||||
let lastServerNotes: string | null = null;
|
||||
|
||||
// Transcript/notes split (FR-UX-1): resizable (drag the Splitter) and
|
||||
// each side independently hideable, shared across the finalized-meeting
|
||||
@@ -103,14 +115,23 @@
|
||||
selectedSegmentMs = null;
|
||||
});
|
||||
|
||||
// Sync the editor buffer whenever a different meeting is selected.
|
||||
// Sync the editor buffer whenever a different meeting is selected — and when
|
||||
// the *server* copy of the same meeting's notes changes underneath us (a
|
||||
// speaker rename rewrites notes.md's dialogue tags, reprocess regenerates it).
|
||||
// A buffer with unsaved local edits is never clobbered: it only adopts the
|
||||
// server copy when it still equals the last-synced one.
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
if (m && m.id !== loadedForId) {
|
||||
notesText = m.notes_markdown;
|
||||
lastServerNotes = m.notes_markdown;
|
||||
loadedForId = m.id;
|
||||
} else if (m && m.id === loadedForId && m.notes_markdown !== lastServerNotes) {
|
||||
if (notesText === lastServerNotes) notesText = m.notes_markdown;
|
||||
lastServerNotes = m.notes_markdown;
|
||||
} else if (!m) {
|
||||
loadedForId = null;
|
||||
lastServerNotes = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -158,6 +179,70 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
// Slash commands: typing "/todo" (etc.) at the start of a line and pressing
|
||||
// Space/Enter swaps it for the matching Markdown prefix. Reuses the same
|
||||
// line-prefix model as the toolbar buttons — no rich inline menu.
|
||||
// ponytail: line-prefix slash only; add a picker popover if users ask.
|
||||
const SLASH_COMMANDS: Record<string, string> = {
|
||||
h1: "# ",
|
||||
h2: "## ",
|
||||
h3: "### ",
|
||||
todo: "- [ ] ",
|
||||
bullet: "- ",
|
||||
num: "1. ",
|
||||
quote: "> ",
|
||||
divider: "---\n",
|
||||
};
|
||||
function handleNotesKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
const el = editorEl;
|
||||
if (!el) return;
|
||||
const { selectionStart: s, value } = el;
|
||||
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
|
||||
const match = /^\/(\w+)$/.exec(value.slice(lineStart, s));
|
||||
if (!match) return;
|
||||
const prefix = SLASH_COMMANDS[match[1].toLowerCase()];
|
||||
if (prefix === undefined) return;
|
||||
e.preventDefault();
|
||||
const head = value.slice(0, lineStart) + prefix;
|
||||
notesText = head + value.slice(s);
|
||||
queueMicrotask(() => {
|
||||
el.focus();
|
||||
el.selectionStart = el.selectionEnd = head.length;
|
||||
});
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
// AI-enhance (Granola-style): expand the user's rough notes into structured
|
||||
// Markdown grounded in the transcript, via the configured LlmProvider (local
|
||||
// by default, no new egress). Keeps a one-step Undo so we never silently lose
|
||||
// what the user typed.
|
||||
let enhancing = $state(false);
|
||||
let enhanceError = $state<string | null>(null);
|
||||
let notesBeforeEnhance = $state<string | null>(null);
|
||||
async function enhanceNotes() {
|
||||
const m = meetings.selected;
|
||||
if (!m || enhancing) return;
|
||||
enhancing = true;
|
||||
enhanceError = null;
|
||||
try {
|
||||
const enhanced = await api.enhanceNotes(m.id, notesText);
|
||||
notesBeforeEnhance = notesText;
|
||||
notesText = enhanced;
|
||||
scheduleSave();
|
||||
} catch (e) {
|
||||
enhanceError = errorMessage(e);
|
||||
} finally {
|
||||
enhancing = false;
|
||||
}
|
||||
}
|
||||
function undoEnhance() {
|
||||
if (notesBeforeEnhance === null) return;
|
||||
notesText = notesBeforeEnhance;
|
||||
notesBeforeEnhance = null;
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function exportMd() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
@@ -247,6 +332,15 @@
|
||||
reprocessing = true;
|
||||
try {
|
||||
await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined);
|
||||
// Re-transcribe rebuilds notes.md server-side (merging saved manual notes),
|
||||
// but the meeting stays selected (same id), so the buffer-sync $effect —
|
||||
// which only fires on an id change — won't pick it up. Resync explicitly so
|
||||
// the notes pane updates in place instead of only after a restart.
|
||||
const updated = meetings.selected;
|
||||
if (updated && updated.id === m.id) {
|
||||
notesText = updated.notes_markdown;
|
||||
loadedForId = updated.id;
|
||||
}
|
||||
} finally {
|
||||
reprocessing = false;
|
||||
}
|
||||
@@ -262,14 +356,23 @@
|
||||
onchange={onTitleChange}
|
||||
aria-label={t("transcript.title_aria")}
|
||||
/>
|
||||
{#if m.status === "transcribing" || imports.get(m.id)}
|
||||
<div class="import-strip"><ImportTracker meetingId={m.id} /></div>
|
||||
{:else if m.model_used}
|
||||
<p class="engine-meta" title={t("transcript.transcribed_with")}>
|
||||
{t("transcript.transcribed_with")}
|
||||
<strong>{m.model_used}</strong>{#if m.backend_used}
|
||||
· {m.backend_used}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<div
|
||||
class="split"
|
||||
style="grid-template-columns: {splitColumns()};"
|
||||
bind:clientWidth={splitWidth}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -- wheel/touchmove
|
||||
here only note "the user scrolled by hand" to pause playback
|
||||
auto-scroll; the pane isn't an interactive control. -->
|
||||
<!-- wheel/touchmove here only note "the user scrolled by hand" to pause
|
||||
playback auto-scroll; the pane isn't an interactive control. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="pane transcript"
|
||||
class:collapsed={layout.transcriptCollapsed}
|
||||
@@ -423,6 +526,43 @@
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("### ")}
|
||||
title={t("notes.h3")}
|
||||
aria-label={t("notes.h3")}
|
||||
>
|
||||
<Heading3 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("1. ")}
|
||||
title={t("notes.numbered")}
|
||||
aria-label={t("notes.numbered")}
|
||||
>
|
||||
<ListOrdered size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("> ")}
|
||||
title={t("notes.quote")}
|
||||
aria-label={t("notes.quote")}
|
||||
>
|
||||
<Quote size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("---\n")}
|
||||
title={t("notes.divider")}
|
||||
aria-label={t("notes.divider")}
|
||||
>
|
||||
<Minus size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="enhance"
|
||||
onclick={enhanceNotes}
|
||||
disabled={enhancing}
|
||||
title={t("notes.enhance_title")}
|
||||
>
|
||||
<Sparkles size={14} aria-hidden="true" class={enhancing ? "spin" : ""} />
|
||||
{enhancing ? t("notes.enhancing") : t("notes.enhance")}
|
||||
</button>
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={() => (notesPreview = !notesPreview)}
|
||||
@@ -459,6 +599,17 @@
|
||||
Obsidian
|
||||
</button>
|
||||
</div>
|
||||
{#if enhanceError}
|
||||
<p class="enhance-bar error" role="alert">{enhanceError}</p>
|
||||
{:else if notesBeforeEnhance !== null}
|
||||
<div class="enhance-bar">
|
||||
<span>{t("notes.enhanced_note")}</span>
|
||||
<button class="link" onclick={undoEnhance}>
|
||||
<Undo2 size={13} aria-hidden="true" />
|
||||
{t("notes.undo_enhance")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="editor-preview">
|
||||
{#if notesPreview}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
@@ -468,6 +619,7 @@
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
onkeydown={handleNotesKeydown}
|
||||
placeholder={t("notes.placeholder")}
|
||||
></textarea>
|
||||
{/if}
|
||||
@@ -610,6 +762,21 @@
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
}
|
||||
.import-strip {
|
||||
flex: none;
|
||||
padding: 0 1rem 0.5rem;
|
||||
}
|
||||
.engine-meta {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 0 1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.engine-meta strong {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.pad {
|
||||
padding: 1rem;
|
||||
max-width: 760px;
|
||||
@@ -842,6 +1009,40 @@
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.toolbar .enhance {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar .enhance:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg));
|
||||
}
|
||||
.toolbar .enhance:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.enhance-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.enhance-bar.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.enhance-bar .link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
height: calc(100% - 2.5rem);
|
||||
|
||||
Reference in New Issue
Block a user